input_boolean
) and trigger its opening/closing in an automation.
+ diff --git a/custom_components/battery_notes/__pycache__/discovery.cpython-313.pyc b/custom_components/battery_notes/__pycache__/discovery.cpython-313.pyc index ae9d2b8f..b2c09210 100644 Binary files a/custom_components/battery_notes/__pycache__/discovery.cpython-313.pyc and b/custom_components/battery_notes/__pycache__/discovery.cpython-313.pyc differ diff --git a/custom_components/battery_notes/__pycache__/library.cpython-313.pyc b/custom_components/battery_notes/__pycache__/library.cpython-313.pyc index 2e2047da..fe455ef0 100644 Binary files a/custom_components/battery_notes/__pycache__/library.cpython-313.pyc and b/custom_components/battery_notes/__pycache__/library.cpython-313.pyc differ diff --git a/custom_components/battery_notes/__pycache__/repairs.cpython-313.pyc b/custom_components/battery_notes/__pycache__/repairs.cpython-313.pyc index 043edd74..fac67e51 100644 Binary files a/custom_components/battery_notes/__pycache__/repairs.cpython-313.pyc and b/custom_components/battery_notes/__pycache__/repairs.cpython-313.pyc differ diff --git a/custom_components/battery_notes/__pycache__/store.cpython-313.pyc b/custom_components/battery_notes/__pycache__/store.cpython-313.pyc index e0d6f5e7..e14307c8 100644 Binary files a/custom_components/battery_notes/__pycache__/store.cpython-313.pyc and b/custom_components/battery_notes/__pycache__/store.cpython-313.pyc differ diff --git a/custom_components/battery_notes/data/library.json b/custom_components/battery_notes/data/library.json index d4ce2676..c2afa885 100644 --- a/custom_components/battery_notes/data/library.json +++ b/custom_components/battery_notes/data/library.json @@ -913,11 +913,23 @@ "model_id": "ZNJLBL01LM", "battery_type": "Rechargeable" }, + { + "manufacturer": "Aqara", + "model": "Roller shade driver E1 (ZNJLBL01LM)", + "battery_type": "Rechargeable" + }, { "manufacturer": "Aqara", "model": "RTCGQ11LM", "battery_type": "CR2450" }, + { + "manufacturer": "Aqara", + "model": "SDL-D01", + "hw_version": "1.0.0", + "battery_type": "AA", + "battery_quantity": 4 + }, { "manufacturer": "Aqara", "model": "Smart radiator thermostat E1 (SRTS-A01)", @@ -966,6 +978,11 @@ "model": "Wireless mini switch (WXKG11LM)", "battery_type": "CR2032" }, + { + "manufacturer": "Aqara", + "model": "Wireless mini switch T1 (WXKG13LM)", + "battery_type": "CR2032" + }, { "manufacturer": "Aqara", "model": "Wireless remote switch (double rocker), 2018 model (WXKG02LM_rev2)", @@ -976,6 +993,11 @@ "model": "Wireless remote switch (single rocker), 2018 model (WXKG03LM_rev2)", "battery_type": "CR2032" }, + { + "manufacturer": "Aqara", + "model": "Wireless remote switch D1 (double rocker) (WXKG07LM)", + "battery_type": "CR2032" + }, { "manufacturer": "Aqara", "model": "Wireless remote switch H1 (double rocker) (WXKG15LM)", @@ -1255,6 +1277,11 @@ "battery_type": "AA", "battery_quantity": 4 }, + { + "manufacturer": "BOSCH", + "model": "MD", + "battery_type": "CR123A" + }, { "manufacturer": "Bosch", "model": "Motion sensor (ISW-ZPR1-WP13)", @@ -1281,6 +1308,13 @@ "battery_type": "AA", "battery_quantity": 2 }, + { + "manufacturer": "Bosch", + "model": "Room thermostat II", + "model_id": "RBSH-RTH0-BAT-ZB-EU", + "battery_type": "AAA", + "battery_quantity": 4 + }, { "manufacturer": "Bosch", "model": "Smoke alarm detector (BSD-2)", @@ -1310,6 +1344,11 @@ "battery_type": "AA", "battery_quantity": 2 }, + { + "manufacturer": "by Philio Technology Corp", + "model": "PSP05", + "battery_type": "CR123A" + }, { "manufacturer": "Centralite", "model": "3157100", @@ -1521,6 +1560,12 @@ "model": "MT2756", "battery_type": "CR123A" }, + { + "manufacturer": "Dome", + "model": "Siren", + "battery_type": "CR123A", + "battery_quantity": 2 + }, { "manufacturer": "Doogee", "model": "S98 Pro", @@ -2383,6 +2428,12 @@ "battery_type": "AA", "battery_quantity": 2 }, + { + "manufacturer": "First Alert", + "model": "Smoke Alarm", + "battery_type": "AA", + "battery_quantity": 2 + }, { "manufacturer": "First Alert (BRK Brands Inc)", "model": "ZCOMBO", @@ -2494,6 +2545,12 @@ "battery_type": "AA", "battery_quantity": 4 }, + { + "manufacturer": "GiEX", + "model": "Water valve (GX02)", + "battery_type": "AA", + "battery_quantity": 4 + }, { "manufacturer": "Google", "model": "A12", @@ -2505,6 +2562,12 @@ "model": "KR1", "battery_type": "CR2" }, + { + "manufacturer": "Google", + "model": "Nest Protect", + "battery_type": "AA", + "battery_quantity": 6 + }, { "manufacturer": "Google", "model": "Pixel 2", @@ -2693,6 +2756,11 @@ "model": "Smoke detector (HS3SA/HS1SA)", "battery_type": "CR123A" }, + { + "manufacturer": "Heiman", + "model": "SmokeSensor-EF-3.0", + "battery_type": "CR123A" + }, { "manufacturer": "HEIMAN", "model": "Water leakage sensor (HS1WL/HS3WL)", @@ -2814,6 +2882,12 @@ "battery_type": "AA", "battery_quantity": 12 }, + { + "manufacturer": "Hunter Douglas", + "model": "Designer Roller", + "battery_type": "AA", + "battery_quantity": 12 + }, { "manufacturer": "Hunter Douglas", "model": "Duette", @@ -2825,6 +2899,11 @@ "model": "Duette by Hunter Douglas", "battery_type": "Rechargeable" }, + { + "manufacturer": "Hunter Douglas", + "model": "Silhouette", + "battery_type": "MANUAL" + }, { "manufacturer": "Hunter Douglas", "model": "Vertical Slats, Left Stack", @@ -2924,6 +3003,13 @@ "battery_type": "AAA", "battery_quantity": 2 }, + { + "manufacturer": "IKEA", + "model": "SYMFONISK sound remote, gen 1", + "model_id": "E1744", + "hw_version": "24.4.5", + "battery_type": "CR2032" + }, { "manufacturer": "Ikea", "model": "SYMFONISK Sound remote, gen 2", @@ -2967,13 +3053,19 @@ { "manufacturer": "IKEA", "model": "TRADFRI shortcut button (E1812)", - "battery_type": "CR2450" + "battery_type": "CR2032" }, { "manufacturer": "IKEA", "model": "TRADFRI wireless dimmer (ICTC-G-1)", "battery_type": "CR2032" }, + { + "manufacturer": "IKEA", + "model": "TREDANSEN", + "model_id": "E2103", + "battery_type": "BRAUNIT" + }, { "manufacturer": "Ikea", "model": "TREDANSEN cellular blind (E2103)", @@ -3205,6 +3297,12 @@ "battery_type": "AA", "battery_quantity": 4 }, + { + "manufacturer": "Kwikset", + "model": "910 SmartCode traditional electronic deadbolt (99100-006)", + "battery_type": "AA", + "battery_quantity": 4 + }, { "manufacturer": "Kwikset", "model": "912", @@ -3310,6 +3408,12 @@ "battery_type": "AAA", "battery_quantity": 2 }, + { + "manufacturer": "Lidl", + "model": "Parkside smart watering timer (PSBZS A1)", + "battery_type": "AA", + "battery_quantity": 2 + }, { "manufacturer": "Lidl", "model": "Silvercrest radiator valve with thermostat (368308_2010)", @@ -3326,6 +3430,12 @@ "model": "Silvercrest smart motion sensor (HG06335/HG07310)", "battery_type": "MANUAL" }, + { + "manufacturer": "Lidl", + "model": "Silvercrest smart window and door sensor (HG06336)", + "battery_type": "AAA", + "battery_quantity": 2 + }, { "manufacturer": "LifeControl", "model": "Door sensor (MCLH-04)", @@ -3585,6 +3695,11 @@ "model": "ms100", "battery_type": "CR2477" }, + { + "manufacturer": "Mi", + "model": "MS009", + "battery_type": "CR2540" + }, { "manufacturer": "Mi light sensor", "model": "GZCGQ01LM", @@ -4447,6 +4562,30 @@ "model": "RuuviTag", "battery_type": "CR2477" }, + { + "manufacturer": "SAF Tehnika", + "model": "ARANET2", + "battery_type": "AA", + "battery_quantity": 2 + }, + { + "manufacturer": "SAF Tehnika", + "model": "ARANET4", + "battery_type": "AA", + "battery_quantity": 2 + }, + { + "manufacturer": "SAF Tehnika", + "model": "ARANET_RADIATION", + "battery_type": "AA", + "battery_quantity": 2 + }, + { + "manufacturer": "SAF Tehnika", + "model": "ARANET_RADON", + "battery_type": "AA", + "battery_quantity": 2 + }, { "manufacturer": "Samjin", "model": "button", @@ -5261,6 +5400,12 @@ "battery_quantity": 4, "model_match_method": "startswith" }, + { + "manufacturer": "switchbot", + "model": "Leak Detector", + "battery_type": "AAA", + "battery_quantity": 2 + }, { "manufacturer": "SwitchBot", "model": "Meter", @@ -5422,6 +5567,11 @@ "model": "TP357", "battery_type": "AAA" }, + { + "manufacturer": "ThermoPro", + "model": "TP357S", + "battery_type": "AAA" + }, { "manufacturer": "Third Reality", "model": "3RSB015BZ", @@ -5471,6 +5621,12 @@ "battery_type": "AAA", "battery_quantity": 2 }, + { + "manufacturer": "Third Reality", + "model": "Temperature and humidity sensor lite (3RTHS0224Z)", + "battery_type": "AAA", + "battery_quantity": 2 + }, { "manufacturer": "Third Reality", "model": "Water sensor (3RWS18BZ)", @@ -5575,6 +5731,12 @@ "model": "SmokeSensor-EM", "battery_type": "CR123A" }, + { + "manufacturer": "Trust", + "model": "Wireless contact sensor", + "model_id": "ZCTS-808", + "battery_type": "CR2032" + }, { "manufacturer": "Trust", "model": "ZYCT-202", @@ -6113,6 +6275,12 @@ "model": "TS0044_1", "battery_type": "CR2430" }, + { + "manufacturer": "Tuya", + "model": "TS0044_1", + "battery_type": "AAA", + "battery_quantity": 2 + }, { "manufacturer": "TuYa", "model": "TS0201_1", @@ -6249,6 +6417,13 @@ "battery_type": "AA", "battery_quantity": 2 }, + { + "manufacturer": "Tuya", + "model": "Zigbee 4 button remote - 12 scene (TS0044_1) by Tuya", + "model_id": "TS0044_1", + "battery_type": "AAA", + "battery_quantity": 2 + }, { "manufacturer": "TuYa", "model": "Zigbee fingerbot plus (TS0001_fingerbot)", @@ -6475,6 +6650,12 @@ "battery_type": "CR123A", "battery_quantity": 2 }, + { + "manufacturer": "Withings", + "model": "Smart Body Analyzer", + "battery_type": "AAA", + "battery_quantity": 4 + }, { "manufacturer": "Woox", "model": "Smart garden irrigation control (R7060)", @@ -6722,6 +6903,11 @@ "model": "BTHome sensor", "battery_type": "MANUAL" }, + { + "manufacturer": "Xiaomi", + "model": "CGDK2", + "battery_type": "CR2430" + }, { "manufacturer": "Xiaomi", "model": "DJT11LM", @@ -6877,6 +7063,11 @@ "model": "RTCGQ01LM", "battery_type": "CR2450" }, + { + "manufacturer": "Xiaomi", + "model": "SJWS01LM", + "battery_type": "CR2032" + }, { "manufacturer": "Xiaomi", "model": "SRTS-A01", diff --git a/custom_components/bodymiscale/__pycache__/__init__.cpython-312.pyc b/custom_components/bodymiscale/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 88fecb5b..00000000 Binary files a/custom_components/bodymiscale/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bodymiscale/__pycache__/config_flow.cpython-312.pyc b/custom_components/bodymiscale/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index 5137af82..00000000 Binary files a/custom_components/bodymiscale/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bodymiscale/__pycache__/config_flow.cpython-313.pyc b/custom_components/bodymiscale/__pycache__/config_flow.cpython-313.pyc index 24f99378..c73c69a9 100644 Binary files a/custom_components/bodymiscale/__pycache__/config_flow.cpython-313.pyc and b/custom_components/bodymiscale/__pycache__/config_flow.cpython-313.pyc differ diff --git a/custom_components/bodymiscale/__pycache__/const.cpython-312.pyc b/custom_components/bodymiscale/__pycache__/const.cpython-312.pyc deleted file mode 100644 index d221ed01..00000000 Binary files a/custom_components/bodymiscale/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bodymiscale/__pycache__/entity.cpython-312.pyc b/custom_components/bodymiscale/__pycache__/entity.cpython-312.pyc deleted file mode 100644 index d270c485..00000000 Binary files a/custom_components/bodymiscale/__pycache__/entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bodymiscale/__pycache__/models.cpython-312.pyc b/custom_components/bodymiscale/__pycache__/models.cpython-312.pyc deleted file mode 100644 index 0efe4f57..00000000 Binary files a/custom_components/bodymiscale/__pycache__/models.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bodymiscale/__pycache__/sensor.cpython-312.pyc b/custom_components/bodymiscale/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index d94a5bda..00000000 Binary files a/custom_components/bodymiscale/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bodymiscale/__pycache__/util.cpython-312.pyc b/custom_components/bodymiscale/__pycache__/util.cpython-312.pyc deleted file mode 100644 index 86456203..00000000 Binary files a/custom_components/bodymiscale/__pycache__/util.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bodymiscale/metrics/__pycache__/__init__.cpython-312.pyc b/custom_components/bodymiscale/metrics/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e2567190..00000000 Binary files a/custom_components/bodymiscale/metrics/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bodymiscale/metrics/__pycache__/__init__.cpython-313.pyc b/custom_components/bodymiscale/metrics/__pycache__/__init__.cpython-313.pyc index c72f9134..ab66cbfc 100644 Binary files a/custom_components/bodymiscale/metrics/__pycache__/__init__.cpython-313.pyc and b/custom_components/bodymiscale/metrics/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/bodymiscale/metrics/__pycache__/body_score.cpython-312.pyc b/custom_components/bodymiscale/metrics/__pycache__/body_score.cpython-312.pyc deleted file mode 100644 index bf0a604e..00000000 Binary files a/custom_components/bodymiscale/metrics/__pycache__/body_score.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bodymiscale/metrics/__pycache__/impedance.cpython-312.pyc b/custom_components/bodymiscale/metrics/__pycache__/impedance.cpython-312.pyc deleted file mode 100644 index 0ffdda87..00000000 Binary files a/custom_components/bodymiscale/metrics/__pycache__/impedance.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bodymiscale/metrics/__pycache__/scale.cpython-312.pyc b/custom_components/bodymiscale/metrics/__pycache__/scale.cpython-312.pyc deleted file mode 100644 index 76b5ce21..00000000 Binary files a/custom_components/bodymiscale/metrics/__pycache__/scale.cpython-312.pyc and /dev/null differ diff --git a/custom_components/bodymiscale/metrics/__pycache__/weight.cpython-312.pyc b/custom_components/bodymiscale/metrics/__pycache__/weight.cpython-312.pyc deleted file mode 100644 index 6b7de7e6..00000000 Binary files a/custom_components/bodymiscale/metrics/__pycache__/weight.cpython-312.pyc and /dev/null differ diff --git a/custom_components/delete/__pycache__/__init__.cpython-312.pyc b/custom_components/delete/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index fa92b3bc..00000000 Binary files a/custom_components/delete/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/elkbledom/__init__.py b/custom_components/elkbledom/__init__.py index 8faa6bf8..4be1220d 100644 --- a/custom_components/elkbledom/__init__.py +++ b/custom_components/elkbledom/__init__.py @@ -35,8 +35,9 @@ async def _async_stop(event: Event) -> None: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) ) + return True - + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/elkbledom/__pycache__/__init__.cpython-312.pyc b/custom_components/elkbledom/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 27e88e7d..00000000 Binary files a/custom_components/elkbledom/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/elkbledom/__pycache__/__init__.cpython-313.pyc b/custom_components/elkbledom/__pycache__/__init__.cpython-313.pyc index 880a54a9..80986ee8 100644 Binary files a/custom_components/elkbledom/__pycache__/__init__.cpython-313.pyc and b/custom_components/elkbledom/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/elkbledom/__pycache__/config_flow.cpython-312.pyc b/custom_components/elkbledom/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index 8db664b9..00000000 Binary files a/custom_components/elkbledom/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/elkbledom/__pycache__/config_flow.cpython-313.pyc b/custom_components/elkbledom/__pycache__/config_flow.cpython-313.pyc index 002e2723..7046c79c 100644 Binary files a/custom_components/elkbledom/__pycache__/config_flow.cpython-313.pyc and b/custom_components/elkbledom/__pycache__/config_flow.cpython-313.pyc differ diff --git a/custom_components/elkbledom/__pycache__/const.cpython-312.pyc b/custom_components/elkbledom/__pycache__/const.cpython-312.pyc deleted file mode 100644 index db39dd6d..00000000 Binary files a/custom_components/elkbledom/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/elkbledom/__pycache__/const.cpython-313.pyc b/custom_components/elkbledom/__pycache__/const.cpython-313.pyc index 4aaaa46d..db5ba8fb 100644 Binary files a/custom_components/elkbledom/__pycache__/const.cpython-313.pyc and b/custom_components/elkbledom/__pycache__/const.cpython-313.pyc differ diff --git a/custom_components/elkbledom/__pycache__/elkbledom.cpython-312.pyc b/custom_components/elkbledom/__pycache__/elkbledom.cpython-312.pyc deleted file mode 100644 index 71d123ed..00000000 Binary files a/custom_components/elkbledom/__pycache__/elkbledom.cpython-312.pyc and /dev/null differ diff --git a/custom_components/elkbledom/__pycache__/elkbledom.cpython-313.pyc b/custom_components/elkbledom/__pycache__/elkbledom.cpython-313.pyc index 29b389a4..69d6db10 100644 Binary files a/custom_components/elkbledom/__pycache__/elkbledom.cpython-313.pyc and b/custom_components/elkbledom/__pycache__/elkbledom.cpython-313.pyc differ diff --git a/custom_components/elkbledom/__pycache__/light.cpython-312.pyc b/custom_components/elkbledom/__pycache__/light.cpython-312.pyc deleted file mode 100644 index 6d653535..00000000 Binary files a/custom_components/elkbledom/__pycache__/light.cpython-312.pyc and /dev/null differ diff --git a/custom_components/elkbledom/__pycache__/light.cpython-313.pyc b/custom_components/elkbledom/__pycache__/light.cpython-313.pyc index 0b8610e6..7b1cca51 100644 Binary files a/custom_components/elkbledom/__pycache__/light.cpython-313.pyc and b/custom_components/elkbledom/__pycache__/light.cpython-313.pyc differ diff --git a/custom_components/elkbledom/__pycache__/number.cpython-312.pyc b/custom_components/elkbledom/__pycache__/number.cpython-312.pyc deleted file mode 100644 index aaaa8d6c..00000000 Binary files a/custom_components/elkbledom/__pycache__/number.cpython-312.pyc and /dev/null differ diff --git a/custom_components/elkbledom/__pycache__/number.cpython-313.pyc b/custom_components/elkbledom/__pycache__/number.cpython-313.pyc index 04d77f00..1bfee568 100644 Binary files a/custom_components/elkbledom/__pycache__/number.cpython-313.pyc and b/custom_components/elkbledom/__pycache__/number.cpython-313.pyc differ diff --git a/custom_components/elkbledom/elkbledom.py b/custom_components/elkbledom/elkbledom.py index 39993205..d0320aad 100644 --- a/custom_components/elkbledom/elkbledom.py +++ b/custom_components/elkbledom/elkbledom.py @@ -1,6 +1,5 @@ import asyncio from datetime import datetime -from homeassistant.components import bluetooth from homeassistant.exceptions import ConfigEntryNotReady from bleak.backends.device import BLEDevice @@ -67,25 +66,31 @@ NAME_ARRAY = ["ELK-BLE", "LEDBLE", "MELK", - "ELK-BULB"] + "ELK-BULB", + "ELK-LAMPL"] WRITE_CHARACTERISTIC_UUIDS = ["0000fff3-0000-1000-8000-00805f9b34fb", "0000ffe1-0000-1000-8000-00805f9b34fb", "0000fff3-0000-1000-8000-00805f9b34fb", + "0000fff3-0000-1000-8000-00805f9b34fb", "0000fff3-0000-1000-8000-00805f9b34fb"] READ_CHARACTERISTIC_UUIDS = ["0000fff4-0000-1000-8000-00805f9b34fb", "0000ffe2-0000-1000-8000-00805f9b34fb", "0000fff4-0000-1000-8000-00805f9b34fb", + "0000fff4-0000-1000-8000-00805f9b34fb", "0000fff4-0000-1000-8000-00805f9b34fb"] TURN_ON_CMD = [[0x7e, 0x00, 0x04, 0xf0, 0x00, 0x01, 0xff, 0x00, 0xef], + [0x7e, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0xef], [0x7e, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0xef], [0x7e, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0xef], [0x7e, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0xef]] TURN_OFF_CMD = [[0x7e, 0x00, 0x04, 0x00, 0x00, 0x00, 0xff, 0x00, 0xef], + [0x7e, 0x00, 0x04, 0x00, 0x00, 0x00, 0xff, 0x00, 0xef], [0x7e, 0x00, 0x04, 0x00, 0x00, 0x00, 0xff, 0x00, 0xef], [0x7e, 0x00, 0x04, 0x00, 0x00, 0x00, 0xff, 0x00, 0xef], [0x7e, 0x00, 0x04, 0x00, 0x00, 0x00, 0xff, 0x00, 0xef]] -MIN_COLOR_TEMPS_K = [2700,2700,2700,2700] -MAX_COLOR_TEMPS_K = [6500,6500,6500,6500] + +MIN_COLOR_TEMPS_K = [2700,2700,2700,2700,2700] +MAX_COLOR_TEMPS_K = [6500,6500,6500,6500,6500] DEFAULT_ATTEMPTS = 3 #DISCONNECT_DELAY = 120 @@ -132,7 +137,7 @@ async def _async_wrap_retry_bluetooth_connection_error( class DeviceData(): def __init__(self, hass, discovery_info): self._discovery = discovery_info - self._supported = self._discovery.name.lower().startswith("elk-ble") or self._discovery.name.lower().startswith("elk-bulb") or self._discovery.name.lower().startswith("ledble") or self._discovery.name.lower().startswith("melk") + self._supported = any(self._discovery.name.lower().startswith(option.lower()) for option in NAME_ARRAY) self._address = self._discovery.address self._name = self._discovery.name self._rssi = self._discovery.rssi @@ -150,20 +155,6 @@ def __init__(self, hass, discovery_info): # if not self._bledevice: # raise ConfigEntryNotReady(f"You need to add bluetooth integration (https://www.home-assistant.io/integrations/bluetooth) or couldn't find a nearby device with address: {address}") - - # def __init__(self, *args): - # if isinstance(args[0], BluetoothServiceInfoBleak): - # self._discovery = args[0] - # self._supported = self._discovery.name.lower().startswith("elk-ble") or self._discovery.name.lower().startswith("elk-bulb") or self._discovery.name.lower().startswith("ledble") or self._discovery.name.lower().startswith("melk") - # self.address = self._discovery.address - # self.name = self._discovery.name - # self.rssi = self._discovery.rssi - # else: - # self._supported = args[0] - # self.address = args[1] - # self.name = args[2] - # self.rssi = args[3] - @property def is_supported(self) -> bool: return self._supported @@ -475,9 +466,12 @@ async def _ensure_connected(self) -> None: #login commands await self._login_command() - if not self._device.name.lower().startswith("melk"): - LOGGER.debug("%s: Subscribe to notifications; RSSI: %s", self.name, self.rssi) - await client.start_notify(self._read_uuid, self._notification_handler) + try: + if not self._device.name.lower().startswith("melk") and not self._device.name.lower().startswith("ledble"): + LOGGER.debug("%s: Subscribe to notifications; RSSI: %s", self.name, self.rssi) + await client.start_notify(self._read_uuid, self._notification_handler) + except Exception as e: + LOGGER.error("Error during connection: %s", e) async def _login_command(self): try: @@ -564,8 +558,6 @@ async def _execute_timed_disconnect(self) -> None: self._delay, ) await self._execute_disconnect() - - async def _execute_disconnect(self) -> None: """Execute disconnection.""" async with self._connect_lock: @@ -578,8 +570,8 @@ async def _execute_disconnect(self) -> None: self._read_uuid = None if client and client.is_connected: try: - if not self._device.name.lower().startswith("melk"): + if not self._device.name.lower().startswith("melk") and not self._device.name.lower().startswith("ledble"): await client.stop_notify(read_char) await client.disconnect() except Exception as e: - LOGGER.error("Error during disconnection: %s", e) + LOGGER.error("Error during disconnection: %s", e) \ No newline at end of file diff --git a/custom_components/elkbledom/light.py b/custom_components/elkbledom/light.py index 7e16c02b..b3e36f31 100644 --- a/custom_components/elkbledom/light.py +++ b/custom_components/elkbledom/light.py @@ -8,7 +8,7 @@ from .const import DOMAIN, EFFECTS, EFFECTS_list from homeassistant.const import CONF_MAC -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.components.light import ( PLATFORM_SCHEMA, diff --git a/custom_components/elkbledom/manifest.json b/custom_components/elkbledom/manifest.json index 6854cb5e..9802ce9b 100644 --- a/custom_components/elkbledom/manifest.json +++ b/custom_components/elkbledom/manifest.json @@ -5,7 +5,8 @@ { "local_name": "ELK-BULB*" }, { "local_name": "ELK-BLE*" }, { "local_name": "MELK*" }, - { "local_name": "LEDBLE*" } + { "local_name": "LEDBLE*" }, + { "local_name": "ELK-LAMPL*" } ], "codeowners": ["@dave-code-ruiz"], "config_flow": true, @@ -13,6 +14,6 @@ "documentation": "https://github.com/dave-code-ruiz/elkbledom", "iot_class": "local_polling", "issue_tracker": "https://github.com/dave-code-ruiz/elkbledom/issues", - "requirements": ["bleak-retry-connector>=1.17.1","bleak>=0.17.0"], - "version": "1.0.2" + "requirements": ["bleak-retry-connector>=3.5.0","bleak>=0.22.2"], + "version": "1.2.2" } \ No newline at end of file diff --git a/custom_components/elkbledom/translations/en.json b/custom_components/elkbledom/translations/en.json index c12a5342..b1143ff2 100644 --- a/custom_components/elkbledom/translations/en.json +++ b/custom_components/elkbledom/translations/en.json @@ -28,7 +28,9 @@ }, "abort": { "cannot_validate": "Unable to validate Elkbledom light", - "cannot_connect": "Unable to connect to Elkbledom" + "cannot_connect": "Unable to connect to Elkbledom", + "not_supported": "Elkbledom light not supported", + "invalid_discovery_info": "Invalid discovery info in bluetooth device" } }, "options": { diff --git a/custom_components/elkbledom/translations/es.json b/custom_components/elkbledom/translations/es.json index 8cfe34a0..af044e0f 100644 --- a/custom_components/elkbledom/translations/es.json +++ b/custom_components/elkbledom/translations/es.json @@ -28,7 +28,9 @@ }, "abort": { "cannot_validate": "No se puede validar la conexión", - "cannot_connect": "No se puede conectar con la tira led" + "cannot_connect": "No se puede conectar con la tira led", + "not_supported": "Tira led no soportada", + "invalid_discovery_info": "No se encuentra información de la tira led" } }, "options": { diff --git a/custom_components/elkbledom/translations/fr.json b/custom_components/elkbledom/translations/fr.json index 5bd092de..bd871572 100644 --- a/custom_components/elkbledom/translations/fr.json +++ b/custom_components/elkbledom/translations/fr.json @@ -28,7 +28,9 @@ }, "abort": { "cannot_validate": "Echec de validation de la lumi?re Elkbledom", - "cannot_connect": "Echec de la connexion ? Elkbledom" + "cannot_connect": "Echec de la connexion ? Elkbledom", + "not_supported": "La lumi?re ElkBledom n'est pas prise en charge", + "invalid_discovery_info": "Informations de d?couverte non valides sur le p?riph?rique Bluetooth" } }, "options": { diff --git a/custom_components/elkbledom/translations/pl.json b/custom_components/elkbledom/translations/pl.json index f5a53922..3404013a 100644 --- a/custom_components/elkbledom/translations/pl.json +++ b/custom_components/elkbledom/translations/pl.json @@ -28,7 +28,9 @@ }, "abort": { "cannot_validate": "Nie można zweryfikować światła Elkbledom", - "cannot_connect": "Nie można połączyć się ze światłem Elkbledom" + "cannot_connect": "Nie można połączyć się ze światłem Elkbledom", + "not_supported": "wiato ElkBledom nie jest obsugiwane", + "invalid_discovery_info": "Nieprawidowe informacje o odkryciu w urzdzeniu Bluetooth" } }, "options": { diff --git a/custom_components/elkbledom/translations/sk.json b/custom_components/elkbledom/translations/sk.json index 02056bd6..5e76bb38 100644 --- a/custom_components/elkbledom/translations/sk.json +++ b/custom_components/elkbledom/translations/sk.json @@ -28,7 +28,9 @@ }, "abort": { "cannot_validate": "Nie je možné overiť Elkbledom light", - "cannot_connect": "Nedá sa pripojiť k Elbledom" + "cannot_connect": "Nedá sa pripojiť k Elbledom", + "not_supported": "Svetlo Elk Bledom nie je podporovane", + "invalid_discovery_info": "Neplatne informacie o objaveni v zariadeni bluetooth" } }, "options": { diff --git a/custom_components/elkbledom/translations/tr.json b/custom_components/elkbledom/translations/tr.json index f3554225..00948699 100644 --- a/custom_components/elkbledom/translations/tr.json +++ b/custom_components/elkbledom/translations/tr.json @@ -28,7 +28,9 @@ }, "abort": { "cannot_validate": "Elkbledom ışığı doğrulanamıyor", - "cannot_connect": "Elkbledom'a bağlanılamıyor" + "cannot_connect": "Elkbledom'a bağlanılamıyor", + "not_supported": "ElkBledom ???? desteklenmiyor", + "invalid_discovery_info": "Bluetooth aygtnda geersiz keif bilgisi" } }, "options": { diff --git a/custom_components/ember_mug/__init__.py b/custom_components/ember_mug/__init__.py deleted file mode 100644 index b9138cf6..00000000 --- a/custom_components/ember_mug/__init__.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Ember Mug Custom Integration.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -from ember_mug import EmberMug -from ember_mug.consts import EMBER_BLE_SIG -from ember_mug.utils import get_model_info_from_advertiser_data -from homeassistant.components import bluetooth -from homeassistant.components.bluetooth import ( - BluetoothCallbackMatcher, - BluetoothScanningMode, -) -from homeassistant.const import ( - CONF_ADDRESS, - CONF_MAC, - CONF_NAME, - EVENT_HOMEASSISTANT_STOP, - Platform, -) -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import CONF_DEBUG, CONFIG_VERSION, DOMAIN -from .coordinator import MugDataUpdateCoordinator - -if TYPE_CHECKING: - from home_assistant_bluetooth import BluetoothServiceInfoBleak - from homeassistant.config_entries import ConfigEntry - from homeassistant.core import Event, HomeAssistant - - -type EmberMugConfigEntry = ConfigEntry[MugDataUpdateCoordinator] - - -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.LIGHT, - Platform.NUMBER, - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - Platform.TEXT, -] -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Mug Platform.""" - address: str = entry.data[CONF_ADDRESS].upper() - service_info = bluetooth.async_last_service_info(hass, address, connectable=True) - - if service_info and not service_info.manufacturer_data: - _LOGGER.debug("Manufacturer data missing from latest advertisement, looking again.") - try: - service_info = await bluetooth.async_process_advertisements( - hass, - _process_more_advertisements, - {"address": address, "connectable": True}, - BluetoothScanningMode.ACTIVE, - 30, - ) - except TimeoutError as e: - raise ConfigEntryNotReady( - f"Could not find device with manufacturer data and address {address}. " - "If you have issues connecting, try putting the device in pairing mode.", - ) from e - - if not service_info: - raise ConfigEntryNotReady( - f"Could not find Ember device with address {entry.data[CONF_ADDRESS]}", - ) - - _LOGGER.debug( - "Integration setup. Last service info: Device: %s, Manufacturer Data: %s", - service_info.device, - service_info.manufacturer_data, - ) - - ember_mug = EmberMug( - service_info.device, - model_info=get_model_info_from_advertiser_data(service_info.advertisement), - debug=entry.options.get(CONF_DEBUG, False), - ) - mug_coordinator = MugDataUpdateCoordinator( - hass, - _LOGGER, - ember_mug, - entry.unique_id, - entry.data.get(CONF_NAME, entry.title), - ) - entry.async_on_unload( - bluetooth.async_register_callback( - hass, - mug_coordinator.handle_bluetooth_event, - BluetoothCallbackMatcher( - address=address, - connectable=True, - manufacturer_id=EMBER_BLE_SIG, - ), - BluetoothScanningMode.ACTIVE, - ), - ) - - await mug_coordinator.async_config_entry_first_refresh() - - entry.async_on_unload( - bluetooth.async_track_unavailable( - hass, - mug_coordinator.handle_unavailable, - address, - ), - ) - - entry.runtime_data = mug_coordinator - entry.async_on_unload(entry.add_update_listener(async_update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - async def _async_stop(event: Event) -> None: - """Close the connection.""" - await mug_coordinator.mug.disconnect() - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop), - ) - - return True - - -def _process_more_advertisements( - service_info: BluetoothServiceInfoBleak, -) -> bool: - """Wait for an advertisement with Ember SIG in the manufacturer_data.""" - return EMBER_BLE_SIG in service_info.manufacturer_data - - -async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): - """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) - - if config_entry.version >= CONFIG_VERSION: - # No migrations to run - return False - - old_data = {**config_entry.data} - if config_entry.version == 1: - old_data[CONF_ADDRESS] = old_data[CONF_MAC] - - hass.config_entries.async_update_entry( - config_entry, - data={ - CONF_ADDRESS: old_data[CONF_ADDRESS], - CONF_NAME: old_data[CONF_NAME], - }, - options={ - CONF_DEBUG: old_data.get(CONF_DEBUG, False), - }, - version=3, - ) - _LOGGER.info("Migration to version %s successful", config_entry.version) - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: EmberMugConfigEntry) -> bool: - """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - mug_coordinator = entry.runtime_data - await mug_coordinator.mug.disconnect() - if not hass.config_entries.async_entries(DOMAIN): - hass.data.pop(DOMAIN) - - return unload_ok diff --git a/custom_components/ember_mug/__pycache__/__init__.cpython-313.pyc b/custom_components/ember_mug/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 83102afb..00000000 Binary files a/custom_components/ember_mug/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/custom_components/ember_mug/__pycache__/config_flow.cpython-313.pyc b/custom_components/ember_mug/__pycache__/config_flow.cpython-313.pyc deleted file mode 100644 index d523bf35..00000000 Binary files a/custom_components/ember_mug/__pycache__/config_flow.cpython-313.pyc and /dev/null differ diff --git a/custom_components/ember_mug/__pycache__/const.cpython-313.pyc b/custom_components/ember_mug/__pycache__/const.cpython-313.pyc deleted file mode 100644 index 6c5fba6b..00000000 Binary files a/custom_components/ember_mug/__pycache__/const.cpython-313.pyc and /dev/null differ diff --git a/custom_components/ember_mug/__pycache__/coordinator.cpython-313.pyc b/custom_components/ember_mug/__pycache__/coordinator.cpython-313.pyc deleted file mode 100644 index 38748fe3..00000000 Binary files a/custom_components/ember_mug/__pycache__/coordinator.cpython-313.pyc and /dev/null differ diff --git a/custom_components/ember_mug/binary_sensor.py b/custom_components/ember_mug/binary_sensor.py deleted file mode 100644 index a2e3d7f0..00000000 --- a/custom_components/ember_mug/binary_sensor.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Binary Sensor Entity for Ember Mug.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -from ember_mug.consts import LiquidState -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.const import EntityCategory - -from .entity import BaseMugEntity - -if TYPE_CHECKING: - from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddEntitiesCallback - - from . import EmberMugConfigEntry - from .coordinator import MugDataUpdateCoordinator - - -_LOGGER = logging.getLogger(__name__) - -SENSOR_TYPES = { - "battery.on_charging_base": BinarySensorEntityDescription( - key="power", - device_class=BinarySensorDeviceClass.PLUG, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "battery.percent": BinarySensorEntityDescription( - key="low_battery", - device_class=BinarySensorDeviceClass.BATTERY, - entity_category=EntityCategory.DIAGNOSTIC, - ), -} - - -class MugBinarySensor(BaseMugEntity, BinarySensorEntity): - """Base Entity for Mug Binary Sensors.""" - - _domain = "binary_sensor" - - def __init__( - self, - coordinator: MugDataUpdateCoordinator, - device_attr: str, - ) -> None: - """Initialize the Mug sensor.""" - self.entity_description = SENSOR_TYPES[device_attr] - super().__init__(coordinator, device_attr) - - @property - def is_on(self) -> bool | None: - """Return mug attribute as binary state.""" - return self.coordinator.get_device_attr(self._device_attr) - - -class MugLowBatteryBinarySensor(MugBinarySensor): - """Warn about low battery.""" - - @property - def is_on(self) -> bool | None: - """Return "on" if battery is low.""" - battery_percent = self.coordinator.get_device_attr(self._device_attr) - if battery_percent is None: - return None - if battery_percent > 25: - # Even if heating, it is not low yet. - return False - state = self.coordinator.get_device_attr("liquid_state") - # If heating or at target temperature the battery will discharge faster. - if state in (LiquidState.HEATING, LiquidState.TARGET_TEMPERATURE): - return True - return bool(battery_percent < 15) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: EmberMugConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Binary Sensor Entities.""" - if entry.entry_id is None: - raise ValueError("Missing Entry ID") - coordinator = entry.runtime_data - async_add_entities( - [ - MugBinarySensor(coordinator, "battery.on_charging_base"), - MugLowBatteryBinarySensor(coordinator, "battery.percent"), - ], - ) diff --git a/custom_components/ember_mug/config_flow.py b/custom_components/ember_mug/config_flow.py deleted file mode 100644 index b4b69ddb..00000000 --- a/custom_components/ember_mug/config_flow.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Add Config Flow for Ember Mug.""" - -from __future__ import annotations - -import contextlib -from typing import TYPE_CHECKING, Any - -import voluptuous as vol -from bleak import BleakClient, BleakError -from ember_mug.consts import DEVICE_SERVICE_UUIDS -from homeassistant import config_entries -from homeassistant.components.bluetooth import async_discovered_service_info -from homeassistant.const import CONF_ADDRESS, CONF_NAME, UnitOfTemperature -from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import selector -from homeassistant.util.unit_conversion import TemperatureConverter - -from . import _LOGGER -from .const import ( - CONF_DEBUG, - CONF_PRESETS, - CONF_PRESETS_UNIT, - CONFIG_VERSION, - DEFAULT_PRESETS, - DOMAIN, - MAX_TEMP_CELSIUS, - MIN_TEMP_CELSIUS, -) - -if TYPE_CHECKING: - from homeassistant.components.bluetooth import BluetoothServiceInfoBleak - from homeassistant.data_entry_flow import FlowResult - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Config Flow for Ember Mug.""" - - VERSION = CONFIG_VERSION - - def __init__(self) -> None: - """Initialize the config flow.""" - self._discovery_info: BluetoothServiceInfoBleak | None = None - - async def async_step_bluetooth( - self, - discovery_info: BluetoothServiceInfoBleak, - ) -> FlowResult: - """Handle the bluetooth discovery step.""" - _LOGGER.debug("Discovered bluetooth device: %s", discovery_info) - await self.async_set_unique_id(discovery_info.address.replace(":", "").lower()) - self._abort_if_unique_id_configured() - - self._discovery_info = discovery_info - self.context["title_placeholders"] = { - CONF_NAME: discovery_info.name, - CONF_ADDRESS: discovery_info.address, - } - return await self.async_step_user() - - async def async_step_user( - self, - user_input: dict[str, Any] | None = None, - ) -> FlowResult: - """First step for users.""" - errors: dict[str, str] = {} - if user_input: - address = user_input[CONF_ADDRESS] - await self.async_set_unique_id( - address.replace(":", "").lower(), - raise_on_progress=False, - ) - self._abort_if_unique_id_configured() - return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) - - if not self._discovery_info: - current_addresses = self._async_current_ids() - for service_info in async_discovered_service_info(self.hass): - address = service_info.address - unique_id = address.replace(":", "").lower() - if unique_id in current_addresses: - _LOGGER.debug("Skipping device %s which is already setup", service_info.address) - continue - if not set(service_info.service_uuids).intersection(DEVICE_SERVICE_UUIDS) and ( - not service_info.name or not service_info.name.startswith("Ember") - ): - _LOGGER.debug( - "Skipping unrelated device %s with services: %s", - service_info.name, - service_info.service_uuids, - ) - continue - try: - async with BleakClient(service_info.device) as client: - await client.connect() - with contextlib.suppress(BleakError, EOFError): - # An error will be raised if already paired - await client.pair() - except BleakError: - self.async_abort(reason="cannot_connect") - self._discovery_info = service_info - break - else: - return self.async_abort(reason="no_new_devices") - - name = self._discovery_info.name - data_schema = vol.Schema( - { - vol.Required(CONF_ADDRESS): vol.In( - { - self._discovery_info.address: f"{name} ({self._discovery_info.address})", - }, - ), - vol.Required(CONF_NAME, default=name): str, - }, - ) - return self.async_show_form( - step_id="user", - data_schema=data_schema, - errors=errors, - ) - - @staticmethod - @callback - def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> config_entries.OptionsFlow: - """Create the options flow.""" - return OptionsFlowHandler(config_entry) - - -class OptionsFlowHandler(config_entries.OptionsFlow): - """Allows users to configure integration after setup.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init( - self, - user_input: dict[str, Any] | None = None, - ) -> FlowResult: - """Manage the options.""" - errors: dict[str, str] = {} - presets_default = self.config_entry.options.get(CONF_PRESETS, DEFAULT_PRESETS) - - if user_input is not None: - min_temp, max_temp = MIN_TEMP_CELSIUS, MAX_TEMP_CELSIUS - if user_input[CONF_PRESETS_UNIT] == UnitOfTemperature.FAHRENHEIT: - min_temp, max_temp = ( - TemperatureConverter.convert(t, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT) - for t in [MIN_TEMP_CELSIUS, MAX_TEMP_CELSIUS] - ) - if presets := user_input[CONF_PRESETS]: - schema = vol.Schema( - { - str: vol.All( - vol.Union(float, int), - vol.Any(vol.Literal(0), vol.Range(min=min_temp, max=max_temp)), - ), - }, - ) - try: - schema(presets) - except (vol.Invalid, vol.MultipleInvalid) as e: - errors[CONF_PRESETS] = str(e) - # Use as default to avoid clearing the field on the user they can cancel if confused - presets_default = presets - else: - _LOGGER.debug("Got updated options: %s", user_input) - return self.async_create_entry(title="", data=user_input) - - return self.async_show_form( - step_id="init", - data_schema=vol.Schema( - { - vol.Required( - CONF_PRESETS_UNIT, - default=self.config_entry.options.get(CONF_PRESETS_UNIT, UnitOfTemperature.CELSIUS), - ): vol.In( - [UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT], - ), - vol.Required( - CONF_PRESETS, - default=presets_default, - ): selector.ObjectSelector(), - vol.Optional(CONF_DEBUG, default=self.config_entry.options.get(CONF_DEBUG, False)): cv.boolean, - }, - ), - errors=errors, - ) diff --git a/custom_components/ember_mug/const.py b/custom_components/ember_mug/const.py deleted file mode 100644 index 5802c553..00000000 --- a/custom_components/ember_mug/const.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Constants used for mug.""" - -from enum import StrEnum -from typing import Final - -from ember_mug.consts import LiquidState - -DOMAIN: Final[str] = "ember_mug" -MANUFACTURER: Final[str] = "Ember" -SUGGESTED_AREA: Final[str] = "Kitchen" -STORAGE_VERSION = 1 -CONFIG_VERSION = 3 - -ICON_DEFAULT = "mdi:coffee" -ICON_EMPTY = "mdi:coffee-outline" -ICON_UNAVAILABLE = "mdi:coffee-off-outline" - -ATTR_BATTERY_VOLTAGE = "battery_voltage" -CONF_DEBUG = "debug" -CONF_PRESETS = "presets" -CONF_PRESETS_UNIT = "presets_unit" - -MIN_TEMP_CELSIUS: Final[float] = 48.8 -MAX_TEMP_CELSIUS: Final[float] = 63 - -DEFAULT_PRESETS = { - "latte": 55, - "cappuccino": 56, - "coffee": 57, - "black-tea": 58.5, - "green-tea": 59, -} - - -class LiquidStateValue(StrEnum): - """Options for liquid state.""" - - STANDBY = "standby" - EMPTY = "empty" - FILLING = "filling" - COLD_NO_CONTROL = "cold_no_control" - COOLING = "cooling" - HEATING = "heating" - PERFECT = "perfect" - WARM_NO_CONTROL = "warm_no_control" - - -LIQUID_STATE_OPTIONS = list(LiquidStateValue) -LIQUID_STATE_TEMP_ICONS = { - None: "thermometer-off", - LiquidState.STANDBY: "thermometer-off", - LiquidState.COLD_NO_TEMP_CONTROL: "thermometer-low", - LiquidState.COOLING: "thermometer-chevron-down", - LiquidState.HEATING: "thermometer-chevron-up", - LiquidState.WARM_NO_TEMP_CONTROL: "thermometer-high", -} - -LIQUID_STATE_MAPPING = { - LiquidState.EMPTY: LiquidStateValue.EMPTY, - LiquidState.FILLING: LiquidStateValue.FILLING, - LiquidState.COLD_NO_TEMP_CONTROL: LiquidStateValue.COLD_NO_CONTROL, - LiquidState.COOLING: LiquidStateValue.COOLING, - LiquidState.HEATING: LiquidStateValue.HEATING, - LiquidState.STANDBY: LiquidStateValue.STANDBY, - LiquidState.TARGET_TEMPERATURE: LiquidStateValue.PERFECT, - LiquidState.WARM_NO_TEMP_CONTROL: LiquidStateValue.WARM_NO_CONTROL, -} diff --git a/custom_components/ember_mug/coordinator.py b/custom_components/ember_mug/coordinator.py deleted file mode 100644 index 58b83a39..00000000 --- a/custom_components/ember_mug/coordinator.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Coordinator for all the sensors.""" - -from __future__ import annotations - -import logging -from datetime import timedelta -from typing import TYPE_CHECKING, Any, TypedDict - -from bleak import BleakError -from bleak_retry_connector import close_stale_connections -from ember_mug.data import Change, MugData -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.storage import Store -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .const import DOMAIN, MANUFACTURER, STORAGE_VERSION, SUGGESTED_AREA - -if TYPE_CHECKING: - from ember_mug import EmberMug - from home_assistant_bluetooth import BluetoothServiceInfoBleak - from homeassistant.components.bluetooth import BluetoothChange - - -_LOGGER = logging.getLogger(__name__) - - -class PersistentData(TypedDict): - """Data that should persist on disk.""" - - target_temp_bkp: float | None - - -class MugDataUpdateCoordinator(DataUpdateCoordinator[MugData]): - """Class to manage fetching Mug data.""" - - def __init__( - self, - hass: HomeAssistant, - logger: logging.Logger, - mug: EmberMug, - base_unique_id: str, - device_name: str, - ) -> None: - """Initialize global Mug data updater.""" - device_type = mug.data.model_info.device_type.value - super().__init__( - hass=hass, - logger=logger, - name=f"ember-{device_type.replace('_', '-')}-{base_unique_id}", - update_interval=timedelta(seconds=15), - always_update=False, - ) - self._store: Store[PersistentData] = Store(hass, STORAGE_VERSION, DOMAIN) - self.persistent_data: PersistentData = None # type: ignore[assignment] - self.device_name = device_name - self.device_type = device_type - self.base_unique_id = base_unique_id - self.mug = mug - self.data = self.mug.data - self.available = False - self._last_refresh_was_full = True - _LOGGER.info("%s %s Setup", self.mug.model_name, self.name) - - async def _async_setup(self) -> None: - """Initialize coordinator and fetch initial data.""" - # Setup storage - self.persistent_data = await self._store.async_load() - try: - await self.mug.update_initial() - await self.mug.update_all() - _LOGGER.debug("[Initial Update] values: %s", self.mug.data) - except (TimeoutError, BleakError) as e: - if isinstance(e, BleakError): - _LOGGER.debug("An error occurred trying to update the %s: %s", self.mug.model_name, e) - raise UpdateFailed( - f"An error occurred updating {self.mug.model_name}: {e=}", - ) from e - - self.mug.register_callback( - self._async_handle_callback, - ) - - async def _async_update_data(self) -> MugData: - """Poll the device.""" - _LOGGER.debug("Updating") - full_update = not self._last_refresh_was_full - changed: list[Change] | None = [] - try: - if self._last_refresh_was_full is False: - # Only fully poll all data every other call to limit time - changed += await self.mug.update_all() - else: - changed += await self.mug.update_queued_attributes() - self._last_refresh_was_full = not self._last_refresh_was_full - self.available = True - except (TimeoutError, BleakError) as e: - if isinstance(e, BleakError): - _LOGGER.debug("An error occurred trying to update the %s: %s", self.mug.model_name, e) - if self.available is True: - _LOGGER.debug("%s is not available: %s", self.mug.model_name, e) - self.available = False - changed = None - except Exception as e: - _LOGGER.error( - "An unexpected error occurred whilst updating the %s: %s", - self.mug.model_name, - e, - ) - self.available = False - raise UpdateFailed( - f"An error occurred updating {self.mug.model_name}: {e=}", - ) from e - - _LOGGER.debug( - "[%s Update] Changed: %s", - "Full" if full_update else "Partial", - changed, - ) - if changed: - self.async_update_listeners() - return self.mug.data - - def ensure_writable(self) -> None: - """Writable check for service methods.""" - if self.mug.can_write is False: - raise ValueError( - f"Unable to write to {self.mug.data.model_info.device_type.value}", - ) - - async def write_to_storage(self, target_temp: float | None) -> None: - """ - Write target temp to file storage. - - This is stored to disk, so it can be restored to the entity even if we restart Home Assistant. - """ - self.persistent_data = {"target_temp_bkp": target_temp} - await self._store.async_save(self.persistent_data) - - @property - def target_temp(self) -> float: - """Shortcut for getting target temp, but showing stored data if temp control is off.""" - if self.data.target_temp == 0 and (bkp_temp := self.persistent_data.get("target_temp_bkp")): - return bkp_temp - return self.data.target_temp - - @callback - def handle_unavailable( - self, - service_info: BluetoothServiceInfoBleak, - ) -> None: - """Handle the device going unavailable.""" - _LOGGER.debug("%s is unavailable", self.mug.model_name) - self.available = False - self.async_update_listeners() - - @callback - def handle_bluetooth_event( - self, - service_info: BluetoothServiceInfoBleak, - change: BluetoothChange, - ) -> None: - """Handle a Bluetooth event.""" - _LOGGER.debug( - "Bluetooth event. Service Info: %s, change: %s", - service_info, - change, - ) - self.mug.ble_event_callback(service_info.device, service_info.advertisement) - self.hass.loop.create_task(close_stale_connections(service_info.device)) - - @callback - def _async_handle_callback(self, mug_data: MugData) -> None: - """Handle a Bluetooth event.""" - _LOGGER.debug("Callback called in Home Assistant") - self.async_set_updated_data(mug_data) - - def refresh_from_mug(self) -> None: - """Update stored data from mug data and trigger entities.""" - self.async_set_updated_data(self.mug.data) - - def get_device_attr(self, device_attr: str) -> Any: - """Get a device attribute by name (recursively) or return None.""" - value = self.data - for attr in device_attr.split("."): - try: - value = getattr(value, attr) - except AttributeError: - return None - return value - - @property - def device_info(self) -> DeviceInfo: - """Return information about the mug.""" - firmware = self.data.firmware - return DeviceInfo( - connections={(CONNECTION_BLUETOOTH, self.mug.device.address)}, - identifiers={(DOMAIN, self.mug.device.address)}, - name=name if (name := self.data.name) and name != "Ember Device" else self.device_name, - model=self.data.model_info.name, - model_id=self.data.model_info.model.value if self.data.model_info.model else None, - serial_number=self.data.meta.serial_number if self.data.meta else None, - suggested_area=SUGGESTED_AREA, - hw_version=str(firmware.hardware) if firmware else None, - sw_version=str(firmware.version) if firmware else None, - manufacturer=MANUFACTURER, - ) diff --git a/custom_components/ember_mug/diagnostics.py b/custom_components/ember_mug/diagnostics.py deleted file mode 100644 index 63093d89..00000000 --- a/custom_components/ember_mug/diagnostics.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Diagnostics support for Mug.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any - -from bleak import BleakError - -if TYPE_CHECKING: - from homeassistant.core import HomeAssistant - - from . import EmberMugConfigEntry - - -logger = logging.getLogger(__name__) - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, - entry: EmberMugConfigEntry, -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - coordinator = entry.runtime_data - data: dict[str, Any] = { - "info": coordinator.data, - "state": coordinator.data.liquid_state_display, - "address": coordinator.mug.device.address, - } - if coordinator.mug.debug is True: - services: dict[str, Any] | None = None - try: - services = await coordinator.mug.discover_services() - except BleakError as e: - logger.error("Failed to log services, %s", e) - if services is not None: - # Ensure bytes are converted into strings for serialization - for service in services.values(): - for char in service["characteristics"].values(): - if (value := char["value"]) is not None: - char["value"] = str(value) - for desc in char["descriptors"]: - if (value := desc["value"]) is not None: - desc["value"] = str(value) - data["services"] = services - return data diff --git a/custom_components/ember_mug/entity.py b/custom_components/ember_mug/entity.py deleted file mode 100644 index d58c5995..00000000 --- a/custom_components/ember_mug/entity.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Generic Entity Logic for multiple platforms.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any - -from homeassistant.core import callback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.unit_conversion import TemperatureConverter, UnitOfTemperature - -if TYPE_CHECKING: - from collections.abc import Mapping - - from ember_mug.consts import TemperatureUnit - - from .coordinator import MugDataUpdateCoordinator - - -_LOGGER = logging.getLogger(__name__) - - -def ensure_celsius( - value: float | None, - source_unit: UnitOfTemperature | TemperatureUnit, -) -> float | None: - """Convert unit back to Celsius for a base and round.""" - if value is None: - return None - if source_unit != UnitOfTemperature.CELSIUS: - value = TemperatureConverter.convert( - value, - source_unit, - UnitOfTemperature.CELSIUS, - ) - return value - - -class BaseMugEntity(CoordinatorEntity): - """Generic entity encapsulating common features of an Ember Mug.""" - - coordinator: MugDataUpdateCoordinator - - _domain: str = None # type: ignore[assignment] - _attr_has_entity_name = True - - def __init__( - self, - coordinator: MugDataUpdateCoordinator, - device_attr: str, - ) -> None: - """Initialize the entity.""" - super().__init__(coordinator) - entity_key = self.entity_description.key - self._device_attr = device_attr - self._address = coordinator.mug.device.address - self._attr_translation_key = entity_key - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"ember_{coordinator.device_type}_{coordinator.base_unique_id}_{entity_key}" - self.entity_id = f"{self._domain}.{self._attr_unique_id}" - - @property - def available(self) -> bool: - """Return if entity is available.""" - return self.coordinator.available - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return empty dict by default.""" - return {} - - @callback - def _async_update_attrs(self) -> None: - """Update the entity attributes.""" - - @callback - def _handle_coordinator_update(self) -> None: - """Handle data update.""" - self._async_update_attrs() - self.async_write_ha_state() - - -class BaseMugValueEntity(BaseMugEntity): - """Base Entity that returns a mug attribute as its `native_value`.""" - - @property - def native_value(self) -> Any: - """Return a mug attribute as the state for the sensor.""" - return self.coordinator.get_device_attr(self._device_attr) diff --git a/custom_components/ember_mug/light.py b/custom_components/ember_mug/light.py deleted file mode 100644 index 17f067f4..00000000 --- a/custom_components/ember_mug/light.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Expose the Mug's LEDs as a light entity.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any - -from ember_mug.data import Colour -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - ATTR_RGB_COLOR, - ColorMode, - LightEntity, - LightEntityDescription, -) -from homeassistant.core import callback -from homeassistant.helpers.entity import EntityCategory - -from .entity import BaseMugEntity - -if TYPE_CHECKING: - from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddEntitiesCallback - - from . import EmberMugConfigEntry - -_LOGGER = logging.getLogger(__name__) - - -class MugLightEntity(BaseMugEntity, LightEntity): - """Light entity for Nug LED.""" - - _domain = "light" - _attr_color_mode = ColorMode.RGB - _attr_supported_color_modes = {ColorMode.RGB} - - entity_description = LightEntityDescription( - key="led", - entity_category=EntityCategory.CONFIG, - ) - - @property - def is_on(self) -> bool | None: - """The light is always on if it is available.""" - return self.coordinator.available or None - - @callback - def _async_update_attrs(self) -> None: - """Handle updating _attr values.""" - colour = self.coordinator.data.led_colour - self._attr_brightness = colour.brightness - self._attr_rgb_color = tuple(colour[:3]) if colour else (255, 255, 255) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Change the LED colour if defined.""" - _LOGGER.debug("Received turn on with %s", kwargs) - self.coordinator.ensure_writable() - current_colour = self.coordinator.mug.data.led_colour - rgb: tuple[int, int, int] - rgb, brightness = current_colour[:3], current_colour[3] - if (rgb := kwargs.get(ATTR_RGB_COLOR, rgb)) or (brightness := kwargs.get(ATTR_BRIGHTNESS)): - if brightness is None: - brightness = current_colour[3] - if not rgb: - rgb = current_colour[:3] - await self.coordinator.mug.set_led_colour(Colour(*rgb, brightness)) - self._attr_rgb_color = tuple(rgb) - self._attr_brightness = brightness - self.coordinator.refresh_from_mug() - - def turn_off(self, **kwargs: Any) -> None: - """Do nothing, since these lights can't be turned off.""" - _LOGGER.warning( - "%s LED cannot be turned off; doing nothing.", - self.coordinator.mug.model_name, - ) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: EmberMugConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the mug light.""" - if entry.entry_id is None: - raise ValueError("Missing config entry ID") - coordinator = entry.runtime_data - entities = [] - if coordinator.mug.has_attribute("led_colour"): - entities = [MugLightEntity(coordinator, "led_colour")] - async_add_entities(entities) diff --git a/custom_components/ember_mug/manifest.json b/custom_components/ember_mug/manifest.json deleted file mode 100644 index de3b74af..00000000 --- a/custom_components/ember_mug/manifest.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "domain": "ember_mug", - "name": "Ember Mug", - "bluetooth": [ - { - "local_name": "Ember C*", - "manufacturer_id": 961 - }, - { - "local_name": "Ember T*", - "manufacturer_id": 961 - }, - { - "service_uuid": "fc543621-236c-4c94-8fa9-944a3e5353fa", - "manufacturer_id": 961 - }, - { - "service_uuid": "fc543622-236c-4c94-8fa9-944a3e5353fa", - "manufacturer_id": 961 - } - ], - "codeowners": [ - "@sopelj" - ], - "config_flow": true, - "dependencies": [ - "bluetooth_adapters" - ], - "documentation": "https://github.com/sopelj/hass_ember_mug", - "iot_class": "local_polling", - "issue_tracker": "https://github.com/sopelj/hass_ember_mug/issues", - "loggers": [ - "bleak", - "bleak_retry_connector", - "ember_mug" - ], - "requirements": [ - "python-ember-mug==1.1.0" - ], - "version": "1.2.1" -} diff --git a/custom_components/ember_mug/number.py b/custom_components/ember_mug/number.py deleted file mode 100644 index c93f03af..00000000 --- a/custom_components/ember_mug/number.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Binary Sensor Entity for Ember Mug.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -from homeassistant.components.number import ( - NumberDeviceClass, - NumberEntity, - NumberEntityDescription, - NumberMode, -) -from homeassistant.const import UnitOfTemperature -from homeassistant.helpers.entity import EntityCategory - -from .const import MAX_TEMP_CELSIUS, MIN_TEMP_CELSIUS -from .entity import BaseMugValueEntity - -if TYPE_CHECKING: - from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddEntitiesCallback - - from . import EmberMugConfigEntry - from .coordinator import MugDataUpdateCoordinator - - -_LOGGER = logging.getLogger(__name__) - -NUMBER_TYPES = { - "target_temp": NumberEntityDescription( - key="target_temp", - native_min_value=MIN_TEMP_CELSIUS, - native_max_value=MAX_TEMP_CELSIUS, - native_step=0.1, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=NumberDeviceClass.TEMPERATURE, - entity_category=EntityCategory.CONFIG, - ), -} - - -class MugNumberEntity(BaseMugValueEntity, NumberEntity): - """Configurable NumberEntity for a mug attribute.""" - - _domain = "number" - _attr_mode = NumberMode.BOX - - def __init__( - self, - coordinator: MugDataUpdateCoordinator, - device_attr: str, - ) -> None: - """Initialize the Mug Number.""" - self.entity_description = NUMBER_TYPES[device_attr] - super().__init__(coordinator, device_attr) - - -class MugTargetTempNumberEntity(MugNumberEntity): - """Configurable NumerEntity for the Mug's target temp.""" - - @property - def native_value(self) -> float | None: - """Return a mug attribute as the state for the sensor.""" - return self.coordinator.target_temp - - async def async_set_native_value(self, value: float) -> None: - """Set the mug target temp.""" - self.coordinator.ensure_writable() - await self.coordinator.mug.set_target_temp(value) - self.coordinator.refresh_from_mug() - - -async def async_setup_entry( - hass: HomeAssistant, - entry: EmberMugConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Number Entities.""" - if entry.entry_id is None: - raise ValueError("Missing config entry ID") - coordinator = entry.runtime_data - async_add_entities( - [ - MugTargetTempNumberEntity(coordinator, "target_temp"), - ], - ) diff --git a/custom_components/ember_mug/select.py b/custom_components/ember_mug/select.py deleted file mode 100644 index 45391876..00000000 --- a/custom_components/ember_mug/select.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Select Entity for Ember Mug.""" - -from __future__ import annotations - -import logging -from enum import Enum -from typing import TYPE_CHECKING, Literal - -from ember_mug.consts import VolumeLevel -from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import UnitOfTemperature -from homeassistant.helpers.entity import EntityCategory -from homeassistant.util.unit_conversion import TemperatureConverter - -from .const import CONF_PRESETS, CONF_PRESETS_UNIT, DEFAULT_PRESETS -from .entity import BaseMugEntity - -if TYPE_CHECKING: - from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddEntitiesCallback - - from . import EmberMugConfigEntry - from .coordinator import MugDataUpdateCoordinator - - -_LOGGER = logging.getLogger(__name__) - -TEMPERATURE_UNITS = [t.value for t in (UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT)] -VOLUME_LEVELS = [v.value for v in VolumeLevel] -SELECT_TYPES = { - "temperature_unit": SelectEntityDescription( - key="temperature_unit", - options=TEMPERATURE_UNITS, - entity_category=EntityCategory.CONFIG, - ), - "temperature_preset": SelectEntityDescription( - key="temperature_preset", - entity_category=EntityCategory.CONFIG, - ), - "volume_level": SelectEntityDescription( - key="volume_level", - options=VOLUME_LEVELS, - entity_category=EntityCategory.CONFIG, - ), -} - - -class MugSelectEntity(BaseMugEntity, SelectEntity): - """Configurable SelectEntity for a mug attribute.""" - - _domain = "select" - - def __init__( - self, - coordinator: MugDataUpdateCoordinator, - device_attr: str, - ) -> None: - """Initialize the Device select entity.""" - self.entity_description = SELECT_TYPES[device_attr] - super().__init__(coordinator, device_attr) - - @property - def current_option(self) -> str | None: - """Return a mug attribute as the state for the current option.""" - option = self.coordinator.get_device_attr(self._device_attr) - return option.value if isinstance(option, Enum) else option - - -class MugTempUnitSelectEntity(MugSelectEntity): - """Configurable SelectEntity for a mug temp unit.""" - - @property - def icon(self) -> str: - """Change icon based on current option.""" - if current := self.current_option: - unit = "fahrenheit" if current == UnitOfTemperature.FAHRENHEIT else "celsius" - return f"mdi:temperature-{unit}" - return "mdi:help-rhombus-outline" - - async def async_select_option( - self, - option: Literal["°C", "°F"] | UnitOfTemperature, - ) -> None: - """Change the selected option.""" - await self.coordinator.mug.set_temperature_unit(option) - self.coordinator.refresh_from_mug() - - -class MugVolumeLevelSelectEntity(MugSelectEntity): - """Configurable SelectEntity for the travel mug volume level.""" - - @property - def icon(self) -> str: - """Change icon based on current option.""" - if current := self.current_option: - return f"mdi:volume-{current}" - return "mdi:volume-off" - - async def async_select_option( - self, - option: Literal["high", "medium", "low"] | VolumeLevel, - ) -> None: - """Change the selected option.""" - self.coordinator.ensure_writable() - if isinstance(option, str): - option = VolumeLevel(option) - await self.coordinator.mug.set_volume_level(option) - self.coordinator.refresh_from_mug() - - -class MugTemperaturePresetSelectEntity(MugSelectEntity): - """Configurable SelectEntity to set the mug temperature from a list of presets.""" - - _attr_icon = "mdi:format-list-bulleted" - - def __init__( - self, - presets: dict[str, float], - presets_unit: UnitOfTemperature, - coordinator: MugDataUpdateCoordinator, - device_attr: str, - ) -> None: - """Set temperature presets and select options base on configs.""" - super().__init__(coordinator, device_attr) - if presets_unit != UnitOfTemperature.CELSIUS and coordinator.mug.data.use_metric: - presets = { - label: TemperatureConverter.convert( - temp, - presets_unit, - UnitOfTemperature.CELSIUS, - ) - for label, temp in presets.items() - } - self._presets = presets - self._temp_to_labels: dict[float, str] = {v: k for k, v in presets.items()} - self._attr_options = list(presets) - - @property - def current_option(self) -> str | None: - """Return selected option if found current temp is one of the presets.""" - return self._temp_to_labels.get(self.coordinator.target_temp, None) - - async def async_select_option(self, option: str) -> None: - """Change the target temp of the mug based on preset.""" - if not (target_temp := self._presets.get(option)): - raise ValueError("Invalid Option") - await self.coordinator.mug.set_target_temp(target_temp) - self.coordinator.refresh_from_mug() - - -async def async_setup_entry( - hass: HomeAssistant, - entry: EmberMugConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Select Entities.""" - if entry.entry_id is None: - raise ValueError("Missing config entry ID") - coordinator = entry.runtime_data - preset_unit = entry.options.get(CONF_PRESETS_UNIT, UnitOfTemperature.CELSIUS) - temp_presets = entry.options.get(CONF_PRESETS, DEFAULT_PRESETS) - entities = [ - MugTemperaturePresetSelectEntity(temp_presets, preset_unit, coordinator, "temperature_preset"), - MugTempUnitSelectEntity(coordinator, "temperature_unit"), - ] - if coordinator.mug.has_attribute("volume_level"): - entities.append( - MugVolumeLevelSelectEntity(coordinator, "volume_level"), - ) - async_add_entities(entities) diff --git a/custom_components/ember_mug/sensor.py b/custom_components/ember_mug/sensor.py deleted file mode 100644 index b96f756c..00000000 --- a/custom_components/ember_mug/sensor.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Sensor Entity for Ember Mug.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any - -from ember_mug.consts import DeviceType -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ATTR_BATTERY_CHARGING, PERCENTAGE, UnitOfTemperature -from homeassistant.helpers.entity import EntityCategory - -from .const import ( - ATTR_BATTERY_VOLTAGE, - ICON_DEFAULT, - ICON_EMPTY, - ICON_UNAVAILABLE, - LIQUID_STATE_MAPPING, - LIQUID_STATE_OPTIONS, - LIQUID_STATE_TEMP_ICONS, - LiquidStateValue, -) -from .entity import BaseMugValueEntity - -if TYPE_CHECKING: - from homeassistant.config_entries import ConfigEntry - from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddEntitiesCallback - - from .coordinator import MugDataUpdateCoordinator - - -SENSOR_TYPES = { - "liquid_state": SensorEntityDescription( - key="state", - device_class=SensorDeviceClass.ENUM, - options=LIQUID_STATE_OPTIONS, - ), - "liquid_level": SensorEntityDescription( - key="liquid_level", - icon="mdi:cup-water", - suggested_display_precision=0, - native_unit_of_measurement=PERCENTAGE, - ), - "current_temp": SensorEntityDescription( - key="current_temp", - suggested_display_precision=1, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.TEMPERATURE, - ), - "battery.percent": SensorEntityDescription( - key="battery_percent", - suggested_display_precision=1, - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.BATTERY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), -} - - -class EmberMugSensor(BaseMugValueEntity, SensorEntity): - """Representation of a Mug sensor.""" - - _domain = "sensor" - - def __init__( - self, - coordinator: MugDataUpdateCoordinator, - device_attr: str, - ) -> None: - """Initialize the Mug sensor.""" - self.entity_description = SENSOR_TYPES[device_attr] - super().__init__(coordinator, device_attr) - - -class EmberMugStateSensor(EmberMugSensor): - """Base Mug State Sensor.""" - - @property - def icon(self) -> str: - """Change icon based on state.""" - state = self.state - if state is None or self.coordinator.available is False: - return ICON_UNAVAILABLE - if state == LiquidStateValue.EMPTY: - return ICON_EMPTY - return ICON_DEFAULT - - @property - def native_value(self) -> str | None: - """Return liquid state key.""" - raw_value = super().native_value - if raw_value in LIQUID_STATE_MAPPING: - return LIQUID_STATE_MAPPING[raw_value].value - if raw_value is not None: - logging.debug('Value "%s" was not found in mapping: %s', raw_value, LIQUID_STATE_MAPPING) - return None - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device specific state attributes.""" - data = self.coordinator.data - colour = data.model_info.colour - attrs = { - "firmware_info": data.firmware, - "raw_state": data.liquid_state, - "colour": colour.value.lower().replace(" ", "-") if colour else "unknown", - } - if data.debug: - attrs |= { - "date_time_zone": data.date_time_zone, - "udsk": data.udsk, - "dsk": data.dsk, - } - return attrs | super().extra_state_attributes - - -class EmberMugLiquidLevelSensor(EmberMugSensor): - """Liquid Level Sensor.""" - - @property - def max_level(self) -> int: - """Max level is different for travel mug.""" - if self.coordinator.mug.data.model_info.device_type == DeviceType.TRAVEL_MUG: - return 100 - return 30 - - @property - def native_value(self) -> float | int: - """Return information about the liquid level.""" - liquid_level: float | None = super().native_value - if liquid_level: - # 30 -> Full (100 for Travel Mug) - # 5, 6 -> Low - # 0 -> Empty - return liquid_level / self.max_level * 100 - return 0 - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device specific state attributes.""" - return { - "raw_liquid_level": self.coordinator.data.liquid_level, - "capacity": self.coordinator.data.model_info.capacity, - **super().extra_state_attributes, - } - - -class EmberMugTemperatureSensor(EmberMugSensor): - """Mug Temperature sensor.""" - - @property - def icon(self) -> str | None: - """Set icon based on temperature.""" - if self._device_attr != "current_temp": - return "mdi:thermometer" - icon = LIQUID_STATE_TEMP_ICONS.get( - self.coordinator.data.liquid_state, - "thermometer", - ) - return f"mdi:{icon}" - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device specific state attributes.""" - return { - "native_value": self.coordinator.data.current_temp, - **super().extra_state_attributes, - } - - -class EmberMugBatterySensor(EmberMugSensor): - """Mug Battery Sensor.""" - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device specific state attributes.""" - data = self.coordinator.data - attrs = { - ATTR_BATTERY_CHARGING: data.battery.on_charging_base if data.battery else None, - } - if self.coordinator.mug.has_attribute("battery_voltage"): - attrs[ATTR_BATTERY_VOLTAGE] = data.battery_voltage - return attrs | super().extra_state_attributes - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Entities.""" - if entry.entry_id is None: - raise ValueError("Missing config entry ID") - coordinator = entry.runtime_data - entities: list[EmberMugSensor] = [ - EmberMugStateSensor(coordinator, "liquid_state"), - EmberMugLiquidLevelSensor(coordinator, "liquid_level"), - EmberMugTemperatureSensor(coordinator, "current_temp"), - EmberMugBatterySensor(coordinator, "battery.percent"), - ] - async_add_entities(entities) diff --git a/custom_components/ember_mug/strings.json b/custom_components/ember_mug/strings.json deleted file mode 100644 index 3b798a7f..00000000 --- a/custom_components/ember_mug/strings.json +++ /dev/null @@ -1,123 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Add an Ember Mug, Cup or Travel Mug", - "data": { - "address": "Device (MAC Address)", - "name": "Name", - "temperature_unit": "Temperature Unit" - } - } - }, - "abort": { - "unknown": "Unknown error occurred", - "cannot_connect": "Unable to connect to the device.", - "no_new_devices": "No new Ember devices were found. Please ensure it is in pairing mode." - } - }, - "options": { - "step": { - "init": { - "title": "Manage Options", - "data": { - "debug": "Enable debug mode to log extra attributes and values for debugging.", - "presets": "A key/value mapping of preset names to target temperatures (in above unit)", - "presets_unit": "Temperature unit used for the below presets (!important: if you change this you need to update the numbers in the presets accordingly)" - } - } - } - }, - "entity": { - "binary_sensor": { - "low_battery": { "name": "Low battery" }, - "power": { "name": "Power" } - }, - "light": { - "led": { "name": "LED" } - }, - "number": { - "target_temp": { "name": "Target temperature" } - }, - "select": { - "temperature_preset": { - "name": "Temperature preset", - "state": { - "latte": "Latte", - "cappuccino": "Cappuccino", - "coffee": "Coffee", - "black-tea": "Black Tea", - "green-tea": "Green Tea" - } - }, - "temperature_unit": { "name": "Temperature unit" }, - "volume_level": { - "name": "Volume Level", - "state": { - "low": "Low", - "medium": "Medium", - "high": "High" - } - } - }, - "sensor": { - "battery_percent": { - "name": "Battery", - "state_attributes": { - "battery_voltage": { "name": "Battery voltage" } - } - }, - "current_temp": { "name": "Current temperature" }, - "liquid_level": { - "name": "Liquid level", - "state_attributes": { - "raw_liquid_level": { "name": "Raw liquid level" }, - "capacity": { "name": "Capacity (ml)" } - } - }, - "state": { - "name": "State", - "state": { - "empty": "Empty", - "filling": "Filling", - "cold_no_control": "Cold (No control)", - "cooling": "Cooling", - "heating": "Heating", - "perfect": "Perfect", - "standby": "Standby", - "warm_no_control": "Warm (No control)" - }, - "state_attributes": { - "colour": { - "name": "Colour", - "state": { - "black": "Black", - "blue": "Blue", - "copper": "Copper", - "gold": "Gold", - "grey": "Grey", - "red": "Red", - "sage-green": "Sage Green", - "sandstone": "Sandstone", - "rose-gold": "Rose Gold", - "stainless-steel": "Stainless-Steel", - "unknown": "Unknown", - "white": "White" - } - }, - "date_time_zone": { "name": "Date and Timezone" }, - "dsk": { "name": "DSK" }, - "firmware_info": { "name": "Firmware info"}, - "raw_state": { "name": "Raw state" }, - "udsk": { "name": "UDSK" } - } - } - }, - "switch": { - "temperature_control": {"name": "Temperature Control"} - }, - "text": { - "name": { "name": "Name" } - } - } -} diff --git a/custom_components/ember_mug/switch.py b/custom_components/ember_mug/switch.py deleted file mode 100644 index 609818e2..00000000 --- a/custom_components/ember_mug/switch.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Switch entities.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any - -from homeassistant.components.switch import ( - SwitchDeviceClass, - SwitchEntity, - SwitchEntityDescription, -) -from homeassistant.const import EntityCategory - -from .entity import BaseMugEntity - -if TYPE_CHECKING: - from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddEntitiesCallback - - from . import EmberMugConfigEntry - from .coordinator import MugDataUpdateCoordinator - - -_LOGGER = logging.getLogger(__name__) - -SWITCH_TYPES = { - "target_temp": SwitchEntityDescription( - key="temperature_control", - device_class=SwitchDeviceClass.SWITCH, - entity_category=EntityCategory.CONFIG, - ), -} - - -class MugSwitchEntity(BaseMugEntity, SwitchEntity): - """Configurable SelectEntity for a mug attribute.""" - - _domain = "switch" - - def __init__( - self, - coordinator: MugDataUpdateCoordinator, - device_attr: str, - ) -> None: - """Initialize the Device select entity.""" - self.entity_description = SWITCH_TYPES[device_attr] - super().__init__(coordinator, device_attr) - - -class MugTemperatureControlEntity(MugSwitchEntity): - """Switch entity for controlling temperature control.""" - - @property - def icon(self) -> str: - """Set icon based on device state.""" - return "mdi:sun-snowflake" if self.is_on else "mdi:sun-snowflake-variant" - - @property - def is_on(self) -> bool: - """It is on if the target temp is not zero.""" - return bool(self.coordinator.mug.data.target_temp) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn heating/cooling on if there is a stored target temp.""" - self.coordinator.ensure_writable() - if not self.coordinator.mug.data.target_temp and ( - stored_temp := self.coordinator.persistent_data.get("target_temp_bkp") - ): - await self.coordinator.mug.set_target_temp(stored_temp) - self.coordinator.refresh_from_mug() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn heating/cooling off if it is not already.""" - self.coordinator.ensure_writable() - if target_temp := self.coordinator.mug.data.target_temp: - await self.coordinator.write_to_storage(target_temp) - await self.coordinator.mug.set_target_temp(0) - self.coordinator.refresh_from_mug() - - -async def async_setup_entry( - hass: HomeAssistant, - entry: EmberMugConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Switch Entities.""" - if entry.entry_id is None: - raise ValueError("Missing config entry ID") - coordinator = entry.runtime_data - async_add_entities([MugTemperatureControlEntity(coordinator, "target_temp")]) diff --git a/custom_components/ember_mug/text.py b/custom_components/ember_mug/text.py deleted file mode 100644 index b68dca4e..00000000 --- a/custom_components/ember_mug/text.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Text Entity for Ember Mug.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -from ember_mug.consts import MUG_NAME_PATTERN -from homeassistant.components.text import TextEntity, TextEntityDescription -from homeassistant.helpers.entity import EntityCategory - -from .entity import BaseMugValueEntity - -if TYPE_CHECKING: - from homeassistant.config_entries import ConfigEntry - from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddEntitiesCallback - - from .coordinator import MugDataUpdateCoordinator - - -_LOGGER = logging.getLogger(__name__) - -TEXT_TYPES = { - "name": TextEntityDescription( - key="name", - native_min=1, - native_max=16, - pattern=MUG_NAME_PATTERN, - entity_category=EntityCategory.CONFIG, - ), -} - - -class MugTextEntity(BaseMugValueEntity, TextEntity): - """Configurable Text Entity for text mug attribute.""" - - _domain = "text" - - def __init__( - self, - coordinator: MugDataUpdateCoordinator, - device_attr: str, - ) -> None: - """Initialize the Mug sensor.""" - self.entity_description = TEXT_TYPES[device_attr] - super().__init__(coordinator, device_attr) - - @property - def native_value(self) -> str: - """Return a mug attribute as the state for the sensor.""" - return super().native_value or "EMBER" - - async def async_set_value(self, value: str) -> None: - """Set the mug name.""" - self.coordinator.ensure_writable() - await self.coordinator.mug.set_name(value) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Text Entities.""" - if entry.entry_id is None: - raise ValueError("Missing config entry ID") - coordinator = entry.runtime_data - entities = [] - if coordinator.mug.has_attribute("name"): - entities = [MugTextEntity(coordinator, attr) for attr in TEXT_TYPES] - async_add_entities(entities) diff --git a/custom_components/ember_mug/translations/en.json b/custom_components/ember_mug/translations/en.json deleted file mode 100644 index 3b798a7f..00000000 --- a/custom_components/ember_mug/translations/en.json +++ /dev/null @@ -1,123 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Add an Ember Mug, Cup or Travel Mug", - "data": { - "address": "Device (MAC Address)", - "name": "Name", - "temperature_unit": "Temperature Unit" - } - } - }, - "abort": { - "unknown": "Unknown error occurred", - "cannot_connect": "Unable to connect to the device.", - "no_new_devices": "No new Ember devices were found. Please ensure it is in pairing mode." - } - }, - "options": { - "step": { - "init": { - "title": "Manage Options", - "data": { - "debug": "Enable debug mode to log extra attributes and values for debugging.", - "presets": "A key/value mapping of preset names to target temperatures (in above unit)", - "presets_unit": "Temperature unit used for the below presets (!important: if you change this you need to update the numbers in the presets accordingly)" - } - } - } - }, - "entity": { - "binary_sensor": { - "low_battery": { "name": "Low battery" }, - "power": { "name": "Power" } - }, - "light": { - "led": { "name": "LED" } - }, - "number": { - "target_temp": { "name": "Target temperature" } - }, - "select": { - "temperature_preset": { - "name": "Temperature preset", - "state": { - "latte": "Latte", - "cappuccino": "Cappuccino", - "coffee": "Coffee", - "black-tea": "Black Tea", - "green-tea": "Green Tea" - } - }, - "temperature_unit": { "name": "Temperature unit" }, - "volume_level": { - "name": "Volume Level", - "state": { - "low": "Low", - "medium": "Medium", - "high": "High" - } - } - }, - "sensor": { - "battery_percent": { - "name": "Battery", - "state_attributes": { - "battery_voltage": { "name": "Battery voltage" } - } - }, - "current_temp": { "name": "Current temperature" }, - "liquid_level": { - "name": "Liquid level", - "state_attributes": { - "raw_liquid_level": { "name": "Raw liquid level" }, - "capacity": { "name": "Capacity (ml)" } - } - }, - "state": { - "name": "State", - "state": { - "empty": "Empty", - "filling": "Filling", - "cold_no_control": "Cold (No control)", - "cooling": "Cooling", - "heating": "Heating", - "perfect": "Perfect", - "standby": "Standby", - "warm_no_control": "Warm (No control)" - }, - "state_attributes": { - "colour": { - "name": "Colour", - "state": { - "black": "Black", - "blue": "Blue", - "copper": "Copper", - "gold": "Gold", - "grey": "Grey", - "red": "Red", - "sage-green": "Sage Green", - "sandstone": "Sandstone", - "rose-gold": "Rose Gold", - "stainless-steel": "Stainless-Steel", - "unknown": "Unknown", - "white": "White" - } - }, - "date_time_zone": { "name": "Date and Timezone" }, - "dsk": { "name": "DSK" }, - "firmware_info": { "name": "Firmware info"}, - "raw_state": { "name": "Raw state" }, - "udsk": { "name": "UDSK" } - } - } - }, - "switch": { - "temperature_control": {"name": "Temperature Control"} - }, - "text": { - "name": { "name": "Name" } - } - } -} diff --git a/custom_components/ember_mug/translations/es.json b/custom_components/ember_mug/translations/es.json deleted file mode 100644 index 8928c6ed..00000000 --- a/custom_components/ember_mug/translations/es.json +++ /dev/null @@ -1,123 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Agregue una taza, copa o taza de viaje Ember", - "data": { - "address": "Dispositivo (Dirección MAC)", - "name": "Nombre", - "temperature_unit": "Unidad de temperatura" - } - } - }, - "abort": { - "unknown": "Un error desconocido ocurrió", - "cannot_connect": "No se puede conectar a el dispositivo", - "no_new_devices": "No se encontraron dispositivo Ember nuevos. Asegúrese de que esté en modo de emparejamiento." - } - }, - "options": { - "step": { - "init": { - "title": "Administrar opciones", - "data": { - "debug": "Registro de servicios y características para el desarrollo al conectarse", - "presets": "Una asignación de clave/valor de nombres preestablecidos a temperaturas objetivo (en la unidad anterior)", - "presets_unit": "Unidades de temperatura utilizadas para los siguientes ajustes preestablecidos (!Importante: si cambia esto, deberá actualizar los números en los ajustes preestablecidos en consecuencia)" - } - } - } - }, - "entity": { - "binary_sensor": { - "low_battery": { "name": "Batería baja" }, - "power": { "name": "Energía" } - }, - "light": { - "led": { "name": "LED" } - }, - "number": { - "target_temp": { "name": "Temperatura deseada" } - }, - "select": { - "temperature_preset": { - "name": "Temperature preset", - "state": { - "latte": "Latté", - "cappuccino": "Capuchino", - "coffee": "Café", - "black-tea": "Té negro", - "green-tea": "Té negro" - } - }, - "temperature_unit": { "name": "Unidad de temperatura" }, - "volume_level": { - "name": "Nivel de volumen", - "state": { - "low": "Bajo", - "medium": "Medio", - "high": "Alto" - } - } - }, - "sensor": { - "battery_percent": { - "name": "Batería", - "state_attributes": { - "battery_voltage": { "name": "Voltaje de la batería" } - } - }, - "current_temp": { "name": "Temperatura actual" }, - "liquid_level": { - "name": "Nivel de liquido", - "state_attributes": { - "raw_liquid_level": { "name": "Nivel de líquido bruto" }, - "capacity": { "name": "Capacidad (ml)" } - } - }, - "state": { - "name": "Estado", - "state": { - "empty": "Vacío", - "filling": "Llenando", - "cold_no_control": "Frío (Sin control)", - "cooling": "Enfriando", - "heating": "Calentamiento", - "perfect": "Perfecto", - "standby": "De espera", - "warm_no_control": "Cálido (sin control)" - }, - "state_attributes": { - "colour": { - "name": "Color", - "state": { - "black": "Negro", - "blue": "Azul", - "copper": "Cobre", - "gold": "Oro", - "grey": "Gris", - "red": "Rojo", - "sage-green": "Verde", - "sandstone": "Arenisca", - "rose-gold": "Oro rosa", - "stainless-steel": "Acero-inoxidable", - "unknown": "Desconocido", - "white": "Blanco" - } - }, - "date_time_zone": { "name": "Fecha y Zona Horaria" }, - "dsk": { "name": "DSK" }, - "firmware_info": { "name": "Información de firmware"}, - "raw_state": { "name": "Estado bruto" }, - "udsk": { "name": "UDSK" } - } - } - }, - "switch": { - "temperature_control": {"name": "Control de temperatura"} - }, - "text": { - "name": { "name": "Nombre" } - } - } -} diff --git a/custom_components/ember_mug/translations/fr.json b/custom_components/ember_mug/translations/fr.json deleted file mode 100644 index afd2733a..00000000 --- a/custom_components/ember_mug/translations/fr.json +++ /dev/null @@ -1,123 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Ajouter une tasse, coupe ou tasse de voyage Ember", - "data": { - "address": "Appareil (adresse MAC)", - "name": "Nom", - "temperature_unit": "Unité de température" - } - } - }, - "abort": { - "unknown": "Une erreur inconnue est survenue", - "cannot_connect": "Impossible de se connecter à l'appareil.", - "no_new_devices": "Aucune nouvelle appareil Ember a été trouvé. Veuillez assurer qu'il est en mode de jumelage." - } - }, - "options": { - "step": { - "init": { - "title": "Gérer les options", - "data": { - "debug": "Lister les services et caractéristiques pour développement au connexion", - "presets": "Mappage du nom de préréglage à la température cible (dans unités ci-dessus)", - "presets_unit": "Unité de température utilisée pour les préréglages ci-dessous (!important: si vous modifiez ceci, vous devez également mettre à jour les chiffres dans les préréglages en conséquence)" - } - } - } - }, - "entity": { - "binary_sensor": { - "low_battery": { "name": "Batterie faible" }, - "power": { "name": "Alimentation" } - }, - "light": { - "led": { "name": "DEL" } - }, - "number": { - "target_temp": { "name": "Température cible" } - }, - "select": { - "temperature_preset": { - "name": "Température prédéfini", - "state": { - "latte": "Latté", - "cappuccino": "Cappuccino", - "coffee": "Café", - "black-tea": "Thé noir", - "green-tea": "Thé vert" - } - }, - "temperature_unit": { "name": "Unité de température" }, - "volume_level": { - "name": "Niveau de volume", - "state": { - "low": "Faible", - "medium": "Moyen", - "high": "Élévé" - } - } - }, - "sensor": { - "battery_percent": { - "name": "Batterie", - "state_attributes": { - "battery_voltage": { "name": "Voltage de batterie" } - } - }, - "current_temp": { "name": "Température actuel" }, - "liquid_level": { - "name": "Niveau de liquide", - "state_attributes": { - "raw_liquid_level": { "name": "Niveau du liquide brute" }, - "capacity": { "name": "Capacitée (ml)" } - } - }, - "state": { - "name": "État", - "state": { - "empty": "Vide", - "filling": "Remplissage", - "cold_no_control": "Froid (sans contrôle)", - "cooling": "Refroidissement", - "heating": "Chauffage", - "perfect": "Parfait", - "standby": "En veille", - "warm_no_control": "Chaud (sans contrôle)" - }, - "state_attributes": { - "colour": { - "name": "Couleur", - "state": { - "black": "Noir", - "blue": "Bleu", - "copper": "Cuivre", - "gold": "Or", - "grey": "Grise", - "red": "Rouge", - "sage-green": "Vert", - "sandstone": "Sablé", - "rose-gold": "Or rose", - "stainless-steel": "Stainless-Steel", - "unknown": "Inconnu", - "white": "Blanche" - } - }, - "date_time_zone": { "name": "Date et fuseau horaire" }, - "dsk": { "name": "DSK" }, - "firmware_info": { "name": "Informations sur le micrologiciel"}, - "raw_state": { "name": "État brute" }, - "udsk": { "name": "UDSK" } - } - } - }, - "switch": { - "temperature_control": {"name": "Contrôle de la température"} - }, - "text": { - "name": { "name": "Nom" } - } - } -} diff --git a/custom_components/ember_mug/translations/ja.json b/custom_components/ember_mug/translations/ja.json deleted file mode 100644 index 67c2547d..00000000 --- a/custom_components/ember_mug/translations/ja.json +++ /dev/null @@ -1,123 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Ember マグ、カップ、トラベル マグを追加します", - "data": { - "address": "デバイス(MACアドレス)", - "name": "名", - "temperature_unit": "温度単位" - } - } - }, - "abort": { - "unknown": "未知数エラーが発生した。", - "cannot_connect": "デバイスに接続できません。", - "no_new_devices": "新しいEmberデバイスは見つかりませんでした。ペアリングモードにしてください。" - } - }, - "options": { - "step": { - "init": { - "title": "オプションの管理", - "data": { - "debug": "開発のためのログサービスと特徴", - "presets": "プリセット名のターゲット温度へのマッピング (上記のユニット内)", - "presets_unit": "次のプリセットに使用される温度単位 (!重要: これを変更した場合は、それに応じてプリセット内の数値を更新する必要があります)" - } - } - } - }, - "entity": { - "binary_sensor": { - "low_battery": { "name": "低バッテリー" }, - "power": { "name": "電源" } - }, - "light": { - "led": { "name": "LED" } - }, - "number": { - "target_temp": { "name": "目標温度" } - }, - "select": { - "temperature_preset": { - "name": "温度プリセット", - "state": { - "latte": "ラテ", - "cappuccino": "カプチーノ", - "coffee": "コーヒー", - "black-tea": "紅茶", - "green-tea": "緑茶" - } - }, - "temperature_unit": { "name": "温度単位" }, - "volume_level": { - "name": "音量", - "state": { - "low": "小音量", - "medium": "中音量", - "high": "大音量" - } - } - }, - "sensor": { - "battery_percent": { - "name": "電池", - "state_attributes": { - "battery_voltage": { "name": "電池電圧" } - } - }, - "current_temp": { "name": "現在の温度" }, - "liquid_level": { - "name": "液量", - "state_attributes": { - "raw_liquid_level": { "name": "液量" }, - "capacity": { "name": "容量 (ml)" } - } - }, - "state": { - "name": "状態", - "state": { - "empty": "空です", - "filling": "埋まっている", - "cold_no_control": "寒い(管理ない)", - "cooling": "冷めている", - "heating": "加熱している", - "perfect": "うってつけです", - "standby": "スタンバイ", - "warm_no_control": "暑い(管理ない)" - }, - "state_attributes": { - "colour": { - "name": "色", - "state": { - "black": "黒色", - "blue": "青色", - "copper": "銅色", - "gold": "金色", - "grey": "灰色", - "red": "赤色", - "sage-green": "緑色", - "sandstone": "砂岩色", - "rose-gold": "ローズゴールド色", - "stainless-steel": "ステンレス鋼色", - "unknown": "未知色", - "white": "白色" - } - }, - "date_time_zone": { "name": "日付と時間帯" }, - "dsk": { "name": "DSK" }, - "firmware_info": { "name": "ファームウェア"}, - "raw_state": { "name": "液体状態" }, - "udsk": { "name": "UDSK" } - } - } - }, - "switch": { - "temperature_control": {"name": "温度管理"} - }, - "text": { - "name": { "name": "名" } - } - } -} diff --git a/custom_components/ember_mug/translations/pl.json b/custom_components/ember_mug/translations/pl.json deleted file mode 100644 index 6a5f118b..00000000 --- a/custom_components/ember_mug/translations/pl.json +++ /dev/null @@ -1,123 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Dodaj kubek, filiżankę lub kubek podróżny Ember", - "data": { - "address": "Urządzenie (adres MAC)", - "name": "Nazwa", - "temperature_unit": "Jednostka temperatury" - } - } - }, - "abort": { - "unknown": "Wystąpił nieznany błąd", - "cannot_connect": "Nie można połączyć się z kubkiem", - "no_new_devices": "Nie znaleziono nowych kubków. Upewnij się, że jest w trybie parowania." - } - }, - "options": { - "step": { - "init": { - "title": "Zarządzaj opcjami", - "data": { - "debug": "Log usługi i cechy do rozwoju po połączeniu.", - "presets": "Mapowanie klucz/wartość nazw ustawień domyślnych na temperatury docelowe (w powyższych jednostkach)", - "presets_unit": "Jednostki temperatury używane w następujących ustawieniach wstępnych (!Ważne: jeśli to zmienisz, konieczna będzie odpowiednia aktualizacja liczb w ustawieniach wstępnych)" - } - } - } - }, - "entity": { - "binary_sensor": { - "low_battery": { "name": "Niski poziom baterii" }, - "power": { "name": "Energia" } - }, - "light": { - "led": { "name": "LED" } - }, - "number": { - "target_temperature": { "name": "Temperatura docelowa" } - }, - "select": { - "temperature_preset": { - "name": "Zadana temperatura", - "state": { - "latte": "Latte", - "cappuccino": "Cappuccino", - "coffee": "Kawa", - "black-tea": "Czarna herbata", - "green-tea": "Zielona herbata" - } - }, - "temperature_unit": { "name": "Jednostka temperatury" }, - "volume_level": { - "name": "Głośność dźwięku", - "state": { - "low": "Niski ", - "medium": "Średni", - "high": "Wysoki" - } - } - }, - "sensor": { - "battery_percent": { - "name": "Bateria", - "state_attributes": { - "battery_voltage": { "name": "Napięcie bateria" } - } - }, - "current_temp": { "name": "Obecna temperatura" }, - "liquid_level": { - "name": "Poziom cieczy", - "state_attributes": { - "raw_liquid_level": { "name": "Poziom surowej cieczy" }, - "capacity": { "name": "Pojemność (ml)" } - } - }, - "state": { - "name": "Stan", - "state": { - "empty": "Pussty", - "filling": "Napełniając", - "cold_no_control": "Zimno (bez kontroli)", - "cooling": "Chłodzić", - "heating": "Ogrzać", - "perfect": "Idealny", - "standby": "Czuwania", - "warm_no_control": "Ciepło (bez kontroli)" - }, - "state_attributes": { - "colour": { - "name": "Kolor", - "state": { - "black": "Czarny", - "blue": "Niebieski", - "copper": "Miedź", - "gold": "Złoto", - "grey": "Szary", - "red": "Czerwony", - "sage-green": "Zielony", - "sandstone": "Piaskowiec", - "rose-gold": "Różowe złoto", - "stainless-steel": "Stal nierdzewna", - "unknown": "Nieznany", - "white": "Biały" - } - }, - "date_time_zone": { "name": "Data i strefa czasowa" }, - "dsk": { "name": "DSK" }, - "firmware_info": { "name": "Oprogramowanie układowe" }, - "raw_state": { "name": "Stan surowy" }, - "udsk": { "name": "UDSK" } - } - } - }, - "switch": { - "temperature_control": {"name": "Kontrola temperatury"} - }, - "text": { - "mug_name": { "name": "Nazwa" } - } - } -} diff --git a/custom_components/fontawesome/__pycache__/__init__.cpython-312.pyc b/custom_components/fontawesome/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index ac924c92..00000000 Binary files a/custom_components/fontawesome/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/fontawesome/__pycache__/__init__.cpython-313.pyc b/custom_components/fontawesome/__pycache__/__init__.cpython-313.pyc index d5a87916..1a143102 100644 Binary files a/custom_components/fontawesome/__pycache__/__init__.cpython-313.pyc and b/custom_components/fontawesome/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/fontawesome/__pycache__/config_flow.cpython-312.pyc b/custom_components/fontawesome/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index 029e7e8c..00000000 Binary files a/custom_components/fontawesome/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/formulaone_api/__pycache__/__init__.cpython-312.pyc b/custom_components/formulaone_api/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 89904b36..00000000 Binary files a/custom_components/formulaone_api/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/formulaone_api/__pycache__/const.cpython-312.pyc b/custom_components/formulaone_api/__pycache__/const.cpython-312.pyc deleted file mode 100644 index 6af5df8a..00000000 Binary files a/custom_components/formulaone_api/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/formulaone_api/__pycache__/constructorsensor.cpython-312.pyc b/custom_components/formulaone_api/__pycache__/constructorsensor.cpython-312.pyc deleted file mode 100644 index b7f38bdc..00000000 Binary files a/custom_components/formulaone_api/__pycache__/constructorsensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/formulaone_api/__pycache__/driverssensor.cpython-312.pyc b/custom_components/formulaone_api/__pycache__/driverssensor.cpython-312.pyc deleted file mode 100644 index 71cda0a0..00000000 Binary files a/custom_components/formulaone_api/__pycache__/driverssensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/formulaone_api/__pycache__/ergast.cpython-312.pyc b/custom_components/formulaone_api/__pycache__/ergast.cpython-312.pyc deleted file mode 100644 index 3b1cdf08..00000000 Binary files a/custom_components/formulaone_api/__pycache__/ergast.cpython-312.pyc and /dev/null differ diff --git a/custom_components/formulaone_api/__pycache__/f1.cpython-312.pyc b/custom_components/formulaone_api/__pycache__/f1.cpython-312.pyc deleted file mode 100644 index 9a77bc55..00000000 Binary files a/custom_components/formulaone_api/__pycache__/f1.cpython-312.pyc and /dev/null differ diff --git a/custom_components/formulaone_api/__pycache__/formulaonesensor.cpython-312.pyc b/custom_components/formulaone_api/__pycache__/formulaonesensor.cpython-312.pyc deleted file mode 100644 index c4d07601..00000000 Binary files a/custom_components/formulaone_api/__pycache__/formulaonesensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/formulaone_api/__pycache__/lastresultsensor.cpython-312.pyc b/custom_components/formulaone_api/__pycache__/lastresultsensor.cpython-312.pyc deleted file mode 100644 index a496fba5..00000000 Binary files a/custom_components/formulaone_api/__pycache__/lastresultsensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/formulaone_api/__pycache__/racessensor.cpython-312.pyc b/custom_components/formulaone_api/__pycache__/racessensor.cpython-312.pyc deleted file mode 100644 index 6523ca54..00000000 Binary files a/custom_components/formulaone_api/__pycache__/racessensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/formulaone_api/__pycache__/sensor.cpython-312.pyc b/custom_components/formulaone_api/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index a7ce15a9..00000000 Binary files a/custom_components/formulaone_api/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/frigate/__init__.py b/custom_components/frigate/__init__.py index 4bd3381b..233ad9a5 100644 --- a/custom_components/frigate/__init__.py +++ b/custom_components/frigate/__init__.py @@ -25,7 +25,13 @@ ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODEL, CONF_HOST, CONF_URL +from homeassistant.const import ( + ATTR_MODEL, + CONF_HOST, + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -196,6 +202,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = FrigateApiClient( str(entry.data.get(CONF_URL)), async_get_clientsession(hass), + entry.data.get(CONF_USERNAME), + entry.data.get(CONF_PASSWORD), ) coordinator = FrigateDataUpdateCoordinator(hass, client=client) await coordinator.async_config_entry_first_refresh() diff --git a/custom_components/frigate/__pycache__/__init__.cpython-313.pyc b/custom_components/frigate/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index f2d13c79..00000000 Binary files a/custom_components/frigate/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/api.cpython-313.pyc b/custom_components/frigate/__pycache__/api.cpython-313.pyc deleted file mode 100644 index 87216690..00000000 Binary files a/custom_components/frigate/__pycache__/api.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/binary_sensor.cpython-313.pyc b/custom_components/frigate/__pycache__/binary_sensor.cpython-313.pyc deleted file mode 100644 index 851c4a66..00000000 Binary files a/custom_components/frigate/__pycache__/binary_sensor.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/camera.cpython-313.pyc b/custom_components/frigate/__pycache__/camera.cpython-313.pyc deleted file mode 100644 index 0f771d1b..00000000 Binary files a/custom_components/frigate/__pycache__/camera.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/config_flow.cpython-313.pyc b/custom_components/frigate/__pycache__/config_flow.cpython-313.pyc deleted file mode 100644 index 506837bb..00000000 Binary files a/custom_components/frigate/__pycache__/config_flow.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/const.cpython-313.pyc b/custom_components/frigate/__pycache__/const.cpython-313.pyc deleted file mode 100644 index a36bb28e..00000000 Binary files a/custom_components/frigate/__pycache__/const.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/diagnostics.cpython-313.pyc b/custom_components/frigate/__pycache__/diagnostics.cpython-313.pyc deleted file mode 100644 index 8dec6da9..00000000 Binary files a/custom_components/frigate/__pycache__/diagnostics.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/icons.cpython-313.pyc b/custom_components/frigate/__pycache__/icons.cpython-313.pyc deleted file mode 100644 index 65955198..00000000 Binary files a/custom_components/frigate/__pycache__/icons.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/image.cpython-313.pyc b/custom_components/frigate/__pycache__/image.cpython-313.pyc deleted file mode 100644 index 6558398f..00000000 Binary files a/custom_components/frigate/__pycache__/image.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/media_source.cpython-313.pyc b/custom_components/frigate/__pycache__/media_source.cpython-313.pyc deleted file mode 100644 index c34b9a3f..00000000 Binary files a/custom_components/frigate/__pycache__/media_source.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/number.cpython-313.pyc b/custom_components/frigate/__pycache__/number.cpython-313.pyc deleted file mode 100644 index 79222e56..00000000 Binary files a/custom_components/frigate/__pycache__/number.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/sensor.cpython-313.pyc b/custom_components/frigate/__pycache__/sensor.cpython-313.pyc deleted file mode 100644 index 5b084b5c..00000000 Binary files a/custom_components/frigate/__pycache__/sensor.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/switch.cpython-313.pyc b/custom_components/frigate/__pycache__/switch.cpython-313.pyc deleted file mode 100644 index 86196683..00000000 Binary files a/custom_components/frigate/__pycache__/switch.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/update.cpython-313.pyc b/custom_components/frigate/__pycache__/update.cpython-313.pyc deleted file mode 100644 index da5d1f9f..00000000 Binary files a/custom_components/frigate/__pycache__/update.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/views.cpython-313.pyc b/custom_components/frigate/__pycache__/views.cpython-313.pyc deleted file mode 100644 index ec238c99..00000000 Binary files a/custom_components/frigate/__pycache__/views.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/ws_api.cpython-313.pyc b/custom_components/frigate/__pycache__/ws_api.cpython-313.pyc deleted file mode 100644 index 12b57ff8..00000000 Binary files a/custom_components/frigate/__pycache__/ws_api.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/__pycache__/ws_event_proxy.cpython-313.pyc b/custom_components/frigate/__pycache__/ws_event_proxy.cpython-313.pyc deleted file mode 100644 index c1545b0e..00000000 Binary files a/custom_components/frigate/__pycache__/ws_event_proxy.cpython-313.pyc and /dev/null differ diff --git a/custom_components/frigate/api.py b/custom_components/frigate/api.py index a8763e0c..5609d5a0 100644 --- a/custom_components/frigate/api.py +++ b/custom_components/frigate/api.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import datetime import logging import socket from typing import Any, cast @@ -11,6 +12,8 @@ import async_timeout from yarl import URL +from homeassistant.auth import jwt_wrapper + TIMEOUT = 10 _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -31,10 +34,19 @@ class FrigateApiClientError(Exception): class FrigateApiClient: """Frigate API client.""" - def __init__(self, host: str, session: aiohttp.ClientSession) -> None: + def __init__( + self, + host: str, + session: aiohttp.ClientSession, + username: str | None = None, + password: str | None = None, + ) -> None: """Construct API Client.""" self._host = host self._session = session + self._username = username + self._password = password + self._token_data: dict[str, Any] = {} async def async_get_version(self) -> str: """Get data from the API.""" @@ -216,6 +228,70 @@ async def async_get_recordings( ) return cast(dict[str, Any], result) if decode_json else result + async def _get_token(self) -> None: + """ + Obtain a new JWT token using the provided username and password. + Sends a POST request to the login endpoint and extracts the token + and expiration date from the response headers. + """ + response = await self.api_wrapper( + method="post", + url=str(URL(self._host) / "api/login"), + data={"user": self._username, "password": self._password}, + decode_json=False, + is_login_request=True, + ) + + set_cookie_header = response.headers.get("Set-Cookie", "") + if not set_cookie_header: + raise KeyError("Missing Set-Cookie header in response") + + for cookie_prop in set_cookie_header.split(";"): + cookie_prop = cookie_prop.strip() + if cookie_prop.startswith("frigate_token="): + jwt_token = cookie_prop.split("=", 1)[1] + self._token_data["token"] = jwt_token + try: + decoded_token = jwt_wrapper.unverified_hs256_token_decode(jwt_token) + except Exception as e: + raise ValueError(f"Failed to decode JWT token: {e}") + exp_timestamp = decoded_token.get("exp") + if not exp_timestamp: + raise KeyError("JWT is missing 'exp' claim") + self._token_data["expires"] = datetime.datetime.fromtimestamp( + exp_timestamp, datetime.UTC + ) + break + else: + raise KeyError("Missing 'frigate_token' in Set-Cookie header") + + async def _refresh_token_if_needed(self) -> None: + """ + Refresh the JWT token if it is expired or about to expire. + """ + if "expires" not in self._token_data: + await self._get_token() + return + + current_time = datetime.datetime.now(datetime.UTC) + if current_time >= self._token_data["expires"]: # Compare UTC-aware datetimes + await self._get_token() + + async def _get_auth_headers(self) -> dict[str, str]: + """ + Get headers for API requests, including the JWT token if available. + Ensures that the token is refreshed if needed. + """ + headers = {} + + if self._username and self._password: + await self._refresh_token_if_needed() + + if "token" in self._token_data: + headers["Authorization"] = f"Bearer {self._token_data['token']}" + + return headers + async def api_wrapper( self, method: str, @@ -223,6 +299,7 @@ async def api_wrapper( data: dict | None = None, headers: dict | None = None, decode_json: bool = True, + is_login_request: bool = False, ) -> Any: """Get information from the API.""" if data is None: @@ -230,6 +307,9 @@ async def api_wrapper( if headers is None: headers = {} + if not is_login_request: + headers.update(await self._get_auth_headers()) + try: async with async_timeout.timeout(TIMEOUT): func = getattr(self._session, method) @@ -237,6 +317,9 @@ async def api_wrapper( response = await func( url, headers=headers, raise_for_status=True, json=data ) + response.raise_for_status() + if is_login_request: + return response if decode_json: return await response.json() return await response.text() @@ -249,6 +332,27 @@ async def api_wrapper( ) raise FrigateApiClientError from exc + except aiohttp.ClientResponseError as exc: + if exc.status == 401: + _LOGGER.error( + "Unauthorized (401) error for URL %s: %s", url, exc.message + ) + raise FrigateApiClientError( + "Unauthorized access - check credentials." + ) from exc + elif exc.status == 403: + _LOGGER.error("Forbidden (403) error for URL %s: %s", url, exc.message) + raise FrigateApiClientError( + "Forbidden - insufficient permissions." + ) from exc + else: + _LOGGER.error( + "Client response error (%d) for URL %s: %s", + exc.status, + url, + exc.message, + ) + raise FrigateApiClientError from exc except (KeyError, TypeError) as exc: _LOGGER.error( "Error parsing information from %s: %s", diff --git a/custom_components/frigate/camera.py b/custom_components/frigate/camera.py index 76d3bcf9..9d62252f 100644 --- a/custom_components/frigate/camera.py +++ b/custom_components/frigate/camera.py @@ -15,7 +15,6 @@ from homeassistant.components.camera import ( Camera, CameraEntityFeature, - StreamType, WebRTCAnswer, WebRTCSendMessage, ) @@ -437,13 +436,6 @@ async def stream_source(self) -> str | None: class FrigateCameraWebRTC(FrigateCamera): """A Frigate camera with WebRTC support.""" - # TODO: this property can be removed after this fix is released: - # https://github.com/home-assistant/core/pull/130932/files#diff-75655c0eec1c3e736cad1bdb5627100a4595ece9accc391b5c85343bb998594fR598-R603 - @property - def frontend_stream_type(self) -> StreamType | None: - """Return the type of stream supported by this camera.""" - return StreamType.WEB_RTC - async def async_handle_async_webrtc_offer( self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage ) -> None: @@ -463,13 +455,6 @@ async def async_on_webrtc_candidate(self, session_id: str, candidate: Any) -> No class BirdseyeCameraWebRTC(BirdseyeCamera): """A Frigate birdseye camera with WebRTC support.""" - # TODO: this property can be removed after this fix is released: - # https://github.com/home-assistant/core/pull/130932/files#diff-75655c0eec1c3e736cad1bdb5627100a4595ece9accc391b5c85343bb998594fR598-R603 - @property - def frontend_stream_type(self) -> StreamType | None: - """Return the type of stream supported by this camera.""" - return StreamType.WEB_RTC - async def async_handle_async_webrtc_offer( self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage ) -> None: diff --git a/custom_components/frigate/config_flow.py b/custom_components/frigate/config_flow.py index e5ebde10..39852f59 100644 --- a/custom_components/frigate/config_flow.py +++ b/custom_components/frigate/config_flow.py @@ -10,7 +10,7 @@ from yarl import URL from homeassistant import config_entries -from homeassistant.const import CONF_URL +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -48,32 +48,62 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> config_entries.ConfigFlowResult: """Handle a flow initialized by the user.""" + return await self._handle_config_step(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Handle a flow initialized by a reconfiguration.""" + return await self._handle_config_step( + user_input, + default_form_input=dict(self._get_reconfigure_entry().data), + ) + + async def _handle_config_step( + self, + user_input: dict[str, Any] | None = None, + default_form_input: dict[str, Any] | None = None, + ) -> config_entries.ConfigFlowResult: + """Handle a config step.""" if user_input is None: - return self._show_config_form() + return self._show_config_form(user_input=default_form_input) try: - # Cannot use cv.url validation in the schema itself, so - # apply extra validation here. + # Cannot use cv.url validation in the schema itself, so apply extra + # validation here. cv.url(user_input[CONF_URL]) except vol.Invalid: return self._show_config_form(user_input, errors={"base": "invalid_url"}) try: session = async_create_clientsession(self.hass) - client = FrigateApiClient(user_input[CONF_URL], session) + client = FrigateApiClient( + user_input[CONF_URL], + session, + user_input.get(CONF_USERNAME), + user_input.get(CONF_PASSWORD), + ) await client.async_get_stats() except FrigateApiClientError: return self._show_config_form(user_input, errors={"base": "cannot_connect"}) # Search for duplicates with the same Frigate CONF_HOST value. - for existing_entry in self._async_current_entries(include_ignore=False): - if existing_entry.data.get(CONF_URL) == user_input[CONF_URL]: - return self.async_abort(reason="already_configured") - - return self.async_create_entry( - title=get_config_entry_title(user_input[CONF_URL]), data=user_input - ) + if self.source != config_entries.SOURCE_RECONFIGURE: + for existing_entry in self._async_current_entries(include_ignore=False): + if existing_entry.data.get(CONF_URL) == user_input[CONF_URL]: + return self.async_abort(reason="already_configured") + + if self.source == config_entries.SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + title=get_config_entry_title(user_input[CONF_URL]), + data=user_input, + ) + else: + return self.async_create_entry( + title=get_config_entry_title(user_input[CONF_URL]), data=user_input + ) def _show_config_form( self, @@ -90,7 +120,13 @@ def _show_config_form( { vol.Required( CONF_URL, default=user_input.get(CONF_URL, DEFAULT_HOST) - ): str + ): str, + vol.Optional( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ): str, + vol.Optional( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, } ), errors=errors, diff --git a/custom_components/frigate/const.py b/custom_components/frigate/const.py index 1bfa78cb..9204d452 100644 --- a/custom_components/frigate/const.py +++ b/custom_components/frigate/const.py @@ -50,6 +50,7 @@ CONF_MEDIA_BROWSER_ENABLE = "media_browser_enable" CONF_NOTIFICATION_PROXY_ENABLE = "notification_proxy_enable" CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS = "notification_proxy_expire_after_seconds" +CONF_USERNAME = "username" CONF_PASSWORD = "password" CONF_PATH = "path" CONF_RTSP_URL_TEMPLATE = "rtsp_url_template" diff --git a/custom_components/frigate/manifest.json b/custom_components/frigate/manifest.json index c1e1bdf6..fdb9dde0 100644 --- a/custom_components/frigate/manifest.json +++ b/custom_components/frigate/manifest.json @@ -15,6 +15,6 @@ "documentation": "https://github.com/blakeblackshear/frigate", "iot_class": "local_push", "issue_tracker": "https://github.com/blakeblackshear/frigate-hass-integration/issues", - "requirements": ["hass-web-proxy-lib==0.0.7", "pytz"], - "version": "5.5.1" + "requirements": ["hass-web-proxy-lib==0.0.7"], + "version": "5.6.0" } diff --git a/custom_components/frigate/media_source.py b/custom_components/frigate/media_source.py index fdfbf6e8..6165dded 100644 --- a/custom_components/frigate/media_source.py +++ b/custom_components/frigate/media_source.py @@ -9,7 +9,6 @@ import attr from dateutil.relativedelta import relativedelta -import pytz from homeassistant.components.media_player.const import MediaClass, MediaType from homeassistant.components.media_source.error import MediaSourceError, Unresolvable @@ -22,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import system_info from homeassistant.helpers.template import DATE_STR_FORMAT -from homeassistant.util.dt import DEFAULT_TIME_ZONE +from homeassistant.util.dt import DEFAULT_TIME_ZONE, async_get_time_zone from . import get_friendly_name from .api import FrigateApiClient, FrigateApiClientError @@ -118,7 +117,7 @@ def get_identifier_type(cls) -> str: """Get the identifier type.""" raise NotImplementedError - def get_integration_proxy_path(self, timezone: str) -> str: + def get_integration_proxy_path(self, tz_info: dt.tzinfo) -> str: """Get the proxy (Home Assistant view) path for this identifier.""" raise NotImplementedError @@ -240,7 +239,7 @@ def get_identifier_type(cls) -> str: """Get the identifier type.""" return "event" - def get_integration_proxy_path(self, timezone: str) -> str: + def get_integration_proxy_path(self, tz_info: dt.tzinfo) -> str: """Get the equivalent Frigate server path.""" if self.frigate_media_type == FrigateMediaType.CLIPS: return f"vod/event/{self.id}/index.{self.frigate_media_type.extension}" @@ -444,7 +443,7 @@ def get_identifier_type(cls) -> str: """Get the identifier type.""" return "recordings" - def get_integration_proxy_path(self, timezone: str) -> str: + def get_integration_proxy_path(self, tz_info: dt.tzinfo) -> str: """Get the integration path that will proxy this identifier.""" if ( @@ -460,8 +459,8 @@ def get_integration_proxy_path(self, timezone: str) -> str: int(month), int(day), int(self.hour), - tzinfo=dt.timezone.utc, - ) - (dt.datetime.now(pytz.timezone(timezone)).utcoffset() or dt.timedelta()) + tzinfo=dt.UTC, + ) - (dt.datetime.now(tz_info).utcoffset() or dt.timedelta()) parts = [ "vod", @@ -565,9 +564,14 @@ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: identifier.frigate_instance_id ): info = await system_info.async_get_system_info(self.hass) - server_path = identifier.get_integration_proxy_path( - info.get("timezone", "utc") - ) + tz_name = info.get("timezone", "utc") + tz_info = await async_get_time_zone(tz_name) + if not tz_info: + raise Unresolvable( + f"Could not get timezone object for timezone: {tz_name}" + ) + + server_path = identifier.get_integration_proxy_path(tz_info) return PlayMedia( f"/api/frigate/{identifier.frigate_instance_id}/{server_path}", identifier.mime_type, diff --git a/custom_components/frigate/translations/en.json b/custom_components/frigate/translations/en.json index 4d42f01c..669ed8b8 100644 --- a/custom_components/frigate/translations/en.json +++ b/custom_components/frigate/translations/en.json @@ -4,7 +4,9 @@ "user": { "description": "URL you use to access Frigate (ie. `http://frigate:5000/`)\n\nIf you are using HassOS with the addon, the URL should be `http://ccab4aaf-frigate:5000/`\n\nHome Assistant needs access to port 5000 (api) and 8554/8555 (rtsp, webrtc) for all features.\n\nThe integration will setup sensors, cameras, and media browser functionality.\n\nSensors:\n- Stats to monitor frigate performance\n- Object counts for all zones and cameras\n\nCameras:\n- Cameras for image of the last detected object for each camera\n- Camera entities with stream support\n\nMedia Browser:\n- Rich UI with thumbnails for browsing event clips\n- Rich UI for browsing 24/7 recordings by month, day, camera, time\n\nAPI:\n- Notification API with public facing endpoints for images in notifications", "data": { - "url": "URL" + "url": "URL", + "username": "Username (optional)", + "password": "Password (optional)" } } }, diff --git a/custom_components/frigate/translations/fr.json b/custom_components/frigate/translations/fr.json index 21c07aa2..8704d828 100644 --- a/custom_components/frigate/translations/fr.json +++ b/custom_components/frigate/translations/fr.json @@ -4,7 +4,9 @@ "user": { "description": "URL que vous utilisez pour accéder à Frigate (par exemple, `http://frigate:5000/`)\n\nSi vous utilisez HassOS avec l'addon, l'URL devrait être `http://ccab4aaf-frigate:5000/`\n\nHome Assistant a besoin d'accès au port 5000 (api) et 8554/8555 (rtsp, webrtc) pour toutes les fonctionnalités.\n\nL'intégration configurera des capteurs, des caméras et la fonctionnalité de navigateur multimédia.\n\nCapteurs :\n- Statistiques pour surveiller la performance de Frigate\n- Comptes d'objets pour toutes les zones et caméras\n\nCaméras :\n- Caméras pour l'image du dernier objet détecté pour chaque caméra\n- Entités de caméra avec support de flux\n\nNavigateur multimédia :\n- Interface riche avec miniatures pour parcourir les clips d'événements\n- Interface riche pour parcourir les enregistrements 24/7 par mois, jour, caméra, heure\n\nAPI :\n- API de notification avec des points de terminaison publics pour les images dans les notifications", "data": { - "url": "URL" + "url": "URL", + "username": "Nom d'utilisateur (facultatif)", + "password": "Mot de passe (facultatif)" } } }, @@ -31,4 +33,4 @@ "only_advanced_options": "Le mode avancé est désactivé et il n'y a que des options avancées" } } -} \ No newline at end of file +} diff --git a/custom_components/frigate/views.py b/custom_components/frigate/views.py index ba0628ec..f1e2c73e 100644 --- a/custom_components/frigate/views.py +++ b/custom_components/frigate/views.py @@ -245,6 +245,8 @@ def _get_proxied_url( url_path = f"api/events/{event_id}/snapshot.jpg" elif path.endswith("clip.mp4"): url_path = f"api/events/{event_id}/clip.mp4" + elif path.endswith("master.m3u8"): + url_path = f"vod/events/{event_id}/master.m3u8" elif path.endswith("event_preview.gif"): url_path = f"api/events/{event_id}/preview.gif" elif path.endswith("review_preview.gif"): diff --git a/custom_components/google_fit/__pycache__/__init__.cpython-312.pyc b/custom_components/google_fit/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 894f3f58..00000000 Binary files a/custom_components/google_fit/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_fit/__pycache__/api.cpython-312.pyc b/custom_components/google_fit/__pycache__/api.cpython-312.pyc deleted file mode 100644 index 2bddb624..00000000 Binary files a/custom_components/google_fit/__pycache__/api.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_fit/__pycache__/api_types.cpython-312.pyc b/custom_components/google_fit/__pycache__/api_types.cpython-312.pyc deleted file mode 100644 index 2e85ccb4..00000000 Binary files a/custom_components/google_fit/__pycache__/api_types.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_fit/__pycache__/application_credentials.cpython-312.pyc b/custom_components/google_fit/__pycache__/application_credentials.cpython-312.pyc deleted file mode 100644 index d804b76e..00000000 Binary files a/custom_components/google_fit/__pycache__/application_credentials.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_fit/__pycache__/config_flow.cpython-312.pyc b/custom_components/google_fit/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index baed7b2c..00000000 Binary files a/custom_components/google_fit/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_fit/__pycache__/const.cpython-312.pyc b/custom_components/google_fit/__pycache__/const.cpython-312.pyc deleted file mode 100644 index 93e35613..00000000 Binary files a/custom_components/google_fit/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_fit/__pycache__/coordinator.cpython-312.pyc b/custom_components/google_fit/__pycache__/coordinator.cpython-312.pyc deleted file mode 100644 index 8a5e5745..00000000 Binary files a/custom_components/google_fit/__pycache__/coordinator.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_fit/__pycache__/entity.cpython-312.pyc b/custom_components/google_fit/__pycache__/entity.cpython-312.pyc deleted file mode 100644 index c8f09c37..00000000 Binary files a/custom_components/google_fit/__pycache__/entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_fit/__pycache__/sensor.cpython-312.pyc b/custom_components/google_fit/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index 8fd4dab7..00000000 Binary files a/custom_components/google_fit/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_fit/__pycache__/sensor.cpython-313.pyc b/custom_components/google_fit/__pycache__/sensor.cpython-313.pyc index 28752d9a..0df5b83b 100644 Binary files a/custom_components/google_fit/__pycache__/sensor.cpython-313.pyc and b/custom_components/google_fit/__pycache__/sensor.cpython-313.pyc differ diff --git a/custom_components/google_home/__pycache__/__init__.cpython-312.pyc b/custom_components/google_home/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 277256d1..00000000 Binary files a/custom_components/google_home/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_home/__pycache__/api.cpython-312.pyc b/custom_components/google_home/__pycache__/api.cpython-312.pyc deleted file mode 100644 index 3c77fe24..00000000 Binary files a/custom_components/google_home/__pycache__/api.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_home/__pycache__/api.cpython-313.pyc b/custom_components/google_home/__pycache__/api.cpython-313.pyc index e6623e9f..015a1766 100644 Binary files a/custom_components/google_home/__pycache__/api.cpython-313.pyc and b/custom_components/google_home/__pycache__/api.cpython-313.pyc differ diff --git a/custom_components/google_home/__pycache__/config_flow.cpython-312.pyc b/custom_components/google_home/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index 017c3d79..00000000 Binary files a/custom_components/google_home/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_home/__pycache__/const.cpython-312.pyc b/custom_components/google_home/__pycache__/const.cpython-312.pyc deleted file mode 100644 index 38ef6de5..00000000 Binary files a/custom_components/google_home/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_home/__pycache__/entity.cpython-312.pyc b/custom_components/google_home/__pycache__/entity.cpython-312.pyc deleted file mode 100644 index fbddfed9..00000000 Binary files a/custom_components/google_home/__pycache__/entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_home/__pycache__/entity.cpython-313.pyc b/custom_components/google_home/__pycache__/entity.cpython-313.pyc index 6e95c69d..37d93850 100644 Binary files a/custom_components/google_home/__pycache__/entity.cpython-313.pyc and b/custom_components/google_home/__pycache__/entity.cpython-313.pyc differ diff --git a/custom_components/google_home/__pycache__/exceptions.cpython-312.pyc b/custom_components/google_home/__pycache__/exceptions.cpython-312.pyc deleted file mode 100644 index a7fb2407..00000000 Binary files a/custom_components/google_home/__pycache__/exceptions.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_home/__pycache__/models.cpython-312.pyc b/custom_components/google_home/__pycache__/models.cpython-312.pyc deleted file mode 100644 index a61f791b..00000000 Binary files a/custom_components/google_home/__pycache__/models.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_home/__pycache__/models.cpython-313.pyc b/custom_components/google_home/__pycache__/models.cpython-313.pyc index c95bd8ed..f13ccf52 100644 Binary files a/custom_components/google_home/__pycache__/models.cpython-313.pyc and b/custom_components/google_home/__pycache__/models.cpython-313.pyc differ diff --git a/custom_components/google_home/__pycache__/number.cpython-312.pyc b/custom_components/google_home/__pycache__/number.cpython-312.pyc deleted file mode 100644 index abb77d94..00000000 Binary files a/custom_components/google_home/__pycache__/number.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_home/__pycache__/number.cpython-313.pyc b/custom_components/google_home/__pycache__/number.cpython-313.pyc index db7899e2..af172436 100644 Binary files a/custom_components/google_home/__pycache__/number.cpython-313.pyc and b/custom_components/google_home/__pycache__/number.cpython-313.pyc differ diff --git a/custom_components/google_home/__pycache__/sensor.cpython-312.pyc b/custom_components/google_home/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index d7baf3a7..00000000 Binary files a/custom_components/google_home/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_home/__pycache__/sensor.cpython-313.pyc b/custom_components/google_home/__pycache__/sensor.cpython-313.pyc index ee366f70..e2c3be79 100644 Binary files a/custom_components/google_home/__pycache__/sensor.cpython-313.pyc and b/custom_components/google_home/__pycache__/sensor.cpython-313.pyc differ diff --git a/custom_components/google_home/__pycache__/switch.cpython-312.pyc b/custom_components/google_home/__pycache__/switch.cpython-312.pyc deleted file mode 100644 index 80068775..00000000 Binary files a/custom_components/google_home/__pycache__/switch.cpython-312.pyc and /dev/null differ diff --git a/custom_components/google_home/__pycache__/switch.cpython-313.pyc b/custom_components/google_home/__pycache__/switch.cpython-313.pyc index 134cbb2f..a7944b62 100644 Binary files a/custom_components/google_home/__pycache__/switch.cpython-313.pyc and b/custom_components/google_home/__pycache__/switch.cpython-313.pyc differ diff --git a/custom_components/google_home/__pycache__/types.cpython-312.pyc b/custom_components/google_home/__pycache__/types.cpython-312.pyc deleted file mode 100644 index d215d4c0..00000000 Binary files a/custom_components/google_home/__pycache__/types.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/__init__.cpython-312.pyc b/custom_components/hacs/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 837394b7..00000000 Binary files a/custom_components/hacs/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/base.cpython-312.pyc b/custom_components/hacs/__pycache__/base.cpython-312.pyc deleted file mode 100644 index da5ecf21..00000000 Binary files a/custom_components/hacs/__pycache__/base.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/base.cpython-313.pyc b/custom_components/hacs/__pycache__/base.cpython-313.pyc index fac5e42e..ecd96cd0 100644 Binary files a/custom_components/hacs/__pycache__/base.cpython-313.pyc and b/custom_components/hacs/__pycache__/base.cpython-313.pyc differ diff --git a/custom_components/hacs/__pycache__/config_flow.cpython-312.pyc b/custom_components/hacs/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index e1ce6cbb..00000000 Binary files a/custom_components/hacs/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/config_flow.cpython-313.pyc b/custom_components/hacs/__pycache__/config_flow.cpython-313.pyc index cb684558..b4fcce17 100644 Binary files a/custom_components/hacs/__pycache__/config_flow.cpython-313.pyc and b/custom_components/hacs/__pycache__/config_flow.cpython-313.pyc differ diff --git a/custom_components/hacs/__pycache__/const.cpython-312.pyc b/custom_components/hacs/__pycache__/const.cpython-312.pyc deleted file mode 100644 index 39f477af..00000000 Binary files a/custom_components/hacs/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/coordinator.cpython-312.pyc b/custom_components/hacs/__pycache__/coordinator.cpython-312.pyc deleted file mode 100644 index 5caf3caa..00000000 Binary files a/custom_components/hacs/__pycache__/coordinator.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/data_client.cpython-312.pyc b/custom_components/hacs/__pycache__/data_client.cpython-312.pyc deleted file mode 100644 index 8996cae1..00000000 Binary files a/custom_components/hacs/__pycache__/data_client.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/data_client.cpython-313.pyc b/custom_components/hacs/__pycache__/data_client.cpython-313.pyc index 0041492e..60fa92d2 100644 Binary files a/custom_components/hacs/__pycache__/data_client.cpython-313.pyc and b/custom_components/hacs/__pycache__/data_client.cpython-313.pyc differ diff --git a/custom_components/hacs/__pycache__/diagnostics.cpython-312.pyc b/custom_components/hacs/__pycache__/diagnostics.cpython-312.pyc deleted file mode 100644 index 8e4775db..00000000 Binary files a/custom_components/hacs/__pycache__/diagnostics.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/diagnostics.cpython-313.pyc b/custom_components/hacs/__pycache__/diagnostics.cpython-313.pyc index 03729e99..ef653a6d 100644 Binary files a/custom_components/hacs/__pycache__/diagnostics.cpython-313.pyc and b/custom_components/hacs/__pycache__/diagnostics.cpython-313.pyc differ diff --git a/custom_components/hacs/__pycache__/entity.cpython-312.pyc b/custom_components/hacs/__pycache__/entity.cpython-312.pyc deleted file mode 100644 index 6140b9bc..00000000 Binary files a/custom_components/hacs/__pycache__/entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/entity.cpython-313.pyc b/custom_components/hacs/__pycache__/entity.cpython-313.pyc index 6a44ab47..2349d1b9 100644 Binary files a/custom_components/hacs/__pycache__/entity.cpython-313.pyc and b/custom_components/hacs/__pycache__/entity.cpython-313.pyc differ diff --git a/custom_components/hacs/__pycache__/enums.cpython-312.pyc b/custom_components/hacs/__pycache__/enums.cpython-312.pyc deleted file mode 100644 index e119429d..00000000 Binary files a/custom_components/hacs/__pycache__/enums.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/exceptions.cpython-312.pyc b/custom_components/hacs/__pycache__/exceptions.cpython-312.pyc deleted file mode 100644 index d169214d..00000000 Binary files a/custom_components/hacs/__pycache__/exceptions.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/frontend.cpython-312.pyc b/custom_components/hacs/__pycache__/frontend.cpython-312.pyc deleted file mode 100644 index a6f9288e..00000000 Binary files a/custom_components/hacs/__pycache__/frontend.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/repairs.cpython-312.pyc b/custom_components/hacs/__pycache__/repairs.cpython-312.pyc deleted file mode 100644 index 387d9e95..00000000 Binary files a/custom_components/hacs/__pycache__/repairs.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/switch.cpython-312.pyc b/custom_components/hacs/__pycache__/switch.cpython-312.pyc deleted file mode 100644 index b24f4080..00000000 Binary files a/custom_components/hacs/__pycache__/switch.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/switch.cpython-313.pyc b/custom_components/hacs/__pycache__/switch.cpython-313.pyc index 8afa043d..3ac71493 100644 Binary files a/custom_components/hacs/__pycache__/switch.cpython-313.pyc and b/custom_components/hacs/__pycache__/switch.cpython-313.pyc differ diff --git a/custom_components/hacs/__pycache__/system_health.cpython-312.pyc b/custom_components/hacs/__pycache__/system_health.cpython-312.pyc deleted file mode 100644 index 7cd13571..00000000 Binary files a/custom_components/hacs/__pycache__/system_health.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/types.cpython-312.pyc b/custom_components/hacs/__pycache__/types.cpython-312.pyc deleted file mode 100644 index 5045e952..00000000 Binary files a/custom_components/hacs/__pycache__/types.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/update.cpython-312.pyc b/custom_components/hacs/__pycache__/update.cpython-312.pyc deleted file mode 100644 index b4ebf804..00000000 Binary files a/custom_components/hacs/__pycache__/update.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/__pycache__/update.cpython-313.pyc b/custom_components/hacs/__pycache__/update.cpython-313.pyc index ff2b2ab6..794ac57b 100644 Binary files a/custom_components/hacs/__pycache__/update.cpython-313.pyc and b/custom_components/hacs/__pycache__/update.cpython-313.pyc differ diff --git a/custom_components/hacs/hacs_frontend/__pycache__/__init__.cpython-312.pyc b/custom_components/hacs/hacs_frontend/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 9032a881..00000000 Binary files a/custom_components/hacs/hacs_frontend/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/hacs_frontend/__pycache__/version.cpython-312.pyc b/custom_components/hacs/hacs_frontend/__pycache__/version.cpython-312.pyc deleted file mode 100644 index 9d762cb7..00000000 Binary files a/custom_components/hacs/hacs_frontend/__pycache__/version.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/repositories/__pycache__/__init__.cpython-312.pyc b/custom_components/hacs/repositories/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index f787024b..00000000 Binary files a/custom_components/hacs/repositories/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/repositories/__pycache__/appdaemon.cpython-312.pyc b/custom_components/hacs/repositories/__pycache__/appdaemon.cpython-312.pyc deleted file mode 100644 index 9604034e..00000000 Binary files a/custom_components/hacs/repositories/__pycache__/appdaemon.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/repositories/__pycache__/base.cpython-312.pyc b/custom_components/hacs/repositories/__pycache__/base.cpython-312.pyc deleted file mode 100644 index 2644395f..00000000 Binary files a/custom_components/hacs/repositories/__pycache__/base.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/repositories/__pycache__/base.cpython-313.pyc b/custom_components/hacs/repositories/__pycache__/base.cpython-313.pyc index b5fa712b..1ba3dc16 100644 Binary files a/custom_components/hacs/repositories/__pycache__/base.cpython-313.pyc and b/custom_components/hacs/repositories/__pycache__/base.cpython-313.pyc differ diff --git a/custom_components/hacs/repositories/__pycache__/integration.cpython-312.pyc b/custom_components/hacs/repositories/__pycache__/integration.cpython-312.pyc deleted file mode 100644 index 9a29fffd..00000000 Binary files a/custom_components/hacs/repositories/__pycache__/integration.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/repositories/__pycache__/integration.cpython-313.pyc b/custom_components/hacs/repositories/__pycache__/integration.cpython-313.pyc index 77fb584a..ccfc95e3 100644 Binary files a/custom_components/hacs/repositories/__pycache__/integration.cpython-313.pyc and b/custom_components/hacs/repositories/__pycache__/integration.cpython-313.pyc differ diff --git a/custom_components/hacs/repositories/__pycache__/plugin.cpython-312.pyc b/custom_components/hacs/repositories/__pycache__/plugin.cpython-312.pyc deleted file mode 100644 index 199b3460..00000000 Binary files a/custom_components/hacs/repositories/__pycache__/plugin.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/repositories/__pycache__/plugin.cpython-313.pyc b/custom_components/hacs/repositories/__pycache__/plugin.cpython-313.pyc index 127fabd3..0821ab63 100644 Binary files a/custom_components/hacs/repositories/__pycache__/plugin.cpython-313.pyc and b/custom_components/hacs/repositories/__pycache__/plugin.cpython-313.pyc differ diff --git a/custom_components/hacs/repositories/__pycache__/python_script.cpython-312.pyc b/custom_components/hacs/repositories/__pycache__/python_script.cpython-312.pyc deleted file mode 100644 index 71bf46e7..00000000 Binary files a/custom_components/hacs/repositories/__pycache__/python_script.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/repositories/__pycache__/template.cpython-312.pyc b/custom_components/hacs/repositories/__pycache__/template.cpython-312.pyc deleted file mode 100644 index 38e62c6f..00000000 Binary files a/custom_components/hacs/repositories/__pycache__/template.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/repositories/__pycache__/theme.cpython-312.pyc b/custom_components/hacs/repositories/__pycache__/theme.cpython-312.pyc deleted file mode 100644 index 967cbe32..00000000 Binary files a/custom_components/hacs/repositories/__pycache__/theme.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/__init__.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 6f9f79ec..00000000 Binary files a/custom_components/hacs/utils/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/backup.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/backup.cpython-312.pyc deleted file mode 100644 index 6cd2e0f4..00000000 Binary files a/custom_components/hacs/utils/__pycache__/backup.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/configuration_schema.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/configuration_schema.cpython-312.pyc deleted file mode 100644 index ab8b75e4..00000000 Binary files a/custom_components/hacs/utils/__pycache__/configuration_schema.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/data.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/data.cpython-312.pyc deleted file mode 100644 index 977a9538..00000000 Binary files a/custom_components/hacs/utils/__pycache__/data.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/data.cpython-313.pyc b/custom_components/hacs/utils/__pycache__/data.cpython-313.pyc index de48d020..b808e157 100644 Binary files a/custom_components/hacs/utils/__pycache__/data.cpython-313.pyc and b/custom_components/hacs/utils/__pycache__/data.cpython-313.pyc differ diff --git a/custom_components/hacs/utils/__pycache__/decode.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/decode.cpython-312.pyc deleted file mode 100644 index 1616f3b8..00000000 Binary files a/custom_components/hacs/utils/__pycache__/decode.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/decorator.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/decorator.cpython-312.pyc deleted file mode 100644 index 8cecacd0..00000000 Binary files a/custom_components/hacs/utils/__pycache__/decorator.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/file_system.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/file_system.cpython-312.pyc deleted file mode 100644 index 0d8c2055..00000000 Binary files a/custom_components/hacs/utils/__pycache__/file_system.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/filters.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/filters.cpython-312.pyc deleted file mode 100644 index 5381a04f..00000000 Binary files a/custom_components/hacs/utils/__pycache__/filters.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/github_graphql_query.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/github_graphql_query.cpython-312.pyc deleted file mode 100644 index 45b4fd4e..00000000 Binary files a/custom_components/hacs/utils/__pycache__/github_graphql_query.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/json.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/json.cpython-312.pyc deleted file mode 100644 index dd7c58d0..00000000 Binary files a/custom_components/hacs/utils/__pycache__/json.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/logger.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/logger.cpython-312.pyc deleted file mode 100644 index 81b48f63..00000000 Binary files a/custom_components/hacs/utils/__pycache__/logger.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/path.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/path.cpython-312.pyc deleted file mode 100644 index a72f5107..00000000 Binary files a/custom_components/hacs/utils/__pycache__/path.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/queue_manager.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/queue_manager.cpython-312.pyc deleted file mode 100644 index 5e2932f2..00000000 Binary files a/custom_components/hacs/utils/__pycache__/queue_manager.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/regex.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/regex.cpython-312.pyc deleted file mode 100644 index 810508f9..00000000 Binary files a/custom_components/hacs/utils/__pycache__/regex.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/store.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/store.cpython-312.pyc deleted file mode 100644 index b7a1c662..00000000 Binary files a/custom_components/hacs/utils/__pycache__/store.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/url.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/url.cpython-312.pyc deleted file mode 100644 index 1b6bd3af..00000000 Binary files a/custom_components/hacs/utils/__pycache__/url.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/validate.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/validate.cpython-312.pyc deleted file mode 100644 index c31c97b2..00000000 Binary files a/custom_components/hacs/utils/__pycache__/validate.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/validate.cpython-313.pyc b/custom_components/hacs/utils/__pycache__/validate.cpython-313.pyc index c2e4f6f1..4f787718 100644 Binary files a/custom_components/hacs/utils/__pycache__/validate.cpython-313.pyc and b/custom_components/hacs/utils/__pycache__/validate.cpython-313.pyc differ diff --git a/custom_components/hacs/utils/__pycache__/version.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/version.cpython-312.pyc deleted file mode 100644 index 1fc15af9..00000000 Binary files a/custom_components/hacs/utils/__pycache__/version.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/utils/__pycache__/workarounds.cpython-312.pyc b/custom_components/hacs/utils/__pycache__/workarounds.cpython-312.pyc deleted file mode 100644 index 5d736283..00000000 Binary files a/custom_components/hacs/utils/__pycache__/workarounds.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/websocket/__pycache__/__init__.cpython-312.pyc b/custom_components/hacs/websocket/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e1b9c7c1..00000000 Binary files a/custom_components/hacs/websocket/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/websocket/__pycache__/critical.cpython-312.pyc b/custom_components/hacs/websocket/__pycache__/critical.cpython-312.pyc deleted file mode 100644 index b66b1416..00000000 Binary files a/custom_components/hacs/websocket/__pycache__/critical.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/websocket/__pycache__/repositories.cpython-312.pyc b/custom_components/hacs/websocket/__pycache__/repositories.cpython-312.pyc deleted file mode 100644 index 898101c4..00000000 Binary files a/custom_components/hacs/websocket/__pycache__/repositories.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/websocket/__pycache__/repositories.cpython-313.pyc b/custom_components/hacs/websocket/__pycache__/repositories.cpython-313.pyc index 9d5e02e2..4032610a 100644 Binary files a/custom_components/hacs/websocket/__pycache__/repositories.cpython-313.pyc and b/custom_components/hacs/websocket/__pycache__/repositories.cpython-313.pyc differ diff --git a/custom_components/hacs/websocket/__pycache__/repository.cpython-312.pyc b/custom_components/hacs/websocket/__pycache__/repository.cpython-312.pyc deleted file mode 100644 index d5d77ce5..00000000 Binary files a/custom_components/hacs/websocket/__pycache__/repository.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hacs/websocket/__pycache__/repository.cpython-313.pyc b/custom_components/hacs/websocket/__pycache__/repository.cpython-313.pyc index ecdf8e04..e6a9f0a2 100644 Binary files a/custom_components/hacs/websocket/__pycache__/repository.cpython-313.pyc and b/custom_components/hacs/websocket/__pycache__/repository.cpython-313.pyc differ diff --git a/custom_components/hass_agent/__pycache__/__init__.cpython-312.pyc b/custom_components/hass_agent/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index c1e14a41..00000000 Binary files a/custom_components/hass_agent/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hass_agent/__pycache__/__init__.cpython-313.pyc b/custom_components/hass_agent/__pycache__/__init__.cpython-313.pyc index 68d6a4ea..a46cfd04 100644 Binary files a/custom_components/hass_agent/__pycache__/__init__.cpython-313.pyc and b/custom_components/hass_agent/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/hass_agent/__pycache__/config_flow.cpython-312.pyc b/custom_components/hass_agent/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index e37ba5c4..00000000 Binary files a/custom_components/hass_agent/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hass_agent/__pycache__/config_flow.cpython-313.pyc b/custom_components/hass_agent/__pycache__/config_flow.cpython-313.pyc index 41d69368..a071a856 100644 Binary files a/custom_components/hass_agent/__pycache__/config_flow.cpython-313.pyc and b/custom_components/hass_agent/__pycache__/config_flow.cpython-313.pyc differ diff --git a/custom_components/hass_agent/__pycache__/const.cpython-312.pyc b/custom_components/hass_agent/__pycache__/const.cpython-312.pyc deleted file mode 100644 index c297aadb..00000000 Binary files a/custom_components/hass_agent/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hass_agent/__pycache__/const.cpython-313.pyc b/custom_components/hass_agent/__pycache__/const.cpython-313.pyc index ba95bf9e..639e7b2f 100644 Binary files a/custom_components/hass_agent/__pycache__/const.cpython-313.pyc and b/custom_components/hass_agent/__pycache__/const.cpython-313.pyc differ diff --git a/custom_components/hass_agent/__pycache__/media_player.cpython-312.pyc b/custom_components/hass_agent/__pycache__/media_player.cpython-312.pyc deleted file mode 100644 index f1b36ad7..00000000 Binary files a/custom_components/hass_agent/__pycache__/media_player.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hass_agent/__pycache__/media_player.cpython-313.pyc b/custom_components/hass_agent/__pycache__/media_player.cpython-313.pyc index 83b462c5..0ca1d34a 100644 Binary files a/custom_components/hass_agent/__pycache__/media_player.cpython-313.pyc and b/custom_components/hass_agent/__pycache__/media_player.cpython-313.pyc differ diff --git a/custom_components/hass_agent/__pycache__/notify.cpython-312.pyc b/custom_components/hass_agent/__pycache__/notify.cpython-312.pyc deleted file mode 100644 index cf46b754..00000000 Binary files a/custom_components/hass_agent/__pycache__/notify.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hass_agent/__pycache__/notify.cpython-313.pyc b/custom_components/hass_agent/__pycache__/notify.cpython-313.pyc index 0f6aa125..52bef0cc 100644 Binary files a/custom_components/hass_agent/__pycache__/notify.cpython-313.pyc and b/custom_components/hass_agent/__pycache__/notify.cpython-313.pyc differ diff --git a/custom_components/hass_agent/__pycache__/views.cpython-312.pyc b/custom_components/hass_agent/__pycache__/views.cpython-312.pyc deleted file mode 100644 index a8d0184e..00000000 Binary files a/custom_components/hass_agent/__pycache__/views.cpython-312.pyc and /dev/null differ diff --git a/custom_components/hass_agent/__pycache__/views.cpython-313.pyc b/custom_components/hass_agent/__pycache__/views.cpython-313.pyc index 41ed5c3e..d411a067 100644 Binary files a/custom_components/hass_agent/__pycache__/views.cpython-313.pyc and b/custom_components/hass_agent/__pycache__/views.cpython-313.pyc differ diff --git a/custom_components/hass_agent/media_player.py b/custom_components/hass_agent/media_player.py index b4f4bb9b..e0938ded 100644 --- a/custom_components/hass_agent/media_player.py +++ b/custom_components/hass_agent/media_player.py @@ -25,7 +25,7 @@ MediaPlayerEntityFeature, ) -from homeassistant.components.media_player.const import MEDIA_TYPE_MUSIC +from homeassistant.components.media_player.const import MediaType from homeassistant.components.media_player.browse_media import ( BrowseMedia, @@ -232,7 +232,7 @@ def device_class(self): @property def media_content_type(self): """Content type of current playing media""" - return MEDIA_TYPE_MUSIC + return MediaType.MUSIC async def async_media_seek(self, position: float) -> None: self._attr_media_position = position @@ -249,7 +249,7 @@ async def async_volume_down(self): async def async_mute_volume(self, mute): """Mute the volume""" - await self._send_command("mute") + await self._send_command("mute", mute) async def async_media_play(self): """Send play command""" @@ -293,12 +293,12 @@ async def async_play_media(self, media_type: str, media_id: str, **kwargs: Any): _logger.error( "Invalid media type %r. Only %s is supported!", media_type, - MEDIA_TYPE_MUSIC, + MediaType.MUSIC, ) return if media_source.is_media_source_id(media_id): - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media(self.hass, media_id, self.entity_id) # play_item returns a relative URL if it has to be resolved on the Home Assistant host # This call will turn it into a full URL diff --git a/custom_components/hassarr/__init__.py b/custom_components/hassarr/__init__.py new file mode 100644 index 00000000..213dffa1 --- /dev/null +++ b/custom_components/hassarr/__init__.py @@ -0,0 +1,105 @@ +import voluptuous as vol +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers.typing import ConfigType +from homeassistant.config_entries import ConfigEntry +import homeassistant.helpers.config_validation as cv +from .services import handle_add_media, handle_add_overseerr_media +from .const import DOMAIN, SERVICE_ADD_RADARR_MOVIE, SERVICE_ADD_SONARR_TV_SHOW, SERVICE_ADD_OVERSEERR_MOVIE, SERVICE_ADD_OVERSEERR_TV_SHOW + +ADD_RADARR_MOVIE_SCHEMA = vol.Schema({ + vol.Required("title"): cv.string, +}) + +ADD_SONARR_TV_SHOW_SCHEMA = vol.Schema({ + vol.Required("title"): cv.string, +}) + +ADD_OVERSEERR_MOVIE_SCHEMA = vol.Schema({ + vol.Required("title"): cv.string, +}) + +ADD_OVERSEERR_TV_SHOW_SCHEMA = vol.Schema({ + vol.Required("title"): cv.string, +}) + +def handle_add_movie(hass: HomeAssistant, call: ServiceCall) -> None: + """Handle the service action to add a movie to Radarr. + + Args: + hass (HomeAssistant): The Home Assistant instance. + call (ServiceCall): The service call object. + """ + handle_add_media(hass, call, "movie", "radarr") + +def handle_add_tv_show(hass: HomeAssistant, call: ServiceCall) -> None: + """Handle the service action to add a TV show to Sonarr. + + Args: + hass (HomeAssistant): The Home Assistant instance. + call (ServiceCall): The service call object. + """ + handle_add_media(hass, call, "series", "sonarr") + +def handle_add_overseerr_movie(hass: HomeAssistant, call: ServiceCall) -> None: + """Handle the service action to add a movie to Overseerr. + + Args: + hass (HomeAssistant): The Home Assistant instance. + call (ServiceCall): The service call object. + """ + handle_add_overseerr_media(hass, call, "movie") + +def handle_add_overseerr_tv_show(hass: HomeAssistant, call: ServiceCall) -> None: + """Handle the service action to add a TV show to Overseerr. + + Args: + hass (HomeAssistant): The Home Assistant instance. + call (ServiceCall): The service call object. + """ + handle_add_overseerr_media(hass, call, "tv") + +def setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Hassarr integration. + + Args: + hass (HomeAssistant): The Home Assistant instance. + config (ConfigType): The configuration dictionary. + + Returns: + bool: True if setup was successful, False otherwise. + """ + hass.services.register(DOMAIN, SERVICE_ADD_RADARR_MOVIE, lambda call: handle_add_movie(hass, call), schema=ADD_RADARR_MOVIE_SCHEMA) + hass.services.register(DOMAIN, SERVICE_ADD_SONARR_TV_SHOW, lambda call: handle_add_tv_show(hass, call), schema=ADD_SONARR_TV_SHOW_SCHEMA) + hass.services.register(DOMAIN, SERVICE_ADD_OVERSEERR_MOVIE, lambda call: handle_add_overseerr_movie(hass, call), schema=ADD_OVERSEERR_MOVIE_SCHEMA) + hass.services.register(DOMAIN, SERVICE_ADD_OVERSEERR_TV_SHOW, lambda call: handle_add_overseerr_tv_show(hass, call), schema=ADD_OVERSEERR_TV_SHOW_SCHEMA) + + # Return boolean to indicate that initialization was successful. + return True + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up Hassarr from a config entry. + + Args: + hass (HomeAssistant): The Home Assistant instance. + config_entry (ConfigEntry): The configuration entry. + + Returns: + bool: True if setup was successful, False otherwise. + """ + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN] = config_entry.data + + # Register services + hass.services.async_register(DOMAIN, SERVICE_ADD_RADARR_MOVIE, lambda call: handle_add_movie(hass, call), schema=ADD_RADARR_MOVIE_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_ADD_SONARR_TV_SHOW, lambda call: handle_add_tv_show(hass, call), schema=ADD_SONARR_TV_SHOW_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_ADD_OVERSEERR_MOVIE, lambda call: handle_add_overseerr_movie(hass, call), schema=ADD_OVERSEERR_MOVIE_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_ADD_OVERSEERR_TV_SHOW, lambda call: handle_add_overseerr_tv_show(hass, call), schema=ADD_OVERSEERR_TV_SHOW_SCHEMA) + + # Register update listener + config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) + + return True + +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Handle options update.""" + hass.data[DOMAIN] = config_entry.data \ No newline at end of file diff --git a/custom_components/hassarr/__pycache__/__init__.cpython-313.pyc b/custom_components/hassarr/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..e50c99e2 Binary files /dev/null and b/custom_components/hassarr/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/hassarr/__pycache__/config_flow.cpython-313.pyc b/custom_components/hassarr/__pycache__/config_flow.cpython-313.pyc new file mode 100644 index 00000000..316b5199 Binary files /dev/null and b/custom_components/hassarr/__pycache__/config_flow.cpython-313.pyc differ diff --git a/custom_components/hassarr/__pycache__/const.cpython-313.pyc b/custom_components/hassarr/__pycache__/const.cpython-313.pyc new file mode 100644 index 00000000..55c5e2a9 Binary files /dev/null and b/custom_components/hassarr/__pycache__/const.cpython-313.pyc differ diff --git a/custom_components/hassarr/__pycache__/services.cpython-313.pyc b/custom_components/hassarr/__pycache__/services.cpython-313.pyc new file mode 100644 index 00000000..f316f030 Binary files /dev/null and b/custom_components/hassarr/__pycache__/services.cpython-313.pyc differ diff --git a/custom_components/hassarr/config_flow.py b/custom_components/hassarr/config_flow.py new file mode 100644 index 00000000..5642c1c6 --- /dev/null +++ b/custom_components/hassarr/config_flow.py @@ -0,0 +1,279 @@ +from urllib.parse import urljoin +import voluptuous as vol +from homeassistant import config_entries +import aiohttp + +from .const import DOMAIN + +class HassarrConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + VERSION = 1 + + async def async_step_user(self, user_input=None): + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({ + vol.Required("integration_type"): vol.In(["Radarr & Sonarr", "Overseerr"]) + }) + ) + + self.integration_type = user_input["integration_type"] + if self.integration_type == "Radarr & Sonarr": + return await self.async_step_radarr_sonarr() + else: + return await self.async_step_overseerr() + + async def async_step_reconfigure(self, user_input=None): + """Handle reconfiguration of an existing entry.""" + if user_input is not None: + self.integration_type = user_input["integration_type"] + if self.integration_type == "Radarr & Sonarr": + return await self.async_step_reconfigure_radarr_sonarr() + else: + return await self.async_step_reconfigure_overseerr() + + # Get existing data to pre-fill the form + existing_data = self._get_reconfigure_entry().data + integration_type = existing_data.get("integration_type", "Radarr & Sonarr") + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({ + vol.Required("integration_type", default=integration_type): vol.In(["Radarr & Sonarr", "Overseerr"]), + }) + ) + + + async def async_step_reconfigure_overseerr(self, user_input=None): + """Handle reconfiguration for Overseerr.""" + if user_input is not None: + # Update the existing config entry + data = dict(self._get_reconfigure_entry().data) + data.update(user_input) + self.hass.config_entries.async_update_entry( + self._get_reconfigure_entry(), + data=data + ) + return await self.async_step_reconfigure_overseerr_user() + + # Get existing data to pre-fill the form + existing_data = self._get_reconfigure_entry().data + + return self.async_show_form( + step_id="reconfigure_overseerr", + data_schema=vol.Schema({ + vol.Optional("overseerr_url", default=existing_data.get("overseerr_url", "")): str, + vol.Optional("overseerr_api_key", default=existing_data.get("overseerr_api_key", "")): str, + }) + ) + + async def async_step_reconfigure_overseerr_user(self, user_input=None): + """Handle reconfiguration for Overseerr user selection.""" + if user_input is not None: + # Update the existing config entry + data = dict(self._get_reconfigure_entry().data) + data.update(user_input) + self.hass.config_entries.async_update_entry( + self._get_reconfigure_entry(), + data=data + ) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=user_input, + ) + + # Get existing data to pre-fill the form + existing_data = self._get_reconfigure_entry().data + overseerr_url = existing_data.get("overseerr_url") + overseerr_api_key = existing_data.get("overseerr_api_key") + + # Fetch users from Overseerr API + users = await self._fetch_overseerr_users(overseerr_url, overseerr_api_key) + user_options = {user["id"]: user["username"] for user in users} + + return self.async_show_form( + step_id="reconfigure_overseerr_user", + data_schema=vol.Schema({ + vol.Required("overseerr_user_id"): vol.In(user_options), + }) + ) + + async def async_step_reconfigure_radarr_sonarr(self, user_input=None): + """Handle reconfiguration for Radarr & Sonarr.""" + if user_input is not None: + # Update the existing config entry + data = dict(self._get_reconfigure_entry().data) + data.update(user_input) + self.hass.config_entries.async_update_entry( + self._get_reconfigure_entry(), + data=data + ) + return await self.async_step_reconfigure_radarr_sonarr_quality_profiles() + + # Get existing data to pre-fill the form + existing_data = self._get_reconfigure_entry().data + + return self.async_show_form( + step_id="reconfigure_radarr_sonarr", + data_schema=vol.Schema({ + vol.Optional("radarr_url", default=existing_data.get("radarr_url", "")): str, + vol.Optional("sonarr_url", default=existing_data.get("sonarr_url", "")): str, + vol.Optional("radarr_api_key", default=existing_data.get("radarr_api_key", "")): str, + vol.Optional("sonarr_api_key", default=existing_data.get("sonarr_api_key", "")): str, + }) + ) + + async def async_step_reconfigure_radarr_sonarr_quality_profiles(self, user_input=None): + """Handle reconfiguration for Radarr & Sonarr quality profiles.""" + if user_input is not None: + # Update the existing config entry + data = dict(self._get_reconfigure_entry().data) + data.update(user_input) + self.hass.config_entries.async_update_entry( + self._get_reconfigure_entry(), + data=data + ) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=user_input, + ) + + # Get existing data to pre-fill the form + existing_data = self._get_reconfigure_entry().data + radarr_url = existing_data.get("radarr_url") + radarr_api_key = existing_data.get("radarr_api_key") + sonarr_url = existing_data.get("sonarr_url") + sonarr_api_key = existing_data.get("sonarr_api_key") + + # Fetch quality profiles from Radarr and Sonarr APIs + radarr_profiles = await self._fetch_quality_profiles(radarr_url, radarr_api_key) + sonarr_profiles = await self._fetch_quality_profiles(sonarr_url, sonarr_api_key) + + radarr_options = {profile["id"]: profile["name"] for profile in radarr_profiles} + sonarr_options = {profile["id"]: profile["name"] for profile in sonarr_profiles} + + return self.async_show_form( + step_id="reconfigure_radarr_sonarr_quality_profiles", + data_schema=vol.Schema({ + vol.Required("radarr_quality_profile_id"): vol.In(radarr_options), + vol.Required("sonarr_quality_profile_id"): vol.In(sonarr_options), + }) + ) + + async def async_step_radarr_sonarr(self, user_input=None): + if user_input is None: + return self.async_show_form(step_id="radarr_sonarr", data_schema=self._get_radarr_sonarr_schema()) + + # Validate user input + errors = {} + if not user_input.get("radarr_url") or not user_input.get("radarr_api_key"): + errors["base"] = "missing_radarr_info" + if not user_input.get("sonarr_url") or not user_input.get("sonarr_api_key"): + errors["base"] = "missing_sonarr_info" + + if errors: + return self.async_show_form(step_id="radarr_sonarr", data_schema=self._get_radarr_sonarr_schema(), errors=errors) + + # Save the radarr_url and radarr_api_key and proceed to quality profile selection step + self.radarr_url = user_input["radarr_url"] + self.radarr_api_key = user_input["radarr_api_key"] + self.sonarr_url = user_input["sonarr_url"] + self.sonarr_api_key = user_input["sonarr_api_key"] + return await self.async_step_radarr_sonarr_quality_profiles() + + async def async_step_radarr_sonarr_quality_profiles(self, user_input=None): + if user_input is None: + # Fetch quality profiles from Radarr and Sonarr APIs + radarr_profiles = await self._fetch_quality_profiles(self.radarr_url, self.radarr_api_key) + sonarr_profiles = await self._fetch_quality_profiles(self.sonarr_url, self.sonarr_api_key) + + radarr_options = {profile["id"]: profile["name"] for profile in radarr_profiles} + sonarr_options = {profile["id"]: profile["name"] for profile in sonarr_profiles} + + return self.async_show_form( + step_id="radarr_sonarr_quality_profiles", + data_schema=vol.Schema({ + vol.Required("radarr_quality_profile_id"): vol.In(radarr_options), + vol.Required("sonarr_quality_profile_id"): vol.In(sonarr_options), + }) + ) + + # Create the entry with the selected quality profile IDs + user_input.update({ + "radarr_url": self.radarr_url, + "radarr_api_key": self.radarr_api_key, + "sonarr_url": self.sonarr_url, + "sonarr_api_key": self.sonarr_api_key + }) + return self.async_create_entry(title="Hassarr", data=user_input) + + async def async_step_overseerr(self, user_input=None): + if user_input is None: + return self.async_show_form(step_id="overseerr", data_schema=self._get_overseerr_schema()) + + # Validate user input + errors = {} + if not user_input.get("overseerr_url") or not user_input.get("overseerr_api_key"): + errors["base"] = "missing_overseerr_info" + + if errors: + return self.async_show_form(step_id="overseerr", data_schema=self._get_overseerr_schema(), errors=errors) + + # Save the overseerr_url and overseerr_api_key and proceed to user selection step + self.overseerr_url = user_input["overseerr_url"] + self.overseerr_api_key = user_input["overseerr_api_key"] + return await self.async_step_overseerr_user() + + async def async_step_overseerr_user(self, user_input=None): + if user_input is None: + # Fetch users from Overseerr API + users = await self._fetch_overseerr_users(self.overseerr_url, self.overseerr_api_key) + user_options = {user["id"]: user["username"] for user in users} + + return self.async_show_form( + step_id="overseerr_user", + data_schema=vol.Schema({ + vol.Required("overseerr_user_id"): vol.In(user_options), + }) + ) + + # Create the entry with the selected user ID + user_input.update({ + "overseerr_url": self.overseerr_url, + "overseerr_api_key": self.overseerr_api_key + }) + return self.async_create_entry(title="Hassarr", data=user_input) + + async def _fetch_overseerr_users(self, url, api_key): + """Fetch users from the Overseerr API.""" + async with aiohttp.ClientSession() as session: + url = urljoin(url, "api/v1/user") + async with session.get(url, headers={"X-Api-Key": api_key}) as response: + response.raise_for_status() + data = await response.json() + return data["results"] + + async def _fetch_quality_profiles(self, url, api_key): + """Fetch quality profiles from the Radarr/Sonarr API.""" + async with aiohttp.ClientSession() as session: + url = urljoin(url, "api/v3/qualityprofile") + async with session.get(url, headers={"X-Api-Key": api_key}) as response: + response.raise_for_status() + data = await response.json() + return data + + @staticmethod + def _get_radarr_sonarr_schema(): + return vol.Schema({ + vol.Required("radarr_url"): str, + vol.Required("radarr_api_key"): str, + vol.Required("sonarr_url"): str, + vol.Required("sonarr_api_key"): str, + }) + + @staticmethod + def _get_overseerr_schema(): + return vol.Schema({ + vol.Required("overseerr_url"): str, + vol.Required("overseerr_api_key"): str + }) \ No newline at end of file diff --git a/custom_components/hassarr/const.py b/custom_components/hassarr/const.py new file mode 100644 index 00000000..69393d73 --- /dev/null +++ b/custom_components/hassarr/const.py @@ -0,0 +1,6 @@ +DOMAIN = "hassarr" + +SERVICE_ADD_RADARR_MOVIE = "add_radarr_movie" +SERVICE_ADD_SONARR_TV_SHOW = "add_sonarr_tv_show" +SERVICE_ADD_OVERSEERR_MOVIE = "add_overseerr_movie" +SERVICE_ADD_OVERSEERR_TV_SHOW = "add_overseerr_tv_show" \ No newline at end of file diff --git a/custom_components/hassarr/manifest.json b/custom_components/hassarr/manifest.json new file mode 100644 index 00000000..3d90ed50 --- /dev/null +++ b/custom_components/hassarr/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "hassarr", + "name": "Hassarr", + "version": "0.2.0", + "documentation": "https://github.com/TegridyTate/Hassarr", + "issue_tracker": "https://github.com/TegridyTate/Hassarr/issues", + "requirements": [], + "dependencies": [], + "codeowners": ["@TegridyTate"], + "config_flow": true +} \ No newline at end of file diff --git a/custom_components/hassarr/services.py b/custom_components/hassarr/services.py new file mode 100644 index 00000000..421cdd4f --- /dev/null +++ b/custom_components/hassarr/services.py @@ -0,0 +1,197 @@ +import logging +import requests +from urllib.parse import urljoin, urlparse, urlunparse +from .const import DOMAIN +from homeassistant.core import HomeAssistant, ServiceCall + +_LOGGER = logging.getLogger(__name__) + +def fetch_data(url: str, headers: dict) -> dict | None: + """Fetch data from the given URL with headers. + + Args: + url (str): The URL to fetch data from. + headers (dict): The headers to include in the request. + + Returns: + dict | None: The JSON response if successful, None otherwise. + """ + response = requests.get(url, headers=headers) + if response.status_code == requests.codes.ok: + return response.json() + else: + _LOGGER.error(f"Failed to fetch data from {url}: {response.text}") + return None + +def get_root_folder_path(url: str, headers: dict) -> str | None: + """Get root folder path from the given URL. + + Args: + url (str): The URL to fetch the root folder path from. + headers (dict): The headers to include in the request. + + Returns: + str | None: The root folder path if successful, None otherwise. + """ + data = fetch_data(url, headers) + if data: + return data[0].get("path") + return None + +def handle_add_media(hass: HomeAssistant, call: ServiceCall, media_type: str, service_name: str) -> None: + """Handle the service action to add a media (movie or TV show). + + Args: + hass (HomeAssistant): The Home Assistant instance. + call (ServiceCall): The service call object. + media_type (str): The type of media to add (e.g., "movie" or "series"). + service_name (str): The name of the service (e.g., "radarr" or "sonarr"). + """ + _LOGGER.info(f"Received call data: {call.data}") + title = call.data.get("title") + + if not title: + _LOGGER.error("Title is missing in the service call data") + return + + _LOGGER.info(f"Title received: {title}") + + # Access stored configuration data + config_data = hass.data[DOMAIN] + + url = config_data.get(f"{service_name}_url") + api_key = config_data.get(f"{service_name}_api_key") + quality_profile_id = config_data.get(f"{service_name}_quality_profile_id") + + if not url or not api_key: + _LOGGER.error(f"{service_name.capitalize()} URL or API key is missing") + return + + headers = {'X-Api-Key': api_key} + + # Fetch media list + search_url = urljoin(url, f"api/v3/{media_type}/lookup?term={title}") + _LOGGER.info(f"Fetching media list from URL: {search_url}") + media_list = fetch_data(search_url, headers) + + if media_list: + media_data = media_list[0] + + # Get root folder path + root_folder_url = urljoin(url, "api/v3/rootfolder") + root_folder_path = get_root_folder_path(root_folder_url, headers) + if not root_folder_path: + return + + # Prepare payload + payload = { + 'title': media_data['title'], + 'titleSlug': media_data['titleSlug'], + 'images': media_data['images'], + 'year': media_data['year'], + 'rootFolderPath': root_folder_path, + 'addOptions': { + 'searchForMovie' if media_type == 'movie' else 'searchForMissingEpisodes': True + }, + 'qualityProfileId': quality_profile_id, + } + if media_type == 'movie': + payload['tmdbId'] = media_data['tmdbId'] + else: + payload['tvdbId'] = media_data['tvdbId'] + + # Add media + add_url = urljoin(url, f"api/v3/{media_type}") + _LOGGER.info(f"Adding media to URL: {add_url} with payload: {payload}") + add_response = requests.post(add_url, json=payload, headers=headers) + + if add_response.status_code == requests.codes.created: + _LOGGER.info(f"Successfully added {media_type} '{title}' to {service_name.capitalize()}") + else: + _LOGGER.error(f"Failed to add {media_type} '{title}' to {service_name.capitalize()}: {add_response.text}") + else: + _LOGGER.info(f"No results found for {media_type} '{title}'") + +def handle_add_overseerr_media(hass: HomeAssistant, call: ServiceCall, media_type: str) -> None: + """Handle the service action to add a media (movie or TV show) using Overseerr. + + Args: + hass (HomeAssistant): The Home Assistant instance. + call (ServiceCall): The service call object. + media_type (str): The type of media to add (e.g., "movie" or "tv"). + """ + _LOGGER.info(f"Received call data: {call.data}") + title = call.data.get("title") + + if not title: + _LOGGER.error("Title is missing in the service call data") + return + + _LOGGER.info(f"Title received: {title}") + + # Access stored configuration data + config_data = hass.data[DOMAIN] + + url = config_data.get("overseerr_url") + api_key = config_data.get("overseerr_api_key") + + if not url or not api_key: + _LOGGER.error("Overseerr URL or API key is missing") + return + + # Ensure the URL has a scheme + parsed_url = urlparse(url) + if not parsed_url.scheme: + url_https = f"https://{url}" + url_http = f"http://{url}" + else: + url_https = url + url_http = url + + headers = {'X-Api-Key': api_key} + + # Try https first + search_url = urljoin(url_https, f"api/v1/search?query={title}") + _LOGGER.info(f"Searching for media with URL: {search_url}") + search_results = fetch_data(search_url, headers) + + if not search_results or not search_results.get("results"): + # Try with http if https fails + search_url = urljoin(url_http, f"api/v1/search?query={title}") + _LOGGER.error(f"Retrying search for media with URL: {search_url}") + _LOGGER.info(f"Retrying search for media with URL: {search_url}") + search_results = fetch_data(search_url, headers) + + if search_results and search_results.get("results"): + media_data = search_results["results"][0] + _LOGGER.error(f"Media data: {media_data}") + + # Prepare payload + payload = { + "mediaType": media_type, + "mediaId": media_data["id"], + "is4k": False, + "serverId": 0, + "profileId": 0, + "rootFolder": "", + "languageProfileId": 0, + "userId": config_data.get("overseerr_user_id"), + "seasons": "all" if media_type == "tv" else [] + } + if media_type == "tv": + tvdb_id = media_data.get("tvdbId") + if tvdb_id is not None: + payload["tvdbId"] = tvdb_id + + # Create request + request_url = urljoin(url_https, "api/v1/request") + _LOGGER.info(f"Creating request with URL: {request_url} and payload: {payload}") + + request_response = requests.post(request_url, json=payload, headers=headers) + + if request_response.status_code == requests.codes.created: + _LOGGER.info(f"Successfully created request for {media_type} '{title}' in Overseerr") + else: + _LOGGER.error(f"Failed to create request for {media_type} '{title}' in Overseerr: {request_response.text}") + else: + _LOGGER.info(f"No results found for {media_type} '{title}'") \ No newline at end of file diff --git a/custom_components/hassarr/services.yaml b/custom_components/hassarr/services.yaml new file mode 100644 index 00000000..76db99d4 --- /dev/null +++ b/custom_components/hassarr/services.yaml @@ -0,0 +1,27 @@ +add_radarr_movie: + description: "Add a movie to Radarr" + fields: + title: + description: "Title of the movie" + example: "Gladiator" + +add_sonarr_tv_show: + description: "Add a TV show to Sonarr" + fields: + title: + description: "Title of the TV show" + example: "Breaking Bad" + +add_overseerr_movie: + description: "Add a movie to Overseerr" + fields: + title: + description: "Title of the movie" + example: "Gladiator" + +add_overseerr_tv_show: + description: "Add a TV show to Overseerr" + fields: + title: + description: "Title of the TV show" + example: "Breaking Bad" \ No newline at end of file diff --git a/custom_components/homewhiz/__init__.py b/custom_components/homewhiz/__init__.py index 35711cfb..722cdd44 100644 --- a/custom_components/homewhiz/__init__.py +++ b/custom_components/homewhiz/__init__.py @@ -47,9 +47,9 @@ async def setup_bluetooth( _LOGGER.info("No unique entry id") return False - coordinator = hass.data.setdefault(DOMAIN, {})[ - entry.entry_id - ] = HomewhizBluetoothUpdateCoordinator(hass, entry.unique_id) + coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( + HomewhizBluetoothUpdateCoordinator(hass, entry.unique_id) + ) @callback def connect( @@ -96,9 +96,9 @@ async def setup_cloud(entry: ConfigEntry, hass: HomeAssistant) -> bool: ids = from_dict(IdExchangeResponse, entry.data["ids"]) cloud_config = from_dict(CloudConfig, entry.data["cloud_config"]) - coordinator = hass.data.setdefault(DOMAIN, {})[ - entry.entry_id - ] = HomewhizCloudUpdateCoordinator(hass, ids.appId, cloud_config, entry) + coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( + HomewhizCloudUpdateCoordinator(hass, ids.appId, cloud_config, entry) + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_create_task(hass, coordinator.connect()) _LOGGER.info("Setup cloud connection successfully") diff --git a/custom_components/homewhiz/__pycache__/__init__.cpython-312.pyc b/custom_components/homewhiz/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a3bd5bb5..00000000 Binary files a/custom_components/homewhiz/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/__init__.cpython-313.pyc b/custom_components/homewhiz/__pycache__/__init__.cpython-313.pyc index 53045a31..8ba77b94 100644 Binary files a/custom_components/homewhiz/__pycache__/__init__.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/api.cpython-312.pyc b/custom_components/homewhiz/__pycache__/api.cpython-312.pyc deleted file mode 100644 index 80b5019d..00000000 Binary files a/custom_components/homewhiz/__pycache__/api.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/api.cpython-313.pyc b/custom_components/homewhiz/__pycache__/api.cpython-313.pyc index 686aaed6..bbc3f85b 100644 Binary files a/custom_components/homewhiz/__pycache__/api.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/api.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/appliance_config.cpython-312.pyc b/custom_components/homewhiz/__pycache__/appliance_config.cpython-312.pyc deleted file mode 100644 index 0e1ade45..00000000 Binary files a/custom_components/homewhiz/__pycache__/appliance_config.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/appliance_config.cpython-313.pyc b/custom_components/homewhiz/__pycache__/appliance_config.cpython-313.pyc index 974e1378..078ff19d 100644 Binary files a/custom_components/homewhiz/__pycache__/appliance_config.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/appliance_config.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/appliance_controls.cpython-312.pyc b/custom_components/homewhiz/__pycache__/appliance_controls.cpython-312.pyc deleted file mode 100644 index 50eb5e67..00000000 Binary files a/custom_components/homewhiz/__pycache__/appliance_controls.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/appliance_controls.cpython-313.pyc b/custom_components/homewhiz/__pycache__/appliance_controls.cpython-313.pyc index b701dcfe..ad39cdb1 100644 Binary files a/custom_components/homewhiz/__pycache__/appliance_controls.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/appliance_controls.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/binary_sensor.cpython-312.pyc b/custom_components/homewhiz/__pycache__/binary_sensor.cpython-312.pyc deleted file mode 100644 index 2b2bd031..00000000 Binary files a/custom_components/homewhiz/__pycache__/binary_sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/binary_sensor.cpython-313.pyc b/custom_components/homewhiz/__pycache__/binary_sensor.cpython-313.pyc index 9f50bddb..ba67504d 100644 Binary files a/custom_components/homewhiz/__pycache__/binary_sensor.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/binary_sensor.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/bluetooth.cpython-312.pyc b/custom_components/homewhiz/__pycache__/bluetooth.cpython-312.pyc deleted file mode 100644 index 40ba46b9..00000000 Binary files a/custom_components/homewhiz/__pycache__/bluetooth.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/bluetooth.cpython-313.pyc b/custom_components/homewhiz/__pycache__/bluetooth.cpython-313.pyc index 8f98d72e..e3dd79bc 100644 Binary files a/custom_components/homewhiz/__pycache__/bluetooth.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/bluetooth.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/climate.cpython-312.pyc b/custom_components/homewhiz/__pycache__/climate.cpython-312.pyc deleted file mode 100644 index d2e3d3a8..00000000 Binary files a/custom_components/homewhiz/__pycache__/climate.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/climate.cpython-313.pyc b/custom_components/homewhiz/__pycache__/climate.cpython-313.pyc index 9746d201..ead6387f 100644 Binary files a/custom_components/homewhiz/__pycache__/climate.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/climate.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/cloud.cpython-312.pyc b/custom_components/homewhiz/__pycache__/cloud.cpython-312.pyc deleted file mode 100644 index fba85f96..00000000 Binary files a/custom_components/homewhiz/__pycache__/cloud.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/cloud.cpython-313.pyc b/custom_components/homewhiz/__pycache__/cloud.cpython-313.pyc index 557aabc1..69e89308 100644 Binary files a/custom_components/homewhiz/__pycache__/cloud.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/cloud.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/config_flow.cpython-312.pyc b/custom_components/homewhiz/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index a2ccf8bb..00000000 Binary files a/custom_components/homewhiz/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/config_flow.cpython-313.pyc b/custom_components/homewhiz/__pycache__/config_flow.cpython-313.pyc index b15a0260..fe2f0903 100644 Binary files a/custom_components/homewhiz/__pycache__/config_flow.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/config_flow.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/const.cpython-312.pyc b/custom_components/homewhiz/__pycache__/const.cpython-312.pyc deleted file mode 100644 index c47487c5..00000000 Binary files a/custom_components/homewhiz/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/const.cpython-313.pyc b/custom_components/homewhiz/__pycache__/const.cpython-313.pyc index 0fc825d2..8ca1ea70 100644 Binary files a/custom_components/homewhiz/__pycache__/const.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/const.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/entity.cpython-312.pyc b/custom_components/homewhiz/__pycache__/entity.cpython-312.pyc deleted file mode 100644 index 013e67c7..00000000 Binary files a/custom_components/homewhiz/__pycache__/entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/entity.cpython-313.pyc b/custom_components/homewhiz/__pycache__/entity.cpython-313.pyc index c4495fdc..32fec763 100644 Binary files a/custom_components/homewhiz/__pycache__/entity.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/entity.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/helper.cpython-312.pyc b/custom_components/homewhiz/__pycache__/helper.cpython-312.pyc deleted file mode 100644 index 3e576a00..00000000 Binary files a/custom_components/homewhiz/__pycache__/helper.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/helper.cpython-313.pyc b/custom_components/homewhiz/__pycache__/helper.cpython-313.pyc index 78a08a73..23dab0ed 100644 Binary files a/custom_components/homewhiz/__pycache__/helper.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/helper.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/homewhiz.cpython-312.pyc b/custom_components/homewhiz/__pycache__/homewhiz.cpython-312.pyc deleted file mode 100644 index 8a30a90d..00000000 Binary files a/custom_components/homewhiz/__pycache__/homewhiz.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/homewhiz.cpython-313.pyc b/custom_components/homewhiz/__pycache__/homewhiz.cpython-313.pyc index 90217e60..2167d1a2 100644 Binary files a/custom_components/homewhiz/__pycache__/homewhiz.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/homewhiz.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/select.cpython-312.pyc b/custom_components/homewhiz/__pycache__/select.cpython-312.pyc deleted file mode 100644 index 9f1a27e9..00000000 Binary files a/custom_components/homewhiz/__pycache__/select.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/select.cpython-313.pyc b/custom_components/homewhiz/__pycache__/select.cpython-313.pyc index 502e6a7b..2b64e273 100644 Binary files a/custom_components/homewhiz/__pycache__/select.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/select.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/sensor.cpython-312.pyc b/custom_components/homewhiz/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index 50c249bf..00000000 Binary files a/custom_components/homewhiz/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/sensor.cpython-313.pyc b/custom_components/homewhiz/__pycache__/sensor.cpython-313.pyc index 98ffdd77..f821787a 100644 Binary files a/custom_components/homewhiz/__pycache__/sensor.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/sensor.cpython-313.pyc differ diff --git a/custom_components/homewhiz/__pycache__/switch.cpython-312.pyc b/custom_components/homewhiz/__pycache__/switch.cpython-312.pyc deleted file mode 100644 index 7b5472c3..00000000 Binary files a/custom_components/homewhiz/__pycache__/switch.cpython-312.pyc and /dev/null differ diff --git a/custom_components/homewhiz/__pycache__/switch.cpython-313.pyc b/custom_components/homewhiz/__pycache__/switch.cpython-313.pyc index e272fbcc..9c97d7b8 100644 Binary files a/custom_components/homewhiz/__pycache__/switch.cpython-313.pyc and b/custom_components/homewhiz/__pycache__/switch.cpython-313.pyc differ diff --git a/custom_components/homewhiz/binary_sensor.py b/custom_components/homewhiz/binary_sensor.py index b55bb893..fcb6e3ff 100644 --- a/custom_components/homewhiz/binary_sensor.py +++ b/custom_components/homewhiz/binary_sensor.py @@ -31,7 +31,7 @@ def __init__( self._control = control @property - def is_on(self) -> bool | None: + def is_on(self) -> bool | None: # type: ignore[override] if self.coordinator.data is None: return None return self._control.get_value(self.coordinator.data) diff --git a/custom_components/homewhiz/climate.py b/custom_components/homewhiz/climate.py index b48bd6c9..6615dded 100644 --- a/custom_components/homewhiz/climate.py +++ b/custom_components/homewhiz/climate.py @@ -38,7 +38,7 @@ def __init__( self._previous_hvac_mode: HVACMode | None = None @property - def supported_features(self) -> ClimateEntityFeature: + def supported_features(self) -> ClimateEntityFeature: # type: ignore[override] features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE @@ -50,11 +50,11 @@ def supported_features(self) -> ClimateEntityFeature: return features @property - def hvac_modes(self) -> list[HVACMode]: + def hvac_modes(self) -> list[HVACMode]: # type: ignore[override] return self._control.hvac_mode.options @property - def hvac_mode(self) -> HVACMode | None: + def hvac_mode(self) -> HVACMode | None: # type: ignore[override] data = self.coordinator.data if data is None: return None @@ -79,19 +79,19 @@ async def async_turn_on(self) -> None: ) @property - def target_temperature_step(self) -> float: + def target_temperature_step(self) -> float: # type: ignore[override] return self._control.target_temperature.bounds.step @property - def target_temperature_low(self) -> float: + def target_temperature_low(self) -> float: # type: ignore[override] return self._control.target_temperature.bounds.lowerLimit @property - def target_temperature_high(self) -> float: + def target_temperature_high(self) -> float: # type: ignore[override] return self._control.target_temperature.bounds.upperLimit @property - def target_temperature(self) -> float | None: + def target_temperature(self) -> float | None: # type: ignore[override] if self.coordinator.data is None: return None return self._control.target_temperature.get_value(self.coordinator.data) @@ -103,15 +103,15 @@ async def async_set_temperature(self, temperature: float, **kwargs: Any) -> None ) @property - def current_temperature(self) -> float | None: + def current_temperature(self) -> float | None: # type: ignore[override] return self._control.current_temperature.get_value(self.coordinator.data) @property - def fan_modes(self) -> list[str]: + def fan_modes(self) -> list[str]: # type: ignore[override] return list(self._control.fan_mode.options.values()) @property - def fan_mode(self) -> str | None: + def fan_mode(self) -> str | None: # type: ignore[override] return self._control.fan_mode.get_value(self.coordinator.data) async def async_set_fan_mode(self, fan_mode: str) -> None: @@ -119,11 +119,11 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: await self.coordinator.send_command(self._control.fan_mode.set_value(fan_mode)) @property - def swing_modes(self) -> list[str] | None: + def swing_modes(self) -> list[str] | None: # type: ignore[override] return self._control.swing.options @property - def swing_mode(self) -> str | None: + def swing_mode(self) -> str | None: # type: ignore[override] if self.coordinator.data is None: return None return self._control.swing.get_value(self.coordinator.data) diff --git a/custom_components/homewhiz/const.py b/custom_components/homewhiz/const.py index 2daa3f31..c1dd468c 100644 --- a/custom_components/homewhiz/const.py +++ b/custom_components/homewhiz/const.py @@ -1,9 +1,7 @@ # Base component constants from homeassistant.const import Platform -NAME = "HomeWhiz" DOMAIN = "homewhiz" -DOMAIN_DATA = f"{DOMAIN}_data" PLATFORMS = [ Platform.SELECT, Platform.SENSOR, @@ -11,14 +9,3 @@ Platform.SWITCH, Platform.BINARY_SENSOR, ] -VERSION = "0.0.0" -CONF_TYPE = "type" -CONF_CLOUD = "could" -CONF_BLUETOOTH = "bt" -ISSUE_URL = "https://github.com/rowysock/home-assistant-HomeWhiz/issues" - -# Icons -ICON = "mdi:washing-machine" - -# Defaults -DEFAULT_NAME = DOMAIN diff --git a/custom_components/homewhiz/entity.py b/custom_components/homewhiz/entity.py index e32ac9aa..d4b6d833 100644 --- a/custom_components/homewhiz/entity.py +++ b/custom_components/homewhiz/entity.py @@ -56,11 +56,11 @@ async def async_added_to_hass(self) -> None: setattr(self._control, "my_entity_ids", {self.entity_id: self.name}) @property - def available(self) -> bool: + def available(self) -> bool: # type: ignore[override] return self.coordinator.is_connected @property - def translation_key(self) -> str: + def translation_key(self) -> str | None: # type: ignore[override] """Translation key for this entity.""" _LOGGER.debug("Retrieving translation_key %s", self.entity_key.lower()) diff --git a/custom_components/homewhiz/homewhiz.py b/custom_components/homewhiz/homewhiz.py index b55f2e9c..f26f4fc9 100644 --- a/custom_components/homewhiz/homewhiz.py +++ b/custom_components/homewhiz/homewhiz.py @@ -16,7 +16,8 @@ class Command: class HomewhizCoordinator( - ABC, DataUpdateCoordinator[bytearray | None] # type: ignore[type-arg] + ABC, + DataUpdateCoordinator[bytearray | None], # type: ignore[type-arg] ): @abc.abstractmethod async def connect(self) -> bool: diff --git a/custom_components/homewhiz/icons.json b/custom_components/homewhiz/icons.json new file mode 100644 index 00000000..962418e9 --- /dev/null +++ b/custom_components/homewhiz/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "switch": { + "washer_steam": { + "default": "mdi:weather-fog" + } + } + } +} diff --git a/custom_components/homewhiz/manifest.json b/custom_components/homewhiz/manifest.json index a7c5c6da..3a94b5ba 100644 --- a/custom_components/homewhiz/manifest.json +++ b/custom_components/homewhiz/manifest.json @@ -26,5 +26,5 @@ "aiohttp", "bidict" ], - "version": "0.0.6" + "version": "v0.5.7" } diff --git a/custom_components/homewhiz/select.py b/custom_components/homewhiz/select.py index 8db0bb06..885e6cf6 100644 --- a/custom_components/homewhiz/select.py +++ b/custom_components/homewhiz/select.py @@ -52,7 +52,7 @@ def __init__( self._attr_options = list(self._control.options.values()) @property - def current_option(self) -> str | None: + def current_option(self) -> str | None: # type: ignore[override] if not self.available: return STATE_UNAVAILABLE if self.coordinator.data is None: diff --git a/custom_components/homewhiz/sensor.py b/custom_components/homewhiz/sensor.py index 000f56c1..2394e890 100644 --- a/custom_components/homewhiz/sensor.py +++ b/custom_components/homewhiz/sensor.py @@ -53,7 +53,7 @@ def __init__( self._attr_device_class = SensorDeviceClass.TIMESTAMP @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: + def extra_state_attributes(self) -> Mapping[str, Any] | None: # type: ignore[override] """Attribute to identify the origin of the data used""" if isinstance(self._control, SummedTimestampControl): return { @@ -66,7 +66,9 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: return None @property - def native_value(self) -> float | int | str | datetime | None: + def native_value( # type: ignore[override] + self, + ) -> float | int | str | datetime | None: _LOGGER.debug( "Native value for entity %s, id: %s, info: %s, class:%s, is %s", self.entity_key, diff --git a/custom_components/homewhiz/switch.py b/custom_components/homewhiz/switch.py index 814f1a10..36b0b540 100644 --- a/custom_components/homewhiz/switch.py +++ b/custom_components/homewhiz/switch.py @@ -30,7 +30,7 @@ def __init__( self._control = control @property - def is_on(self) -> bool | None: + def is_on(self) -> bool | None: # type: ignore[override] if self.coordinator.data is None: return None return self._control.get_value(self.coordinator.data) diff --git a/custom_components/homewhiz/translations/cs.json b/custom_components/homewhiz/translations/cs.json index cf9f427d..c6167e92 100644 --- a/custom_components/homewhiz/translations/cs.json +++ b/custom_components/homewhiz/translations/cs.json @@ -21,7 +21,7 @@ "entity": { "select": { "state": { - "name": "St\u00e1t", + "name": "Stav", "state": { "device_state_on": "Zapnuto", "device_state_off": "Vypnuto", @@ -305,7 +305,7 @@ }, "sensor": { "state": { - "name": "St\u00e1t", + "name": "Stav", "state": { "device_state_on": "Zapnuto", "device_state_off": "Vypnuto", @@ -464,7 +464,7 @@ "name": "Doba trv\u00e1n\u00ed" }, "washer_remaining": { - "name": "Zb\u00fdvaj\u00edc\u00ed" + "name": "Zb\u00fdvaj\u00edc\u00ed \u010das" }, "remote_control": { "name": "D\u00e1lkov\u00e9 ovl\u00e1d\u00e1n\u00ed" diff --git a/custom_components/homewhiz/translations/pl.json b/custom_components/homewhiz/translations/pl.json index 0001a47a..8f9eba83 100644 --- a/custom_components/homewhiz/translations/pl.json +++ b/custom_components/homewhiz/translations/pl.json @@ -28,6 +28,7 @@ "device_state_running": "Uruchomiono", "device_state_paused": "Wstrzymano", "device_state_time_delay_active": "Op\u00f3\u017anienie aktywne", + "device_state_time_delay_paused": "Op\u00f3\u017anienie wstrzymane", "device_state_cancelling": "Anulowanie", "device_state_door_open": "Drzwi s\u0105 otwarte!", "device_state_settings": "Ustawienia" @@ -65,7 +66,17 @@ "program_dark_wash": "Ciemne", "program_outdoor": "Na zewn\u0105trz", "program_drum_clean_plus": "Czyszczenie b\u0119bna+", - "program_steam_refresh": "Od\u015bwie\u017cenie parowe" + "program_steam_refresh": "Od\u015bwie\u017cenie parowe", + "program_anti_allergy": "Antyalergiczny", + "program_quickwash_40": "Szybkie pranie 40\u00b0/40 min", + "program_woolprotect": "Ochrona we\u0142ny", + "program_machinecare": "Konserwacja pralki", + "program_multisense": " MultiSense", + "program_staincare": "Odplamianie StainCare", + "program_mini_14": "Mini 14 min", + "program_darkcare": "Ciemne ubrania", + "program_bedding": "Po\u015bciel", + "program_hand_wash": "Pranie r\u0119czne" } }, "washer_temperature": { @@ -75,7 +86,13 @@ "temperature_20": "20\u00b0C", "temperature_30": "30\u00b0C", "temperature_40": "40\u00b0C", + "20c": "20\u00b0C", + "30c": "30\u00b0C", + "40c": "40\u00b0C", + "50c": "50\u00b0C", "60c": "60\u00b0C", + "70c": "70\u00b0C", + "80c": "80\u00b0C", "90c": "90\u00b0C" } }, @@ -83,6 +100,7 @@ "name": "Wirowanie", "state": { "spin_no_spin": "Bez wirowania", + "400rpm": "400RPM", "600rpm": "600RPM", "800rpm": "800RPM", "1000rpm": "1000RPM", @@ -103,6 +121,51 @@ "fast_plus_on_fast_plus": "Fast+" } }, + "washer_dirt_level": { + "name": "Poziom zabrudze\u0144", + "state": { + "dirt_level_low": "Niski", + "dirt_level_medium": "\u015aredni", + "dirt_level_high": "Wysoki" + } + }, + "washer_dirt_type": { + "name": "Rodzaj plam", + "state": { + "dirt_level_high_sweat": "S\u0142odkie", + "dirt_level_collar_soil": "Zabrudzenie ko\u0142nierzyka", + "dirt_level_tea": "Herbata", + "dirt_level_coffee": "Kawa", + "dirt_level_meal": "Posi\u0142ek", + "dirt_level_ketchup": "Keczup", + "dirt_level_mayonnaise": "Majonez", + "dirt_level_salad_dressing": "Sos do sa\u0142atki", + "dirt_level_machine_oil": "Olej maszynowy", + "dirt_level_make_up": "Makija\u017c", + "dirt_level_blood": "Krew", + "dirt_level_red_wine": "Czerwone wino", + "dirt_level_fruit_juice": "Sok owocowy", + "dirt_level_chocolate": "Czekolada", + "dirt_level_pudding": "Deser", + "dirt_level_mud": "B\u0142oto", + "dirt_level_grass": "Trawa", + "dirt_level_baby_formula": "Formu\u0142a dzieci\u0119ca", + "dirt_level_egg": "Jajko", + "dirt_level_coke": "Cola", + "dirt_level_butter": "Mas\u0142o", + "dirt_level_jam": "D\u017cem", + "dirt_level_curry": "Curry", + "dirt_level_coal": "W\u0119giel" + } + }, + "washer_extra_rinsing": { + "name": "Dodatkowe p\u0142ukanie", + "state": { + "extra_rinsing_no": "Bez dodatkowego p\u0142ukania", + "extra_rinsing_one": "Jedno dodatkowe p\u0142ukanie", + "extra_rinsing_two": "Dwa dodatkowe p\u0142ukania" + } + }, "custom_duration_level": { "name": "Poziom dostosowanego czasu trwania", "state": { @@ -301,6 +364,92 @@ "left_right_vane_control_right": "W prawo", "left_right_vane_control_auto": "Automatyczny" } + }, + "dryer_program": { + "name": "Program", + "state": { + "program_cottons": "Bawe\u0142na", + "program_cottons_eco": "Bawe\u0142na Eco", + "program_synthetics": "Syntetyki", + "program_woolprotect": "We\u0142na", + "program_night": "Noc", + "program_duvet_downwear": "Ko\u0142dra / Puchowe", + "program_drying_rack_timer_programmes": "Programy czasowe", + "program_hygienic_refresh": "Od\u015bwie\u017cenie higieniczne", + "program_hygienic_drying": "Suszenie higieniczne", + "program_xpress_super_short": "Codzienne Szybkie", + "program_jeans": "Jeans", + "program_mix": "Mix / Codzienny", + "program_steam_refresh": "Od\u015bwie\u017cenie parowe", + "program_ready_to_wear": "Gotowe do noszenia (Koszule)", + "program_bedding": "Po\u015bciel", + "program_lingerie": "Bielizna", + "program_outdoor_sports": "Sport", + "program_fashion": "Moda", + "program_towel": "R\u0119czniki" + } + }, + "dryer_steam_mode_level": { + "name": "Poziom pary", + "state": { + "steam_mode_level_1": "Poziom 1", + "steam_mode_level_2": "Poziom 2", + "steam_mode_level_3": "Poziom 3" + } + }, + "dryer_drying_level": { + "name": "Poziom wysuszenia", + "state": { + "drying_level_iron_dry": "Suszenie do prasowania", + "drying_level_cupboard_dry": "Suszenie do przechowywania w szafie", + "drying_level_plus": "Suszenie do przechowywania w szafie+", + "drying_level_extra_dry": "Bardzo wysuszone" + } + }, + "dryer_anti_creasing": { + "name": "Ochrona przeciw zagnieceniom", + "state": { + "0": "Wy\u0142.", + "30": "30 min", + "60": "60 min", + "120": "120 min" + } + }, + "dryer_duration": { + "name": "Czas trwania programu czasowego", + "state": { + "10": "10 min", + "20": "20 min", + "30": "30 min", + "40": "40 min", + "50": "50 min", + "60": "1 h", + "70": "1h 10 min", + "80": "1h 20 min", + "90": "1h 30 min", + "100": "1h 40 min", + "110": "1h 50 min", + "120": "2h", + "130": "2h 10 min", + "140": "2h 20 min", + "150": "2h 30 min", + "160": "2h 40 min" + } + }, + "setting_volume": { + "name": "Sygna\u0142 d\u017awi\u0119kowy", + "state": { + "volume_close": "Wy\u0142.", + "volume_on": "W\u0142." + } + }, + "settings_language": { + "name": "J\u0119zyk", + "state": { + "language_turkish": "Turecki", + "language_english": "Angielski", + "language_german": "Niemiecki" + } } }, "sensor": { @@ -312,6 +461,7 @@ "device_state_running": "Uruchomiono", "device_state_paused": "Wstrzymano", "device_state_time_delay_active": "Op\u00f3\u017anienie aktywne", + "device_state_time_delay_paused": "Op\u00f3\u017anienie wstrzymane", "device_state_cancelling": "Anulowanie", "device_state_door_open": "Drzwi s\u0105 otwarte!", "device_state_settings": "Ustawienia" @@ -339,7 +489,7 @@ "program_curtain": "Zas\u0142ony", "program_lingerie": "Bielizna damska", "program_soft_toys": "Pluszowe zabawki", - "program_towel": "R\u0119cznik", + "program_towel": "R\u0119czniki", "program_cottons_eco": "Bawe\u0142na eko", "program_dryer_cotton": "Suszenie: bawe\u0142na", "program_dryer_synthetics": "Suszenie: syntetyki", @@ -349,7 +499,17 @@ "program_dark_wash": "Ciemne", "program_outdoor": "Na zewn\u0105trz", "program_drum_clean_plus": "Czyszczenie b\u0119bna+", - "program_steam_refresh": "Od\u015bwie\u017cenie parowe" + "program_steam_refresh": "Od\u015bwie\u017cenie parowe", + "program_anti_allergy": "Antyalergiczny", + "program_quickwash_40": "Szybkie pranie 40\u00b0/40 min", + "program_woolprotect": "Ochrona we\u0142ny", + "program_machinecare": "Konserwacja pralki", + "program_multisense": " MultiSense", + "program_staincare": "Odplamianie StainCare", + "program_mini_14": "Mini 14 min", + "program_darkcare": "Ciemne ubrania", + "program_bedding": "Po\u015bciel", + "program_hand_wash": "Pranie r\u0119czne" } }, "washer_temperature": { @@ -359,7 +519,13 @@ "temperature_20": "20\u00b0C", "temperature_30": "30\u00b0C", "temperature_40": "40\u00b0C", + "20c": "20\u00b0C", + "30c": "30\u00b0C", + "40c": "40\u00b0C", + "50c": "50\u00b0C", "60c": "60\u00b0C", + "70c": "70\u00b0C", + "80c": "80\u00b0C", "90c": "90\u00b0C" } }, @@ -367,6 +533,7 @@ "name": "Wirowanie", "state": { "spin_no_spin": "Bez wirowania", + "400rpm": "400RPM", "600rpm": "600RPM", "800rpm": "800RPM", "1000rpm": "1000RPM", @@ -387,6 +554,51 @@ "fast_plus_on_fast_plus": "Fast+" } }, + "washer_dirt_level": { + "name": "Poziom zabrudze\u0144", + "state": { + "dirt_level_low": "Niski", + "dirt_level_medium": "\u015aredni", + "dirt_level_high": "Wysoki" + } + }, + "washer_dirt_type": { + "name": "Rodzaj plam", + "state": { + "dirt_level_high_sweat": "S\u0142odkie", + "dirt_level_collar_soil": "Zabrudzenie ko\u0142nierzyka", + "dirt_level_tea": "Herbata", + "dirt_level_coffee": "Kawa", + "dirt_level_meal": "Posi\u0142ek", + "dirt_level_ketchup": "Keczup", + "dirt_level_mayonnaise": "Majonez", + "dirt_level_salad_dressing": "Sos do sa\u0142atki", + "dirt_level_machine_oil": "Olej maszynowy", + "dirt_level_make_up": "Makija\u017c", + "dirt_level_blood": "Krew", + "dirt_level_red_wine": "Czerwone wino", + "dirt_level_fruit_juice": "Sok owocowy", + "dirt_level_chocolate": "Czekolada", + "dirt_level_pudding": "Deser", + "dirt_level_mud": "B\u0142oto", + "dirt_level_grass": "Trawa", + "dirt_level_baby_formula": "Formu\u0142a dzieci\u0119ca", + "dirt_level_egg": "Jajko", + "dirt_level_coke": "Cola", + "dirt_level_butter": "Mas\u0142o", + "dirt_level_jam": "D\u017cem", + "dirt_level_curry": "Curry", + "dirt_level_coal": "W\u0119giel" + } + }, + "washer_extra_rinsing": { + "name": "Dodatkowe p\u0142ukanie", + "state": { + "extra_rinsing_no": "Bez dodatkowego p\u0142ukania", + "extra_rinsing_one": "Jedno dodatkowe p\u0142ukanie", + "extra_rinsing_two": "Dwa dodatkowe p\u0142ukania" + } + }, "custom_duration_level": { "name": "Poziom dostosowanego czasu trwania", "state": { @@ -454,7 +666,16 @@ "dishwasher_message_program_started": "Program uruchomiony", "dishwasher_message_washing": "Zmywanie", "dishwasher_message_rinsing": "P\u0142ukanie", - "dishwasher_message_drying": "Suszenie" + "dishwasher_message_drying": "Suszenie", + "dryer_message_program_finished": "Program zako\u0144czony", + "dryer_message_program_started": "Program uruchomiony", + "dryer_message_drying": "Suszenie", + "dryer_message_time_delay_active": "Op\u00f3\u017anienie aktywne", + "dryer_message_paused": "Program zosta\u0142 wstrzymany", + "dryer_message_cooling": "Ch\u0142odzenie", + "dryer_message_refreshing": "Od\u015bwie\u017canie", + "dryer_message_anti_creasing": "Ochrona przeciw zagnieceniom", + "dryer_message_drum_empty": "B\u0119ben pusty" } }, "washer_delay": { @@ -727,6 +948,92 @@ }, "air_conditioner_auto_switch_on": { "name": "Automatyczne w\u0142\u0105czanie" + }, + "dryer_program": { + "name": "Program", + "state": { + "program_cottons": "Bawe\u0142na", + "program_cottons_eco": "Bawe\u0142na Eco", + "program_synthetics": "Syntetyki", + "program_woolprotect": "We\u0142na", + "program_night": "Noc", + "program_duvet_downwear": "Ko\u0142dra / Puchowe", + "program_drying_rack_timer_programmes": "Programy czasowe", + "program_hygienic_refresh": "Od\u015bwie\u017cenie higieniczne", + "program_hygienic_drying": "Suszenie higieniczne", + "program_xpress_super_short": "Codzienne Szybkie", + "program_jeans": "Jeans", + "program_mix": "Mix / Codzienny", + "program_steam_refresh": "Od\u015bwie\u017cenie parowe", + "program_ready_to_wear": "Gotowe do noszenia (Koszule)", + "program_bedding": "Po\u015bciel", + "program_lingerie": "Bielizna", + "program_outdoor_sports": "Sport", + "program_fashion": "Moda", + "program_towel": "R\u0119czniki" + } + }, + "dryer_steam_mode_level": { + "name": "Poziom pary", + "state": { + "steam_mode_level_1": "Poziom 1", + "steam_mode_level_2": "Poziom 2", + "steam_mode_level_3": "Poziom 3" + } + }, + "dryer_drying_level": { + "name": "Poziom wysuszenia", + "state": { + "drying_level_iron_dry": "Suszenie do prasowania", + "drying_level_cupboard_dry": "Suszenie do przechowywania w szafie", + "drying_level_plus": "Suszenie do przechowywania w szafie+", + "drying_level_extra_dry": "Bardzo wysuszone" + } + }, + "dryer_anti_creasing": { + "name": "Ochrona przeciw zagnieceniom", + "state": { + "0": "Wy\u0142.", + "30": "30 min", + "60": "60 min", + "120": "120 min" + } + }, + "dryer_duration": { + "name": "Czas trwania programu czasowego", + "state": { + "10": "10 min", + "20": "20 min", + "30": "30 min", + "40": "40 min", + "50": "50 min", + "60": "1 h", + "70": "1h 10 min", + "80": "1h 20 min", + "90": "1h 30 min", + "100": "1h 40 min", + "110": "1h 50 min", + "120": "2h", + "130": "2h 10 min", + "140": "2h 20 min", + "150": "2h 30 min", + "160": "2h 40 min" + } + }, + "setting_volume": { + "name": "Sygna\u0142 d\u017awi\u0119kowy", + "state": { + "volume_close": "Wy\u0142.", + "volume_on": "W\u0142." + } + }, + "settings_language": { + "name": "J\u0119zyk", + "state": { + "language_turkish": "Turecki", + "language_english": "Angielski", + "language_german": "Niemiecki" + } } }, "binary_sensor": { @@ -771,6 +1078,33 @@ }, "dishwasher_liquid_detergent_low": { "name": "Niski poziom detergnetu w p\u0142ynie" + }, + "dryer_warning_door_is_open": { + "name": "Otwarte drzwi" + }, + "dryer_warning_tankfull": { + "name": "Pe\u0142en zbiornik na wod\u0119" + }, + "dryer_warning_check_the_filter": { + "name": "Sprawd\u017a filtr" + }, + "dryer_warning_check_the_condenser_filter": { + "name": "Sprawd\u017a filtr wymiennika" + }, + "dryer_warning_drum_empty": { + "name": "B\u0119ben pusty" + }, + "dryer_message_child_lock": { + "name": "Blokada przed dost\u0119pem dzieci" + }, + "dryer_message_end_program": { + "name": "Koniec programu" + }, + "dryer_message_anti_creasing": { + "name": "Ochrona przeciw zagnieceniom" + }, + "dryer_message_anti_creasing_finished": { + "name": "Ochrona przeciw zagnieceniom zako\u0144czona" } }, "climate": { @@ -806,6 +1140,15 @@ "washer_anticrease": { "name": "AntiCrease" }, + "washer_silent_mode": { + "name": "Tryb cichy" + }, + "washer_anti_crease": { + "name": "Zdalna ochrona przed zagniataniem" + }, + "washer_hidden_extra_water": { + "name": "Dodatkowa woda" + }, "oven_booster": { "name": "Szybkie nagrzewanie Booster" }, @@ -838,6 +1181,12 @@ }, "dishwasher_deepwash": { "name": "G\u0142\u0119bokie pranie" + }, + "dryer_steam_mode_onoff": { + "name": "Funkcja pary" + }, + "dryer_lowtemp_mode": { + "name": "Niska temperatura" } } } diff --git a/custom_components/homewhiz/translations/sv.json b/custom_components/homewhiz/translations/sv.json index c1f677a5..787a5097 100644 --- a/custom_components/homewhiz/translations/sv.json +++ b/custom_components/homewhiz/translations/sv.json @@ -75,8 +75,8 @@ "temperature_20": "20 \u00b0C", "temperature_30": "30 \u00b0C", "temperature_40": "40 \u00b0C", - "60c": "60C", - "90c": "90C" + "60c": "60 \u00b0C", + "90c": "90 \u00b0C" } }, "washer_spin": { @@ -359,8 +359,8 @@ "temperature_20": "20 \u00b0C", "temperature_30": "30 \u00b0C", "temperature_40": "40 \u00b0C", - "60c": "60C", - "90c": "90C" + "60c": "60 \u00b0C", + "90c": "90 \u00b0C" } }, "washer_spin": { diff --git a/custom_components/localtuya/__init__.py b/custom_components/localtuya/__init__.py index 35a2adbd..d3736511 100644 --- a/custom_components/localtuya/__init__.py +++ b/custom_components/localtuya/__init__.py @@ -1,55 +1,52 @@ """The LocalTuya integration.""" + import asyncio +from dataclasses import dataclass import logging import time from datetime import timedelta +from typing import Any, NamedTuple import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr import homeassistant.helpers.entity_registry as er import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, - CONF_DEVICE_ID, CONF_DEVICES, + CONF_DEVICE_ID, CONF_ENTITIES, CONF_HOST, CONF_ID, CONF_PLATFORM, CONF_REGION, - CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.event import async_track_time_interval -from .cloud_api import TuyaCloudApi -from .common import TuyaDevice, async_config_entry_by_device_id -from .config_flow import ENTRIES_VERSION, config_schema +from .coordinator import TuyaDevice, HassLocalTuyaData, TuyaCloudApi +from .config_flow import ENTRIES_VERSION from .const import ( ATTR_UPDATED_AT, + CONF_GATEWAY_ID, + CONF_NODE_ID, CONF_NO_CLOUD, CONF_PRODUCT_KEY, CONF_USER_ID, - DATA_CLOUD, DATA_DISCOVERY, DOMAIN, - TUYA_DEVICES, + PLATFORMS, ) + 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" @@ -57,7 +54,7 @@ SERVICE_SET_DP_SCHEMA = vol.Schema( { vol.Required(CONF_DEVICE_ID): cv.string, - vol.Required(CONF_DP): int, + vol.Optional(CONF_DP): int, vol.Required(CONF_VALUE): object, } ) @@ -66,11 +63,11 @@ async def async_setup(hass: HomeAssistant, config: dict): """Set up the LocalTuya integration component.""" hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][TUYA_DEVICES] = {} + current_entries = hass.config_entries.async_entries(DOMAIN) device_cache = {} - async def _handle_reload(service): + async def _handle_reload(service: ServiceCall): """Handle reload service call.""" _LOGGER.info("Service %s.reload called: reloading integration", DOMAIN) @@ -80,56 +77,81 @@ async def _handle_reload(service): hass.config_entries.async_reload(entry.entry_id) for entry in current_entries ] - await asyncio.gather(*reload_tasks) - async def _handle_set_dp(event): + async def _handle_set_dp(event: ServiceCall): """Handle set_dp service call.""" dev_id = event.data[CONF_DEVICE_ID] - if dev_id not in hass.data[DOMAIN][TUYA_DEVICES]: + entry: ConfigEntry = async_config_entry_by_device_id(hass, dev_id) + if not entry or not entry.entry_id: raise HomeAssistantError("unknown device id") - device = hass.data[DOMAIN][TUYA_DEVICES][dev_id] + host = entry.data[CONF_DEVICES][dev_id].get(CONF_HOST) + if node_id := entry.data[CONF_DEVICES][dev_id].get(CONF_NODE_ID): + host = f"{host}_{node_id}" + device: TuyaDevice = hass.data[DOMAIN][entry.entry_id].devices[host] if not device.connected: raise HomeAssistantError("not connected to device") + value = event.data[CONF_VALUE] + if isinstance(value, dict): + await device.set_dps(value) + else: + await device.set_dp(value, event.data[CONF_DP]) - await device.set_dp(event.data[CONF_VALUE], event.data[CONF_DP]) - - def _device_discovered(device): + def _device_discovered(device: dict): """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 - entry = async_config_entry_by_device_id(hass, device_id) + entry: ConfigEntry = async_config_entry_by_device_id(hass, device_id) + if entry is None: return - if device_id not in device_cache: + hass_data: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id] + + if device_id not in device_cache or device_id not in device_cache.get( + device_id, {} + ): if entry and device_id in entry.data[CONF_DEVICES]: # Save address from config entry in cache to trigger # potential update below host_ip = entry.data[CONF_DEVICES][device_id][CONF_HOST] - device_cache[device_id] = host_ip + device_cache[device_id] = {device_id: host_ip} + + for subdev_id, dev_config in entry.data[CONF_DEVICES].items(): + if dev_config.get(CONF_NODE_ID): + if gateway_id := dev_config.get(CONF_GATEWAY_ID): + if entry and device_id == gateway_id: + device_cache[device_id] = device_cache.get(device_id, {}) + device_cache[device_id].update( + {subdev_id: dev_config.get(CONF_HOST)} + ) if device_id not in device_cache: return + if not entry.state == ConfigEntryState.LOADED: + return - dev_entry = entry.data[CONF_DEVICES][device_id] + if device := hass_data.devices.get(device_ip): + ... + # hass.create_task(hass_data.cloud_data.async_get_devices_list()) new_data = entry.data.copy() updated = False - - if device_cache[device_id] != device_ip: - updated = True - new_data[CONF_DEVICES][device_id][CONF_HOST] = device_ip - device_cache[device_id] = device_ip - - if dev_entry.get(CONF_PRODUCT_KEY) != product_key: - updated = True - new_data[CONF_DEVICES][device_id][CONF_PRODUCT_KEY] = product_key - + for dev_id, host in device_cache[device_id].items(): + if dev_id not in entry.data[CONF_DEVICES]: + continue + dev_entry = entry.data[CONF_DEVICES][dev_id] + if host != device_ip: + updated = True + new_data[CONF_DEVICES][dev_id][CONF_HOST] = device_ip + device_cache[device_id][dev_id] = device_ip + + if (p_key := dev_entry.get(CONF_PRODUCT_KEY)) and p_key != product_key: + updated = True + new_data[CONF_DEVICES][dev_id][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. @@ -140,33 +162,11 @@ def _device_discovered(device): new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) hass.config_entries.async_update_entry(entry, data=new_data) - elif device_id in hass.data[DOMAIN][TUYA_DEVICES]: - _LOGGER.debug("Device %s found with IP %s", device_id, device_ip) - - device = hass.data[DOMAIN][TUYA_DEVICES].get(device_id) - if not device: - _LOGGER.warning(f"Could not find device for device_id {device_id}") - elif not device.connected: - device.async_connect() - - def _shutdown(event): """Clean up resources when shutting down.""" discovery.close() - async def _async_reconnect(now): - """Try connecting to devices not already connected to.""" - for device_id, device in hass.data[DOMAIN][TUYA_DEVICES].items(): - 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.services.async_register(DOMAIN, SERVICE_RELOAD, _handle_reload) hass.services.async_register( DOMAIN, SERVICE_SET_DP, _handle_set_dp, schema=SERVICE_SET_DP_SCHEMA @@ -183,43 +183,105 @@ async def _async_reconnect(now): return True -async def async_migrate_entry(hass, config_entry: ConfigEntry): +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): """Migrate old entries merging all of them in one.""" new_version = ENTRIES_VERSION stored_entries = hass.config_entries.async_entries(DOMAIN) if config_entry.version == 1: + # This an old version of original integration no nned to put it here. + pass + # Update to version 3 + if config_entry.version == 2: + # Switch config flow to selectors convert DP IDs from int to str require HA 2022.4. _LOGGER.debug("Migrating config entry from version %s", config_entry.version) + new_data = config_entry.data.copy() + for device in new_data[CONF_DEVICES]: + i = 0 + for _ent in new_data[CONF_DEVICES][device][CONF_ENTITIES]: + ent_items = {} + for k, v in _ent.items(): + ent_items[k] = str(v) if type(v) is int else v + new_data[CONF_DEVICES][device][CONF_ENTITIES][i].update(ent_items) + i = i + 1 + hass.config_entries.async_update_entry(config_entry, data=new_data, version=3) + # Update to version 4 + if config_entry.version <= 3: + # Convert values and friendly name values to dict. + from .const import ( + Platform, + CONF_OPTIONS, + CONF_HVAC_MODE_SET, + CONF_HVAC_ACTION_SET, + CONF_PRESET_SET, + CONF_SCENE_VALUES, + # Deprecated + CONF_SCENE_VALUES_FRIENDLY, + CONF_OPTIONS_FRIENDLY, + CONF_HVAC_ADD_OFF, + ) + from .climate import ( + RENAME_HVAC_MODE_SETS, + RENAME_ACTION_SETS, + RENAME_PRESET_SETS, + HVAC_OFF, + ) - if config_entry.entry_id == stored_entries[0].entry_id: - _LOGGER.debug( - "Migrating the first config entry (%s)", config_entry.entry_id - ) - new_data = {} - new_data[CONF_REGION] = "eu" - new_data[CONF_CLIENT_ID] = "" - new_data[CONF_CLIENT_SECRET] = "" - new_data[CONF_USER_ID] = "" - new_data[CONF_USERNAME] = DOMAIN - new_data[CONF_NO_CLOUD] = True - new_data[CONF_DEVICES] = { - config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy() - } - new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) - config_entry.version = new_version - hass.config_entries.async_update_entry( - config_entry, title=DOMAIN, data=new_data - ) - else: - _LOGGER.debug( - "Merging the config entry %s into the main one", config_entry.entry_id - ) - new_data = stored_entries[0].data.copy() - new_data[CONF_DEVICES].update( - {config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy()} - ) - new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) - hass.config_entries.async_update_entry(stored_entries[0], data=new_data) - await hass.config_entries.async_remove(config_entry.entry_id) + def convert_str_to_dict(list1: str, list2: str = ""): + to_dict = {} + if not isinstance(list1, str): + return list1 + list1, list2 = list1.replace(";", ","), list2.replace(";", ",") + v, v_fn = list1.split(","), list2.split(",") + for k in range(len(v)): + to_dict[v[k]] = ( + v_fn[k] if k < len(v_fn) and v_fn[k] else v[k].capitalize() + ) + return to_dict + + new_data = config_entry.data.copy() + for device in new_data[CONF_DEVICES]: + current_entity = 0 + for entity in new_data[CONF_DEVICES][device][CONF_ENTITIES]: + new_entity_data = {} + if entity[CONF_PLATFORM] == Platform.SELECT: + # Merge 2 Lists Values and Values friendly names into dict. + v_fn = entity.get(CONF_OPTIONS_FRIENDLY, "") + if v := entity.get(CONF_OPTIONS): + new_entity_data[CONF_OPTIONS] = convert_str_to_dict(v, v_fn) + if entity[CONF_PLATFORM] == Platform.LIGHT: + v_fn = entity.get(CONF_SCENE_VALUES_FRIENDLY, "") + if v := entity.get(CONF_SCENE_VALUES): + new_entity_data[CONF_SCENE_VALUES] = convert_str_to_dict( + v, v_fn + ) + if entity[CONF_PLATFORM] == Platform.CLIMATE: + # Merge 2 Lists Values and Values friendly names into dict. + climate_to_dict = {} + for conf, new_values in ( + (CONF_HVAC_MODE_SET, RENAME_HVAC_MODE_SETS), + (CONF_HVAC_ACTION_SET, RENAME_ACTION_SETS), + (CONF_PRESET_SET, RENAME_PRESET_SETS), + ): + climate_to_dict[conf] = {} + if hvac_set := entity.get(conf, ""): + if entity.get(CONF_HVAC_ADD_OFF, False): + if conf == CONF_HVAC_MODE_SET: + climate_to_dict[conf].update(HVAC_OFF) + if not isinstance(conf, str): + continue + hvac_set = hvac_set.replace("/", ",") + for i in hvac_set.split(","): + for k, v in new_values.items(): + if i in k: + new_v = True if i == "True" else i + new_v = False if i == "False" else new_v + climate_to_dict[conf].update({v: new_v}) + new_entity_data = climate_to_dict + new_data[CONF_DEVICES][device][CONF_ENTITIES][current_entity].update( + new_entity_data + ) + current_entity += 1 + hass.config_entries.async_update_entry(config_entry, data=new_data, version=4) _LOGGER.info( "Entry %s successfully migrated to version %s.", @@ -245,86 +307,102 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): secret = entry.data[CONF_CLIENT_SECRET] user_id = entry.data[CONF_USER_ID] tuya_api = TuyaCloudApi(hass, region, client_id, secret, user_id) - no_cloud = True - if CONF_NO_CLOUD in entry.data: - no_cloud = entry.data.get(CONF_NO_CLOUD) + no_cloud = entry.data.get(CONF_NO_CLOUD, True) + if no_cloud: - _LOGGER.info("Cloud API account not configured.") - # wait 1 second to make sure possible migration has finished - await asyncio.sleep(1) + _LOGGER.info(f"Cloud API account not configured.") else: - res = await tuya_api.async_get_access_token() - if res != "ok": - _LOGGER.error("Cloud API connection failed: %s", res) - else: - _LOGGER.info("Cloud API connection succeeded.") - res = await tuya_api.async_get_devices_list() - hass.data[DOMAIN][DATA_CLOUD] = tuya_api - - async def setup_entities(device_ids): - platforms = set() - for dev_id in device_ids: - entities = entry.data[CONF_DEVICES][dev_id][CONF_ENTITIES] - platforms = platforms.union( - set(entity[CONF_PLATFORM] for entity in entities) + entry.async_create_background_task( + hass, tuya_api.async_connect(), "localtuya-cloudAPI" + ) + + hass_localtuya = HassLocalTuyaData(tuya_api, {}) + hass.data[DOMAIN][entry.entry_id] = hass_localtuya + + def _setup_devices(entry_devices: dict): + """Setup Localtuya devices object.""" + devices = hass_localtuya.devices + connect_to_devices = [] + + # Sort parent devices first then sub-devices. + sorted_devices = dict( + sorted( + entry_devices.items(), key=lambda k: 1 if k[1].get(CONF_NODE_ID) else 0 ) - hass.data[DOMAIN][TUYA_DEVICES][dev_id] = TuyaDevice(hass, entry, dev_id) + ) - # Setup all platforms at once, letting HA handling each platform and avoiding - # potential integration restarts while elements are still initialising. - await hass.config_entries.async_forward_entry_setups(entry, platforms) + for dev_id, config in sorted_devices.items(): + if check_if_device_disabled(hass, entry, dev_id): + continue - for dev_id in device_ids: - hass.data[DOMAIN][TUYA_DEVICES][dev_id].async_connect() + host = config.get(CONF_HOST) - await async_remove_orphan_entities(hass, entry) + # Parent Devices. + if not (node_id := config.get(CONF_NODE_ID)): + devices[host] = (dev := TuyaDevice(hass, entry, config)) + connect_to_devices.append(dev) + continue - hass.async_create_task(setup_entities(entry.data[CONF_DEVICES].keys())) + # Sub-Devices + if not (gateway := devices.get(host)): + # Setup sub-device as fake gateway if there is no a gateway exist. + devices[host] = (gateway := TuyaDevice(hass, entry, config, True)) + connect_to_devices.append(gateway) - unsub_listener = entry.add_update_listener(update_listener) - hass.data[DOMAIN][entry.entry_id] = {UNSUB_LISTENER: unsub_listener} + devices[f"{host}_{node_id}"] = (sub_dev := TuyaDevice(hass, entry, config)) + sub_dev.gateway = gateway + gateway.sub_devices[node_id] = sub_dev - return True + return connect_to_devices + connect_to_devices = _setup_devices(entry.data[CONF_DEVICES]) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): - """Unload a config entry.""" - platforms = {} + await async_remove_orphan_entities(hass, entry) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS.values()) - for dev_id, dev_entry in entry.data[CONF_DEVICES].items(): - for entity in dev_entry[CONF_ENTITIES]: - platforms[entity[CONF_PLATFORM]] = True + # Note: entry.async_on_unload items are called in LIFO order! - unload_ok = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, component) - for component in platforms - ] - ) + for dev in connect_to_devices: + asyncio.create_task(dev.async_connect()) + entry.async_on_unload(dev.close) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + async def _shutdown(event): + """Clean up resources when shutting down.""" + for dev in connect_to_devices: + await dev.close() + _LOGGER.info("Shutdown completed") + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) ) - hass.data[DOMAIN][entry.entry_id][UNSUB_LISTENER]() - for dev_id, device in hass.data[DOMAIN][TUYA_DEVICES].items(): - if device.connected: - await device.close() + entry.async_on_unload(_run_async_listen(hass, entry)) + _LOGGER.info("Setup completed") + return True + - if unload_ok: - hass.data[DOMAIN][TUYA_DEVICES] = {} +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unloading the Tuya platforms.""" + # Unload the platforms. + await hass.config_entries.async_unload_platforms(entry, PLATFORMS.values()) + hass.data[DOMAIN].pop(entry.entry_id) + _LOGGER.info("Unload completed") return True -async def update_listener(hass, config_entry): +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry): """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a config entry from a device.""" - dev_id = list(device_entry.identifiers)[0][1].split("_")[-1] + dev_id = _device_id_by_identifiers(device_entry.identifiers) ent_reg = er.async_get(hass) entities = { @@ -341,7 +419,8 @@ async def async_remove_config_entry_device( ) return True - await hass.data[DOMAIN][TUYA_DEVICES][dev_id].close() + # host = config_entry.data[CONF_DEVICES][dev_id][CONF_HOST] + # await hass.data[DOMAIN][config_entry.entry_id].devices[host].close() new_data = config_entry.data.copy() new_data[CONF_DEVICES].pop(dev_id) @@ -374,3 +453,87 @@ async def async_remove_orphan_entities(hass, entry): for entity_id in entities.values(): ent_reg.async_remove(entity_id) + + +def _run_async_listen(hass: HomeAssistant, entry: ConfigEntry): + """Start the listing events""" + + @callback + def _event_filtter(data: dr.EventDeviceRegistryUpdatedData) -> bool: + device_reg = dr.async_get(hass).async_get(data["device_id"]) + is_entry = device_reg and entry.entry_id in device_reg.config_entries + return data["action"] == "update" and is_entry + + async def device_state_changed(event: Event[dr.EventDeviceRegistryUpdatedData]): + """Close connection if device disabled.""" + if not "disabled_by" in event.data["changes"]: + return + + device_registry = dr.async_get(hass).async_get(event.data["device_id"]) + + hass_localtuya: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id] + + dev_id = _device_id_by_identifiers(device_registry.identifiers) + host_ip = entry.data[CONF_DEVICES][dev_id][CONF_HOST] + + if cid := entry.data[CONF_DEVICES][dev_id].get(CONF_NODE_ID): + host_ip = f"{host_ip}_{cid}" + + device = hass_localtuya.devices.get(host_ip) + + if device and device_registry.disabled: + # If this is a gateway or fake gateway then reload entry to start using another device as GW. + if device.sub_devices or (device.gateway and device.gateway.id == dev_id): + await hass.config_entries.async_reload(entry.entry_id) + else: + await device.close() + + return hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, device_state_changed, _event_filtter + ) + + +def _device_id_by_identifiers(identifiers: set[tuple[str, str]]): + """Return localtuya device ID by device registry identifiers.""" + return list(identifiers)[0][1].split("_")[-1] + + +@callback +def async_config_entry_by_device_id(hass: HomeAssistant, device_id: str): + """Look up config entry by device id.""" + current_entries = hass.config_entries.async_entries(DOMAIN) + for entry in current_entries: + if device_id in entry.data[CONF_DEVICES]: + return entry + # Search for gateway_id + for dev_conf in entry.data[CONF_DEVICES].values(): + if (gw_id := dev_conf.get(CONF_GATEWAY_ID)) and gw_id == device_id: + return entry + return None + + +@callback +def async_device_id_by_entity_id(hass: HomeAssistant, entity_id: str): + """Look up config entry by device id.""" + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + if device := dev_reg.async_get(ent_reg.async_get(entity_id).device_id): + return list(device.identifiers)[0][1].split("_")[-1] + + return None + + +@callback +def check_if_device_disabled(hass: HomeAssistant, entry: ConfigEntry, dev_id: str): + """Return whether if the device disabled or not.""" + ent_reg = er.async_get(hass) + entries = er.async_entries_for_config_entry(ent_reg, entry.entry_id) + ha_device_id: str = None + + for entitiy in entries: + if dev_id in entitiy.unique_id: + ha_device_id = entitiy.device_id + break + + if ha_device_id and (device := dr.async_get(hass).async_get(ha_device_id)): + return device.disabled diff --git a/custom_components/localtuya/__pycache__/__init__.cpython-312.pyc b/custom_components/localtuya/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a9037ee6..00000000 Binary files a/custom_components/localtuya/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/__init__.cpython-313.pyc b/custom_components/localtuya/__pycache__/__init__.cpython-313.pyc index e88ef714..69430f48 100644 Binary files a/custom_components/localtuya/__pycache__/__init__.cpython-313.pyc and b/custom_components/localtuya/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/alarm_control_panel.cpython-313.pyc b/custom_components/localtuya/__pycache__/alarm_control_panel.cpython-313.pyc new file mode 100644 index 00000000..b2de8b7b Binary files /dev/null and b/custom_components/localtuya/__pycache__/alarm_control_panel.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/binary_sensor.cpython-312.pyc b/custom_components/localtuya/__pycache__/binary_sensor.cpython-312.pyc deleted file mode 100644 index 4fd61961..00000000 Binary files a/custom_components/localtuya/__pycache__/binary_sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/binary_sensor.cpython-313.pyc b/custom_components/localtuya/__pycache__/binary_sensor.cpython-313.pyc index 510693a3..6bcc2639 100644 Binary files a/custom_components/localtuya/__pycache__/binary_sensor.cpython-313.pyc and b/custom_components/localtuya/__pycache__/binary_sensor.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/button.cpython-313.pyc b/custom_components/localtuya/__pycache__/button.cpython-313.pyc new file mode 100644 index 00000000..40a4218d Binary files /dev/null and b/custom_components/localtuya/__pycache__/button.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/climate.cpython-312.pyc b/custom_components/localtuya/__pycache__/climate.cpython-312.pyc deleted file mode 100644 index a545d629..00000000 Binary files a/custom_components/localtuya/__pycache__/climate.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/climate.cpython-313.pyc b/custom_components/localtuya/__pycache__/climate.cpython-313.pyc index 14b809f7..4ee0b164 100644 Binary files a/custom_components/localtuya/__pycache__/climate.cpython-313.pyc and b/custom_components/localtuya/__pycache__/climate.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/cloud_api.cpython-312.pyc b/custom_components/localtuya/__pycache__/cloud_api.cpython-312.pyc deleted file mode 100644 index a67f4ea1..00000000 Binary files a/custom_components/localtuya/__pycache__/cloud_api.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/cloud_api.cpython-313.pyc b/custom_components/localtuya/__pycache__/cloud_api.cpython-313.pyc deleted file mode 100644 index 42c73dfb..00000000 Binary files a/custom_components/localtuya/__pycache__/cloud_api.cpython-313.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/common.cpython-312.pyc b/custom_components/localtuya/__pycache__/common.cpython-312.pyc deleted file mode 100644 index 17257a93..00000000 Binary files a/custom_components/localtuya/__pycache__/common.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/common.cpython-313.pyc b/custom_components/localtuya/__pycache__/common.cpython-313.pyc deleted file mode 100644 index f31c1eb9..00000000 Binary files a/custom_components/localtuya/__pycache__/common.cpython-313.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/config_flow.cpython-312.pyc b/custom_components/localtuya/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index bf963082..00000000 Binary files a/custom_components/localtuya/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/config_flow.cpython-313.pyc b/custom_components/localtuya/__pycache__/config_flow.cpython-313.pyc index afb41fc1..83c84a7a 100644 Binary files a/custom_components/localtuya/__pycache__/config_flow.cpython-313.pyc and b/custom_components/localtuya/__pycache__/config_flow.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/const.cpython-312.pyc b/custom_components/localtuya/__pycache__/const.cpython-312.pyc deleted file mode 100644 index 9a6accaf..00000000 Binary files a/custom_components/localtuya/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/const.cpython-313.pyc b/custom_components/localtuya/__pycache__/const.cpython-313.pyc index 72fb6192..63930a08 100644 Binary files a/custom_components/localtuya/__pycache__/const.cpython-313.pyc and b/custom_components/localtuya/__pycache__/const.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/coordinator.cpython-313.pyc b/custom_components/localtuya/__pycache__/coordinator.cpython-313.pyc new file mode 100644 index 00000000..08a0637d Binary files /dev/null and b/custom_components/localtuya/__pycache__/coordinator.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/cover.cpython-312.pyc b/custom_components/localtuya/__pycache__/cover.cpython-312.pyc deleted file mode 100644 index 7392c903..00000000 Binary files a/custom_components/localtuya/__pycache__/cover.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/cover.cpython-313.pyc b/custom_components/localtuya/__pycache__/cover.cpython-313.pyc index ef213c35..76f651f3 100644 Binary files a/custom_components/localtuya/__pycache__/cover.cpython-313.pyc and b/custom_components/localtuya/__pycache__/cover.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/diagnostics.cpython-312.pyc b/custom_components/localtuya/__pycache__/diagnostics.cpython-312.pyc deleted file mode 100644 index a3b07dbe..00000000 Binary files a/custom_components/localtuya/__pycache__/diagnostics.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/diagnostics.cpython-313.pyc b/custom_components/localtuya/__pycache__/diagnostics.cpython-313.pyc index 3ee35142..69341676 100644 Binary files a/custom_components/localtuya/__pycache__/diagnostics.cpython-313.pyc and b/custom_components/localtuya/__pycache__/diagnostics.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/discovery.cpython-312.pyc b/custom_components/localtuya/__pycache__/discovery.cpython-312.pyc deleted file mode 100644 index b8344299..00000000 Binary files a/custom_components/localtuya/__pycache__/discovery.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/discovery.cpython-313.pyc b/custom_components/localtuya/__pycache__/discovery.cpython-313.pyc index 4dba754d..9e189adf 100644 Binary files a/custom_components/localtuya/__pycache__/discovery.cpython-313.pyc and b/custom_components/localtuya/__pycache__/discovery.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/entity.cpython-313.pyc b/custom_components/localtuya/__pycache__/entity.cpython-313.pyc new file mode 100644 index 00000000..b2e31f64 Binary files /dev/null and b/custom_components/localtuya/__pycache__/entity.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/fan.cpython-312.pyc b/custom_components/localtuya/__pycache__/fan.cpython-312.pyc deleted file mode 100644 index 579bcd0f..00000000 Binary files a/custom_components/localtuya/__pycache__/fan.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/fan.cpython-313.pyc b/custom_components/localtuya/__pycache__/fan.cpython-313.pyc index bd7be31c..0e0dbaf3 100644 Binary files a/custom_components/localtuya/__pycache__/fan.cpython-313.pyc and b/custom_components/localtuya/__pycache__/fan.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/humidifier.cpython-313.pyc b/custom_components/localtuya/__pycache__/humidifier.cpython-313.pyc new file mode 100644 index 00000000..177e9475 Binary files /dev/null and b/custom_components/localtuya/__pycache__/humidifier.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/light.cpython-312.pyc b/custom_components/localtuya/__pycache__/light.cpython-312.pyc deleted file mode 100644 index 43e7f6c1..00000000 Binary files a/custom_components/localtuya/__pycache__/light.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/light.cpython-313.pyc b/custom_components/localtuya/__pycache__/light.cpython-313.pyc index 066836c1..629f37c2 100644 Binary files a/custom_components/localtuya/__pycache__/light.cpython-313.pyc and b/custom_components/localtuya/__pycache__/light.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/lock.cpython-313.pyc b/custom_components/localtuya/__pycache__/lock.cpython-313.pyc new file mode 100644 index 00000000..f3d8f9eb Binary files /dev/null and b/custom_components/localtuya/__pycache__/lock.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/number.cpython-312.pyc b/custom_components/localtuya/__pycache__/number.cpython-312.pyc deleted file mode 100644 index 69888a11..00000000 Binary files a/custom_components/localtuya/__pycache__/number.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/number.cpython-313.pyc b/custom_components/localtuya/__pycache__/number.cpython-313.pyc index 1607ab22..7ba9d750 100644 Binary files a/custom_components/localtuya/__pycache__/number.cpython-313.pyc and b/custom_components/localtuya/__pycache__/number.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/remote.cpython-313.pyc b/custom_components/localtuya/__pycache__/remote.cpython-313.pyc new file mode 100644 index 00000000..9add15f2 Binary files /dev/null and b/custom_components/localtuya/__pycache__/remote.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/select.cpython-312.pyc b/custom_components/localtuya/__pycache__/select.cpython-312.pyc deleted file mode 100644 index e39cd7e5..00000000 Binary files a/custom_components/localtuya/__pycache__/select.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/select.cpython-313.pyc b/custom_components/localtuya/__pycache__/select.cpython-313.pyc index 4dcfe2a2..0014f543 100644 Binary files a/custom_components/localtuya/__pycache__/select.cpython-313.pyc and b/custom_components/localtuya/__pycache__/select.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/sensor.cpython-312.pyc b/custom_components/localtuya/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index 0138f988..00000000 Binary files a/custom_components/localtuya/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/sensor.cpython-313.pyc b/custom_components/localtuya/__pycache__/sensor.cpython-313.pyc index 7ad70d2d..d69e5974 100644 Binary files a/custom_components/localtuya/__pycache__/sensor.cpython-313.pyc and b/custom_components/localtuya/__pycache__/sensor.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/siren.cpython-313.pyc b/custom_components/localtuya/__pycache__/siren.cpython-313.pyc new file mode 100644 index 00000000..28678a18 Binary files /dev/null and b/custom_components/localtuya/__pycache__/siren.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/switch.cpython-312.pyc b/custom_components/localtuya/__pycache__/switch.cpython-312.pyc deleted file mode 100644 index e1739b47..00000000 Binary files a/custom_components/localtuya/__pycache__/switch.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/switch.cpython-313.pyc b/custom_components/localtuya/__pycache__/switch.cpython-313.pyc index e92cc4f0..ffa92a24 100644 Binary files a/custom_components/localtuya/__pycache__/switch.cpython-313.pyc and b/custom_components/localtuya/__pycache__/switch.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/vacuum.cpython-312.pyc b/custom_components/localtuya/__pycache__/vacuum.cpython-312.pyc deleted file mode 100644 index 68113513..00000000 Binary files a/custom_components/localtuya/__pycache__/vacuum.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/__pycache__/vacuum.cpython-313.pyc b/custom_components/localtuya/__pycache__/vacuum.cpython-313.pyc index b521d2e2..dd0d18ae 100644 Binary files a/custom_components/localtuya/__pycache__/vacuum.cpython-313.pyc and b/custom_components/localtuya/__pycache__/vacuum.cpython-313.pyc differ diff --git a/custom_components/localtuya/__pycache__/water_heater.cpython-313.pyc b/custom_components/localtuya/__pycache__/water_heater.cpython-313.pyc new file mode 100644 index 00000000..c799df4a Binary files /dev/null and b/custom_components/localtuya/__pycache__/water_heater.cpython-313.pyc differ diff --git a/custom_components/localtuya/alarm_control_panel.py b/custom_components/localtuya/alarm_control_panel.py new file mode 100644 index 00000000..e6151fe5 --- /dev/null +++ b/custom_components/localtuya/alarm_control_panel.py @@ -0,0 +1,134 @@ +"""Platform to present any Tuya DP as a Alarm.""" + +from enum import StrEnum +import logging +from functools import partial +from .config_flow import col_to_select + +import voluptuous as vol +from homeassistant.helpers import selector +from homeassistant.components.alarm_control_panel import ( + DOMAIN, + AlarmControlPanelEntity, + CodeFormat, + AlarmControlPanelEntityFeature, + AlarmControlPanelState, +) + +from .entity import LocalTuyaEntity, async_setup_entry +from .const import CONF_ALARM_SUPPORTED_STATES + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PRECISION = 2 + + +class TuyaMode(StrEnum): + DISARMED = "disarmed" + ARM = "arm" + HOME = "home" + SOS = "sos" + + +DEFAULT_SUPPORTED_MODES = { + AlarmControlPanelState.DISARMED: TuyaMode.DISARMED, + AlarmControlPanelState.ARMED_AWAY: TuyaMode.ARM, + AlarmControlPanelState.ARMED_HOME: TuyaMode.HOME, + AlarmControlPanelState.TRIGGERED: TuyaMode.SOS, +} + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional( + CONF_ALARM_SUPPORTED_STATES, default=DEFAULT_SUPPORTED_MODES + ): selector.ObjectSelector(), + } + + +class LocalTuyaAlarmControlPanel(LocalTuyaEntity, AlarmControlPanelEntity): + """Representation of a Tuya Alarm.""" + + _supported_modes = {} + + def __init__( + self, + device, + config_entry, + dpid, + **kwargs, + ): + """Initialize the Tuya Alarm.""" + super().__init__(device, config_entry, dpid, _LOGGER, **kwargs) + self._state = None + self._changed_by = None + + # supported modes + if supported_modes := self._config.get(CONF_ALARM_SUPPORTED_STATES, {}): + # Key is HA state and value is Tuya State. + if AlarmControlPanelState.ARMED_AWAY in supported_modes: + self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME + if AlarmControlPanelState.ARMED_HOME in supported_modes: + self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY + if AlarmControlPanelState.TRIGGERED in supported_modes: + self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER + + self._state_ha_to_tuya: dict[str, str] = supported_modes + self._state_tuya_to_ha: dict[str, str] = { + v: k for k, v in supported_modes.items() + } + + @property + def state(self): + """Return Alarm state.""" + return self._state_tuya_to_ha.get(self._state, None) + + @property + def code_format(self) -> CodeFormat | None: + """Code format or None if no code is required.""" + return None # self._attr_code_format + + @property + def changed_by(self) -> str | None: + """Last change triggered by.""" + return None # self._attr_changed_by + + @property + def code_arm_required(self) -> bool: + """Whether the code is required for arm actions.""" + return True # self._attr_code_arm_required + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + state = self._state_ha_to_tuya.get(AlarmControlPanelState.DISARMED) + await self._device.set_dp(state, self._dp_id) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + state = self._state_ha_to_tuya.get(AlarmControlPanelState.ARMED_HOME) + await self._device.set_dp(state, self._dp_id) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + state = self._state_ha_to_tuya.get(AlarmControlPanelState.ARMED_AWAY) + await self._device.set_dp(state, self._dp_id) + + async def async_alarm_trigger(self, code: str | None = None) -> None: + """Send alarm trigger command.""" + state = self._state_ha_to_tuya.get(AlarmControlPanelState.TRIGGERED) + await self._device.set_dp(state, self._dp_id) + + def status_updated(self): + """Device status was updated.""" + super().status_updated() + + # No need to restore state for a AlarmControlPanel + async def restore_state_when_connected(self): + """Do nothing for a AlarmControlPanel.""" + return + + +async_setup_entry = partial( + async_setup_entry, DOMAIN, LocalTuyaAlarmControlPanel, flow_schema +) diff --git a/custom_components/localtuya/binary_sensor.py b/custom_components/localtuya/binary_sensor.py index 273880ca..53fb4ad5 100644 --- a/custom_components/localtuya/binary_sensor.py +++ b/custom_components/localtuya/binary_sensor.py @@ -1,4 +1,5 @@ """Platform to present any Tuya DP as a binary sensor.""" + import logging from functools import partial @@ -10,11 +11,11 @@ ) from homeassistant.const import CONF_DEVICE_CLASS -from .common import LocalTuyaEntity, async_setup_entry +from .entity import LocalTuyaEntity, async_setup_entry +from .const import CONF_STATE_ON _LOGGER = logging.getLogger(__name__) -CONF_STATE_ON = "state_on" CONF_STATE_OFF = "state_off" @@ -22,12 +23,12 @@ 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.Required(CONF_STATE_OFF, default="False"): str, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, } -class LocaltuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity): +class LocalTuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity): """Representation of a Tuya binary sensor.""" def __init__( @@ -46,24 +47,17 @@ 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.""" super().status_updated() - state = str(self.dps(self._dp_id)).lower() - if state == self._config[CONF_STATE_ON].lower(): + state = str(self.dp_value(self._dp_id)).lower() + # users may set wrong on states, But we assume that must devices use this on states. + possible_on_states = ["true", "1", "pir", "on"] + if state == self._config[CONF_STATE_ON].lower() or state in possible_on_states: 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 - ) + self._is_on = False # No need to restore state for a sensor async def restore_state_when_connected(self): @@ -72,5 +66,5 @@ async def restore_state_when_connected(self): async_setup_entry = partial( - async_setup_entry, DOMAIN, LocaltuyaBinarySensor, flow_schema + async_setup_entry, DOMAIN, LocalTuyaBinarySensor, flow_schema ) diff --git a/custom_components/localtuya/button.py b/custom_components/localtuya/button.py new file mode 100644 index 00000000..1a210c07 --- /dev/null +++ b/custom_components/localtuya/button.py @@ -0,0 +1,41 @@ +"""Platform to locally control Tuya-based button devices.""" + +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.button import DOMAIN, ButtonEntity + +from .entity import LocalTuyaEntity, async_setup_entry +from .const import CONF_PASSIVE_ENTITY + +_LOGGER = logging.getLogger(__name__) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + # vol.Required(CONF_PASSIVE_ENTITY): bool, + } + + +class LocalTuyaButton(LocalTuyaEntity, ButtonEntity): + """Representation of a Tuya button.""" + + def __init__( + self, + device, + config_entry, + buttonid, + **kwargs, + ): + """Initialize the Tuya button.""" + super().__init__(device, config_entry, buttonid, _LOGGER, **kwargs) + self._state = None + + async def async_press(self): + """Press the button.""" + await self._device.set_dp(True, self._dp_id) + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaButton, flow_schema) diff --git a/custom_components/localtuya/climate.py b/custom_components/localtuya/climate.py index da23cfaf..e99ca3fe 100644 --- a/custom_components/localtuya/climate.py +++ b/custom_components/localtuya/climate.py @@ -1,7 +1,12 @@ -"""Platform to locally control Tuya-based climate devices.""" +"""Platform to locally control Tuya-based climate devices. + # PRESETS and HVAC_MODE Needs to be handle in better way. +""" + import asyncio import logging from functools import partial +from .config_flow import col_to_select +from homeassistant.helpers import selector import voluptuous as vol from homeassistant.components.climate import ( @@ -11,20 +16,13 @@ ClimateEntity, ) from homeassistant.components.climate.const import ( - HVACAction, HVACMode, + HVACAction, PRESET_AWAY, PRESET_ECO, PRESET_HOME, PRESET_NONE, ClimateEntityFeature, - FAN_AUTO, - FAN_LOW, - FAN_MEDIUM, - FAN_HIGH, - FAN_TOP, - SWING_ON, - SWING_OFF, ) from homeassistant.const import ( ATTR_TEMPERATURE, @@ -34,12 +32,10 @@ PRECISION_WHOLE, UnitOfTemperature, ) - -from .common import LocalTuyaEntity, async_setup_entry +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM +from .entity import LocalTuyaEntity, async_setup_entry from .const import ( CONF_CURRENT_TEMPERATURE_DP, - CONF_TEMP_MAX, - CONF_TEMP_MIN, CONF_ECO_DP, CONF_ECO_VALUE, CONF_HEURISTIC_ACTION, @@ -47,142 +43,144 @@ CONF_HVAC_ACTION_SET, CONF_HVAC_MODE_DP, CONF_HVAC_MODE_SET, - CONF_MAX_TEMP_DP, - CONF_MIN_TEMP_DP, CONF_PRECISION, CONF_PRESET_DP, CONF_PRESET_SET, CONF_TARGET_PRECISION, CONF_TARGET_TEMPERATURE_DP, CONF_TEMPERATURE_STEP, - CONF_HVAC_FAN_MODE_DP, - CONF_HVAC_FAN_MODE_SET, - CONF_HVAC_SWING_MODE_DP, - CONF_HVAC_SWING_MODE_SET, + CONF_MIN_TEMP, + CONF_MAX_TEMP, + CONF_HVAC_ADD_OFF, + CONF_FAN_SPEED_DP, + CONF_FAN_SPEED_LIST, ) _LOGGER = logging.getLogger(__name__) -HVAC_MODE_SETS = { - "manual/auto": { - HVACMode.HEAT: "manual", - HVACMode.AUTO: "auto", - }, - "Manual/Auto": { - HVACMode.HEAT: "Manual", - HVACMode.AUTO: "Auto", - }, - "Manual/Program": { - HVACMode.HEAT: "Manual", - HVACMode.AUTO: "Program", - }, - "m/p": { - HVACMode.HEAT: "m", - HVACMode.AUTO: "p", - }, - "True/False": { - HVACMode.HEAT: True, - }, - "Auto/Cold/Dry/Wind/Hot": { - HVACMode.HEAT: "hot", - HVACMode.FAN_ONLY: "wind", - HVACMode.DRY: "wet", - HVACMode.COOL: "cold", - HVACMode.AUTO: "auto", - }, - "1/0": { - HVACMode.HEAT: "1", - HVACMode.AUTO: "0", - }, + +HVAC_OFF = {HVACMode.OFF.value: "off"} +RENAME_HVAC_MODE_SETS = { # Migrate to 3 + ("manual", "Manual", "hot", "m", "True"): HVACMode.HEAT.value, + ("auto", "0", "p", "Program"): HVACMode.AUTO.value, + ("freeze", "cold", "1"): HVACMode.COOL.value, + ("wet"): HVACMode.DRY.value, } -HVAC_ACTION_SETS = { - "True/False": { - HVACAction.HEATING: True, - HVACAction.IDLE: False, - }, - "open/close": { - HVACAction.HEATING: "open", - HVACAction.IDLE: "close", - }, - "heating/no_heating": { - HVACAction.HEATING: "heating", - HVACAction.IDLE: "no_heating", - }, - "Heat/Warming": { - HVACAction.HEATING: "Heat", - HVACAction.IDLE: "Warming", - }, +RENAME_ACTION_SETS = { # Migrate to 3 + ("open", "opened", "heating", "Heat", "True"): HVACAction.HEATING.value, + ("closed", "close", "no_heating"): HVACAction.IDLE.value, + ("Warming", "warming", "False"): HVACAction.IDLE.value, + ("cooling"): HVACAction.COOLING.value, + ("off"): HVACAction.OFF.value, } -HVAC_FAN_MODE_SETS = { - "Auto/Low/Middle/High/Strong": { - FAN_AUTO: "auto", - FAN_LOW: "low", - FAN_MEDIUM: "middle", - FAN_HIGH: "high", - FAN_TOP: "strong", - } +RENAME_PRESET_SETS = { + "Holiday": (PRESET_AWAY), + "Program": (PRESET_HOME), + "Manual": (PRESET_NONE, "manual"), + "Auto": "auto", + "Manual": "manual", + "Smart": "smart", + "Comfort": "comfortable", + "ECO": "eco", } -HVAC_SWING_MODE_SETS = { - "True/False": { - SWING_ON: True, - SWING_OFF: False, - } + + +HVAC_MODE_SETS = { + HVACMode.OFF: False, + HVACMode.AUTO: "auto", + HVACMode.COOL: "cold", + HVACMode.HEAT: "hot", + HVACMode.HEAT_COOL: "heat", + HVACMode.DRY: "wet", + HVACMode.FAN_ONLY: "wind", } -PRESET_SETS = { - "Manual/Holiday/Program": { - PRESET_AWAY: "Holiday", - PRESET_HOME: "Program", - PRESET_NONE: "Manual", - }, + +HVAC_ACTION_SETS = { + HVACAction.HEATING: "opened", + HVACAction.IDLE: "closed", } +from enum import StrEnum + -TEMPERATURE_CELSIUS = "celsius" -TEMPERATURE_FAHRENHEIT = "fahrenheit" -DEFAULT_TEMPERATURE_UNIT = TEMPERATURE_CELSIUS +class SupportedTemps(StrEnum): + C = "celsius" + F = "fahrenheit" + C_F = f"celsius/fahrenheit" + F_C = f"fahrenheit/celsius" + + +SUPPORTED_TEMPERATURES = { + UnitOfTemperature.CELSIUS: SupportedTemps.C, + UnitOfTemperature.FAHRENHEIT: SupportedTemps.F, + f"Target Temperature: {UnitOfTemperature.CELSIUS} | Current Temperature {UnitOfTemperature.FAHRENHEIT}": SupportedTemps.C_F, + f"Target Temperature: {UnitOfTemperature.FAHRENHEIT} | Current Temperature {UnitOfTemperature.CELSIUS}": SupportedTemps.F_C, +} + +DEFAULT_TEMPERATURE_UNIT = SupportedTemps.C DEFAULT_PRECISION = PRECISION_TENTHS DEFAULT_TEMPERATURE_STEP = PRECISION_HALVES # Empirically tested to work for AVATTO thermostat MODE_WAIT = 0.1 +FAN_SPEEDS_DEFAULT = "auto,low,middle,high" + 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, default=PRECISION_WHOLE): vol.In( + vol.Optional(CONF_TARGET_TEMPERATURE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_CURRENT_TEMPERATURE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_TEMPERATURE_STEP): col_to_select( [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] ), - vol.Optional(CONF_TEMP_MIN, default=DEFAULT_MIN_TEMP): vol.Coerce(float), - vol.Optional(CONF_TEMP_MAX, default=DEFAULT_MAX_TEMP): vol.Coerce(float), - vol.Optional(CONF_MAX_TEMP_DP): vol.In(dps), - vol.Optional(CONF_MIN_TEMP_DP): vol.In(dps), - vol.Optional(CONF_PRECISION, default=PRECISION_WHOLE): vol.In( + vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), + vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float), + vol.Optional(CONF_PRECISION, default=str(DEFAULT_PRECISION)): col_to_select( [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_FAN_MODE_DP): vol.In(dps), - vol.Optional(CONF_HVAC_FAN_MODE_SET): vol.In(list(HVAC_FAN_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_TARGET_PRECISION, default=str(DEFAULT_PRECISION) + ): col_to_select([PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]), + vol.Optional(CONF_HVAC_MODE_DP): col_to_select(dps, is_dps=True), + vol.Optional( + CONF_HVAC_MODE_SET, default=HVAC_MODE_SETS + ): selector.ObjectSelector(), + vol.Optional(CONF_HVAC_ACTION_DP): col_to_select(dps, is_dps=True), + vol.Optional( + CONF_HVAC_ACTION_SET, default=HVAC_ACTION_SETS + ): selector.ObjectSelector(), + vol.Optional(CONF_ECO_DP): col_to_select(dps, is_dps=True), 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, default=PRECISION_WHOLE): vol.In( - [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] - ), + vol.Optional(CONF_PRESET_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_PRESET_SET, default={}): selector.ObjectSelector(), + vol.Optional(CONF_FAN_SPEED_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_FAN_SPEED_LIST, default=FAN_SPEEDS_DEFAULT): str, + vol.Optional(CONF_TEMPERATURE_UNIT): col_to_select(SUPPORTED_TEMPERATURES), vol.Optional(CONF_HEURISTIC_ACTION): bool, } -class LocaltuyaClimate(LocalTuyaEntity, ClimateEntity): +# Convertors +def f_to_c(num): + return (num - 32) * 5 / 9 if num else num + + +def c_to_f(num): + return (num * 1.8) + 32 if num else num + + +def config_unit(unit): + if unit == SupportedTemps.F: + return UnitOfTemperature.FAHRENHEIT + else: + return UnitOfTemperature.CELSIUS + + +class LocalTuyaClimate(LocalTuyaEntity, ClimateEntity): """Tuya climate device.""" + _enable_turn_on_off_backwards_compatibility = False + def __init__( self, device, @@ -190,59 +188,78 @@ def __init__( switchid, **kwargs, ): - """Initialize a new LocaltuyaClimate.""" + """Initialize a new LocalTuyaClimate.""" super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) self._state = None self._target_temperature = None + self._target_temp_forced_to_celsius = False self._current_temperature = None self._hvac_mode = None - self._fan_mode = None - self._swing_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_hvac_fan_mode_dp = self._config.get(CONF_HVAC_FAN_MODE_DP) - self._conf_hvac_fan_mode_set = HVAC_FAN_MODE_SETS.get( - self._config.get(CONF_HVAC_FAN_MODE_SET), {} + self._precision = float(self._config.get(CONF_PRECISION, DEFAULT_PRECISION)) + self._precision_target = float( + self._config.get(CONF_TARGET_PRECISION, DEFAULT_PRECISION) ) - self._conf_hvac_swing_mode_dp = self._config.get(CONF_HVAC_SWING_MODE_DP) - self._conf_hvac_swing_mode_set = HVAC_SWING_MODE_SETS.get( - self._config.get(CONF_HVAC_SWING_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), {}) + + # HVAC Modes + self._hvac_mode_dp = self._config.get(CONF_HVAC_MODE_DP) + if modes_set := self._config.get(CONF_HVAC_MODE_SET, {}): + # HA HVAC Modes are all lower case. + modes_set = {k.lower(): v for k, v in modes_set.copy().items()} + self._hvac_mode_set = modes_set + + # Presets + self._preset_dp = self._config.get(CONF_PRESET_DP) + self._preset_set: dict = self._config.get(CONF_PRESET_SET, {}) + + # Sort Modes If the HVAC isn't supported by HA then we add it as preset. + if self._preset_dp == self._hvac_mode_dp or not self._preset_dp: + for k, v in self._hvac_mode_set.copy().items(): + if k not in HVACMode: + self._preset_dp = self._hvac_mode_dp + self._preset_set[k] = self._hvac_mode_set.pop(k) + + self._preset_name_to_value = {v: k for k, v in self._preset_set.items()} + + # HVAC Actions 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 - ) - _LOGGER.debug("Initialized climate [%s]", self.name) + if actions_set := self._config.get(CONF_HVAC_ACTION_SET, {}): + actions_set = {k.lower(): v for k, v in actions_set.copy().items()} + self._conf_hvac_action_set = actions_set + + # Fan + self._fan_speed_dp = self._config.get(CONF_FAN_SPEED_DP) + if fan_speeds := self._config.get(CONF_FAN_SPEED_LIST, []): + fan_speeds = [v.lstrip() for v in fan_speeds.split(",")] + self._fan_supported_speeds = fan_speeds + self._has_fan_mode = self._fan_speed_dp and self._fan_supported_speeds + + # Eco!? + self._eco_dp = self._config.get(CONF_ECO_DP) + self._eco_value = self._config.get(CONF_ECO_VALUE, "ECO") + self._has_presets = self._eco_dp or (self._preset_dp and self._preset_set) + + self._min_temp = self._config.get(CONF_MIN_TEMP, DEFAULT_MIN_TEMP) + self._max_temp = self._config.get(CONF_MAX_TEMP, DEFAULT_MAX_TEMP) + + # Temperture unit + self._temperature_unit = UnitOfTemperature.CELSIUS @property def supported_features(self): """Flag supported features.""" - supported_features = ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + supported_features = ClimateEntityFeature(0) if self.has_config(CONF_TARGET_TEMPERATURE_DP): - supported_features = supported_features | ClimateEntityFeature.TARGET_TEMPERATURE - if self.has_config(CONF_MAX_TEMP_DP): - supported_features = supported_features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - if self.has_config(CONF_PRESET_DP) or self.has_config(CONF_ECO_DP): - supported_features = supported_features | ClimateEntityFeature.PRESET_MODE - if self.has_config(CONF_HVAC_FAN_MODE_DP) and self.has_config(CONF_HVAC_FAN_MODE_SET): - supported_features = supported_features | ClimateEntityFeature.FAN_MODE - if self.has_config(CONF_HVAC_SWING_MODE_DP): - supported_features = supported_features | ClimateEntityFeature.SWING_MODE + supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + if self._has_presets: + supported_features |= ClimateEntityFeature.PRESET_MODE + if self._has_fan_mode: + supported_features |= ClimateEntityFeature.FAN_MODE + + supported_features |= ClimateEntityFeature.TURN_OFF + supported_features |= ClimateEntityFeature.TURN_ON + return supported_features @property @@ -250,50 +267,71 @@ def precision(self): """Return the precision of the system.""" return self._precision - @property - def target_precision(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 UnitOfTemperature.FAHRENHEIT - return UnitOfTemperature.CELSIUS + return self._temperature_unit + + @property + def min_temp(self): + """Return the minimum temperature.""" + # DEFAULT_MIN_TEMP is in C + return self._min_temp + + @property + def max_temp(self): + """Return the maximum temperature.""" + # DEFAULT_MAX_TEMP is in C + return self._max_temp @property def hvac_mode(self): """Return current operation ie. heat, cool, idle.""" + if not self._state: + return HVACMode.OFF + if not self._hvac_mode_dp: + return HVACMode.HEAT + 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) + [HVACMode.OFF] + return [HVACMode.OFF] + + modes = list(self._hvac_mode_set) + + if self._config.get(CONF_HVAC_ADD_OFF, True) and HVACMode.OFF not in modes: + modes.append(HVACMode.OFF) + return modes @property def hvac_action(self): - """Return the current running hvac operation if supported. + """Return the current running hvac operation if supported.""" + if not self._state: + return HVACAction.OFF - Need to be one of CURRENT_HVAC_*. - """ + if not self._conf_hvac_action_dp: + if self._hvac_mode == HVACMode.COOL: + self._hvac_action = HVACAction.COOLING + if self._hvac_mode == HVACMode.HEAT: + self._hvac_action = HVACAction.HEATING + if self._hvac_mode == HVACMode.DRY: + self._hvac_action = HVACAction.DRYING + + # This exists from upstream, not sure the use case of this. if self._config.get(CONF_HEURISTIC_ACTION, False): if self._hvac_mode == HVACMode.HEAT: if self._current_temperature < ( self._target_temperature - self._precision ): - self._hvac_action = HVACAction.HEATING + self._hvac_action = HVACMode.HEAT if self._current_temperature == ( self._target_temperature - self._precision ): - if self._hvac_action == HVACAction.HEATING: - self._hvac_action = HVACAction.HEATING + if self._hvac_action == HVACMode.HEAT: + self._hvac_action = HVACMode.HEAT if self._hvac_action == HVACAction.IDLE: self._hvac_action = HVACAction.IDLE if ( @@ -306,6 +344,10 @@ def hvac_action(self): @property def preset_mode(self): """Return current preset.""" + mode = self.dp_value(CONF_HVAC_MODE_DP) + if mode in list(self._hvac_mode_set.values()): + return None + return self._preset_mode @property @@ -313,8 +355,9 @@ 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 = list(self._preset_set.values()) + if self._eco_dp: presets.append(PRESET_ECO) return presets @@ -331,76 +374,54 @@ def target_temperature(self): @property def target_temperature_step(self): """Return the supported step of target temperature.""" - return self._config.get(CONF_TEMPERATURE_STEP, DEFAULT_TEMPERATURE_STEP) + target_step = self._config.get(CONF_TEMPERATURE_STEP, DEFAULT_TEMPERATURE_STEP) + return float(target_step) @property def fan_mode(self): """Return the fan setting.""" - return self._fan_mode - - @property - def fan_modes(self): - """Return the list of available fan modes.""" - if not self.has_config(CONF_HVAC_FAN_MODE_DP): + if not (fan_value := self.dp_value(self._fan_speed_dp)): return None - return list(self._conf_hvac_fan_mode_set) - - @property - def swing_mode(self): - """Return the swing setting.""" - return self._swing_mode + return fan_value @property - def swing_modes(self): - """Return the list of available swing modes.""" - if not self.has_config(CONF_HVAC_SWING_MODE_DP): - return None - return list(self._conf_hvac_swing_mode_set) + def fan_modes(self) -> list: + """Return the list of available fan modes.""" + return self._fan_supported_speeds 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) + temperature = kwargs[ATTR_TEMPERATURE] + + if self._target_temp_forced_to_celsius: + # Revert temperture to Fahrenheit it was forced to celsius + temperature = round(c_to_f(temperature)) + + temperature = round(temperature / self._precision_target) await self._device.set_dp( temperature, self._config[CONF_TARGET_TEMPERATURE_DP] ) async def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" - if self._conf_hvac_fan_mode_dp is None: - _LOGGER.error("Fan speed unsupported (no DP)") - return - if fan_mode not in self._conf_hvac_fan_mode_set: - _LOGGER.error("Unsupported fan_mode: %s" % fan_mode) - return - await self._device.set_dp( - self._conf_hvac_fan_mode_set[fan_mode], self._conf_hvac_fan_mode_dp - ) + if not self._state: + await self._device.set_dp(True, self._dp_id) - async def async_set_hvac_mode(self, hvac_mode): + await self._device.set_dp(fan_mode, self._fan_speed_dp) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode): """Set new target operation mode.""" - if hvac_mode == HVACMode.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 - ) + new_states = {} + if not self._state: + new_states[self._dp_id] = True + elif hvac_mode == HVACMode.OFF and HVACMode.OFF not in self._hvac_mode_set: + new_states[self._dp_id] = False - async def async_set_swing_mode(self, swing_mode): - """Set new target swing operation.""" - if self._conf_hvac_swing_mode_dp is None: - _LOGGER.error("Swing mode unsupported (no DP)") - return - if swing_mode not in self._conf_hvac_swing_mode_set: - _LOGGER.error("Unsupported swing_mode: %s" % swing_mode) - return - await self._device.set_dp( - self._conf_hvac_swing_mode_set[swing_mode], self._conf_hvac_swing_mode_dp - ) + if hvac_mode in self._hvac_mode_set: + new_states[self._hvac_mode_dp] = self._hvac_mode_set[hvac_mode] + + await self._device.set_dps(new_states) async def async_turn_on(self) -> None: """Turn the entity on.""" @@ -413,92 +434,75 @@ async def async_turn_off(self) -> None: 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) + await self._device.set_dp(self._eco_value, self._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 self._config[CONF_TEMP_MIN] - @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 self._config[CONF_TEMP_MAX] + preset_value = self._preset_name_to_value.get(preset_mode) + await self._device.set_dp(preset_value, self._preset_dp) def status_updated(self): """Device status was updated.""" - self._state = self.dps(self._dp_id) + self._state = self.dp_value(self._dp_id) - if self.has_config(CONF_TARGET_TEMPERATURE_DP): - self._target_temperature = ( - self.dps_conf(CONF_TARGET_TEMPERATURE_DP) * self._target_precision - ) + # Update target temperature + if self.has_config(CONF_TARGET_TEMPERATURE_DP) and ( + target_dp_value := self.dp_value(CONF_TARGET_TEMPERATURE_DP) + ): + self._target_temperature = target_dp_value * self._precision_target - if self.has_config(CONF_CURRENT_TEMPERATURE_DP): - self._current_temperature = ( - self.dps_conf(CONF_CURRENT_TEMPERATURE_DP) * self._precision - ) + # Update current temperature + if self.has_config(CONF_CURRENT_TEMPERATURE_DP) and ( + current_dp_temp := self.dp_value(CONF_CURRENT_TEMPERATURE_DP) + ): + self._current_temperature = current_dp_temp * self._precision + + # Force the Current temperature and Target temperature to matching the unit. + config_temp_unit = self._config.get(CONF_TEMPERATURE_UNIT, "") + target_unit, *current_unit = config_temp_unit.split("/") + + if current_unit: + set_temp_unit = UnitOfTemperature.CELSIUS + if target_unit == SupportedTemps.F: + self._target_temperature = f_to_c(self._target_temperature) + if not self._target_temp_forced_to_celsius: + self._target_temp_forced_to_celsius = True + self._min_temp = f_to_c(self._min_temp) + self._max_temp = f_to_c(self._max_temp) + else: + self._current_temperature = f_to_c(self._current_temperature) + else: + set_temp_unit = config_unit(config_temp_unit) + self._temperature_unit = set_temp_unit + # Update preset states if self._has_presets: - if ( - self.has_config(CONF_ECO_DP) - and self.dps_conf(CONF_ECO_DP) == self._conf_eco_value - ): + if self.dp_value(CONF_ECO_DP) == self._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 + for preset_value, preset_name in self._preset_set.items(): + if self.dp_value(CONF_PRESET_DP) == preset_value: + self._preset_mode = preset_name break else: self._preset_mode = PRESET_NONE - # Update the HVAC status + # If device is off there is no needs to check the states. + if not self._state: + return + + # Update the HVAC Mode if self.has_config(CONF_HVAC_MODE_DP): - if not self._state: - self._hvac_mode = HVACMode.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 = HVACMode.AUTO - - # Update the fan status - if self.has_config(CONF_HVAC_FAN_MODE_DP): - for mode, value in self._conf_hvac_fan_mode_set.items(): - if self.dps_conf(CONF_HVAC_FAN_MODE_DP) == value: - self._fan_mode = mode + for ha_hvac, tuya_value in self._hvac_mode_set.items(): + if self.dp_value(CONF_HVAC_MODE_DP) == tuya_value: + self._hvac_mode = ha_hvac break - else: - # in case fan mode and preset share the same dp - _LOGGER.debug("Unknown fan mode %s" % self.dps_conf(CONF_HVAC_FAN_MODE_DP)) - self._fan_mode = FAN_AUTO - - # Update the swing status - if self.has_config(CONF_HVAC_SWING_MODE_DP): - for mode, value in self._conf_hvac_swing_mode_set.items(): - if self.dps_conf(CONF_HVAC_SWING_MODE_DP) == value: - self._swing_mode = mode - break - else: - _LOGGER.debug("Unknown swing mode %s" % self.dps_conf(CONF_HVAC_SWING_MODE_DP)) - self._swing_mode = SWING_OFF # 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 + if self.has_config(CONF_HVAC_ACTION_DP): + for ha_action, tuya_value in self._conf_hvac_action_set.items(): + if self.dp_value(CONF_HVAC_ACTION_DP) == tuya_value: + self._hvac_action = ha_action + break -async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaClimate, flow_schema) +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaClimate, flow_schema) diff --git a/custom_components/localtuya/cloud_api.py b/custom_components/localtuya/cloud_api.py deleted file mode 100644 index a0c51285..00000000 --- a/custom_components/localtuya/cloud_api.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Class to perform requests to Tuya Cloud APIs.""" -import functools -import hashlib -import hmac -import json -import logging -import time - -import requests - -_LOGGER = logging.getLogger(__name__) - - -# Signature algorithm. -def calc_sign(msg, key): - """Calculate signature for request.""" - sign = ( - hmac.new( - msg=bytes(msg, "latin-1"), - key=bytes(key, "latin-1"), - digestmod=hashlib.sha256, - ) - .hexdigest() - .upper() - ) - return sign - - -class TuyaCloudApi: - """Class to send API calls.""" - - def __init__(self, hass, region_code, client_id, secret, user_id): - """Initialize the class.""" - self._hass = hass - self._base_url = f"https://openapi.tuya{region_code}.com" - self._client_id = client_id - self._secret = secret - self._user_id = user_id - self._access_token = "" - self.device_list = {} - - def generate_payload(self, method, timestamp, url, headers, body=None): - """Generate signed payload for requests.""" - payload = self._client_id + self._access_token + timestamp - - payload += method + "\n" - # Content-SHA256 - payload += hashlib.sha256(bytes((body or "").encode("utf-8"))).hexdigest() - payload += ( - "\n" - + "".join( - [ - "%s:%s\n" % (key, headers[key]) # Headers - for key in headers.get("Signature-Headers", "").split(":") - if key in headers - ] - ) - + "\n/" - + url.split("//", 1)[-1].split("/", 1)[-1] # Url - ) - # _LOGGER.debug("PAYLOAD: %s", payload) - return payload - - async def async_make_request(self, method, url, body=None, headers={}): - """Perform requests.""" - timestamp = str(int(time.time() * 1000)) - payload = self.generate_payload(method, timestamp, url, headers, body) - default_par = { - "client_id": self._client_id, - "access_token": self._access_token, - "sign": calc_sign(payload, self._secret), - "t": timestamp, - "sign_method": "HMAC-SHA256", - } - full_url = self._base_url + url - # _LOGGER.debug("\n" + method + ": [%s]", full_url) - - if method == "GET": - func = functools.partial( - requests.get, full_url, headers=dict(default_par, **headers) - ) - elif method == "POST": - func = functools.partial( - requests.post, - full_url, - headers=dict(default_par, **headers), - data=json.dumps(body), - ) - # _LOGGER.debug("BODY: [%s]", body) - elif method == "PUT": - func = functools.partial( - requests.put, - full_url, - headers=dict(default_par, **headers), - data=json.dumps(body), - ) - - resp = await self._hass.async_add_executor_job(func) - # r = json.dumps(r.json(), indent=2, ensure_ascii=False) # Beautify the format - return resp - - async def async_get_access_token(self): - """Obtain a valid access token.""" - try: - resp = await self.async_make_request("GET", "/v1.0/token?grant_type=1") - except requests.exceptions.ConnectionError: - return "Request failed, status ConnectionError" - - if not resp.ok: - return "Request failed, status " + str(resp.status) - - r_json = resp.json() - if not r_json["success"]: - return f"Error {r_json['code']}: {r_json['msg']}" - - self._access_token = resp.json()["result"]["access_token"] - return "ok" - - async def async_get_devices_list(self): - """Obtain the list of devices associated to a user.""" - resp = await self.async_make_request( - "GET", url=f"/v1.0/users/{self._user_id}/devices" - ) - - if not resp.ok: - return "Request failed, status " + str(resp.status) - - r_json = resp.json() - if not r_json["success"]: - # _LOGGER.debug( - # "Request failed, reply is %s", - # json.dumps(r_json, indent=2, ensure_ascii=False) - # ) - return f"Error {r_json['code']}: {r_json['msg']}" - - self.device_list = {dev["id"]: dev for dev in r_json["result"]} - # _LOGGER.debug("DEV_LIST: %s", self.device_list) - - return "ok" diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py deleted file mode 100644 index 30f5e74b..00000000 --- a/custom_components/localtuya/common.py +++ /dev/null @@ -1,607 +0,0 @@ -"""Code shared between all platforms.""" -import asyncio -import json.decoder -import logging -import time -from datetime import timedelta - -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_DEVICES, - CONF_ENTITIES, - CONF_FRIENDLY_NAME, - CONF_HOST, - CONF_ID, - CONF_PLATFORM, - CONF_SCAN_INTERVAL, - STATE_UNKNOWN, -) -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.restore_state import RestoreEntity - -from . import pytuya -from .const import ( - ATTR_STATE, - ATTR_UPDATED_AT, - CONF_DEFAULT_VALUE, - CONF_ENABLE_DEBUG, - CONF_LOCAL_KEY, - CONF_MODEL, - CONF_PASSIVE_ENTITY, - CONF_PROTOCOL_VERSION, - CONF_RESET_DPIDS, - CONF_RESTORE_ON_RECONNECT, - DATA_CLOUD, - DOMAIN, - TUYA_DEVICES, -) - -_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 = [] - - 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. - """ - entities = [] - - for dev_id in config_entry.data[CONF_DEVICES]: - # entities_to_setup = prepare_setup_entities( - # hass, config_entry.data[dev_id], domain - # ) - dev_entry = config_entry.data[CONF_DEVICES][dev_id] - entities_to_setup = [ - entity - for entity in dev_entry[CONF_ENTITIES] - if entity[CONF_PLATFORM] == domain - ] - - if entities_to_setup: - - tuyainterface = hass.data[DOMAIN][TUYA_DEVICES][dev_id] - - dps_config_fields = list(get_dps_for_platform(flow_schema)) - - for entity_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 entity_config: - tuyainterface.dps_to_request[entity_config[dp_conf]] = None - - entities.append( - entity_class( - tuyainterface, - dev_entry, - entity_config[CONF_ID], - ) - ) - # Once the entities have been created, add to the TuyaDevice instance - tuyainterface.add_entities(entities) - 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[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 device_id in entry.data.get(CONF_DEVICES, []): - return entry - # else: - # _LOGGER.warning(f"Missing device configuration for device_id {device_id}") - return None - - -class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): - """Cache wrapper for pytuya.TuyaInterface.""" - - def __init__(self, hass, config_entry, dev_id): - """Initialize the cache.""" - super().__init__() - self._hass = hass - self._config_entry = config_entry - self._dev_config_entry = config_entry.data[CONF_DEVICES][dev_id].copy() - 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._entities = [] - self._local_key = self._dev_config_entry[CONF_LOCAL_KEY] - self._default_reset_dpids = None - if CONF_RESET_DPIDS in self._dev_config_entry: - reset_ids_str = self._dev_config_entry[CONF_RESET_DPIDS].split(",") - - self._default_reset_dpids = [] - for reset_id in reset_ids_str: - self._default_reset_dpids.append(int(reset_id.strip())) - - self.set_logger(_LOGGER, self._dev_config_entry[CONF_DEVICE_ID]) - - # This has to be done in case the device type is type_0d - for entity in self._dev_config_entry[CONF_ENTITIES]: - self.dps_to_request[entity[CONF_ID]] = None - - def add_entities(self, entities): - """Set the entities associated with this device.""" - self._entities.extend(entities) - - @property - def is_connecting(self): - """Return whether device is currently connecting.""" - return self._connect_task is not 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.""" - # self.info("async_connect: %d %r %r", self._is_closing, self._connect_task, self._interface) - 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.info("Trying to connect to %s...", self._dev_config_entry[CONF_HOST]) - - try: - self._interface = await pytuya.connect( - self._dev_config_entry[CONF_HOST], - self._dev_config_entry[CONF_DEVICE_ID], - self._local_key, - float(self._dev_config_entry[CONF_PROTOCOL_VERSION]), - self._dev_config_entry.get(CONF_ENABLE_DEBUG, False), - self, - ) - self._interface.add_dps_to_request(self.dps_to_request) - except Exception as ex: # pylint: disable=broad-except - self.warning( - f"Failed to connect to {self._dev_config_entry[CONF_HOST]}: %s", ex - ) - if self._interface is not None: - await self._interface.close() - self._interface = None - - if self._interface is not None: - try: - try: - self.debug("Retrieving initial state") - status = await self._interface.status() - if status is None: - raise Exception("Failed to retrieve status") - - self._interface.start_heartbeat() - self.status_updated(status) - - except Exception as ex: - if (self._default_reset_dpids is not None) and ( - len(self._default_reset_dpids) > 0 - ): - self.debug( - "Initial state update failed, trying reset command " - + "for DP IDs: %s", - self._default_reset_dpids, - ) - await self._interface.reset(self._default_reset_dpids) - - self.debug("Update completed, retrying initial state") - status = await self._interface.status() - if status is None or not status: - raise Exception("Failed to retrieve status") from ex - - self._interface.start_heartbeat() - self.status_updated(status) - else: - self.error("Initial state update failed, giving up: %r", ex) - if self._interface is not None: - await self._interface.close() - self._interface = None - - except (UnicodeDecodeError, json.decoder.JSONDecodeError) as ex: - self.warning("Initial state update failed (%s), trying key update", ex) - await self.update_local_key() - - if self._interface is not None: - await self._interface.close() - self._interface = None - - if self._interface is not None: - # Attempt to restore status for all entities that need to first set - # the DPS value before the device will respond with status. - for entity in self._entities: - await entity.restore_state_when_connected() - - def _new_entity_handler(entity_id): - self.debug( - "New entity %s was added to %s", - entity_id, - self._dev_config_entry[CONF_HOST], - ) - self._dispatch_status() - - signal = f"localtuya_entity_{self._dev_config_entry[CONF_DEVICE_ID]}" - self._disconnect_task = async_dispatcher_connect( - self._hass, signal, _new_entity_handler - ) - - if ( - CONF_SCAN_INTERVAL in self._dev_config_entry - and int(self._dev_config_entry[CONF_SCAN_INTERVAL]) > 0 - ): - self._unsub_interval = async_track_time_interval( - self._hass, - self._async_refresh, - timedelta(seconds=int(self._dev_config_entry[CONF_SCAN_INTERVAL])), - ) - - self.info(f"Successfully connected to {self._dev_config_entry[CONF_HOST]}") - - self._connect_task = None - - async def update_local_key(self): - """Retrieve updated local_key from Cloud API and update the config_entry.""" - dev_id = self._dev_config_entry[CONF_DEVICE_ID] - await self._hass.data[DOMAIN][DATA_CLOUD].async_get_devices_list() - cloud_devs = self._hass.data[DOMAIN][DATA_CLOUD].device_list - if dev_id in cloud_devs: - self._local_key = cloud_devs[dev_id].get(CONF_LOCAL_KEY) - new_data = self._config_entry.data.copy() - new_data[CONF_DEVICES][dev_id][CONF_LOCAL_KEY] = self._local_key - new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) - self._hass.config_entries.async_update_entry( - self._config_entry, - data=new_data, - ) - self.info("local_key updated for device %s.", dev_id) - - 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() - self.info( - "Closed connection with device %s.", - self._dev_config_entry[CONF_FRIENDLY_NAME], - ) - - 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 %s", dp_index, str(state)) - else: - self.error( - "Not connected to device %s", self._dev_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._dev_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._dev_config_entry[CONF_DEVICE_ID]}" - async_dispatcher_send(self._hass, signal, self._status) - - @callback - def disconnected(self): - """Device disconnected.""" - signal = f"localtuya_{self._dev_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 - - if self._connect_task is not None: - self._connect_task.cancel() - self._connect_task = None - self.warning("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._dev_config_entry = config_entry - self._config = get_entity_config(config_entry, dp_id) - self._dp_id = dp_id - self._status = {} - self._state = None - self._last_state = None - - # Default value is available to be provided by Platform entities if required - self._default_value = self._config.get(CONF_DEFAULT_VALUE) - - # Determine whether is a passive entity - self._is_passive_entity = self._config.get(CONF_PASSIVE_ENTITY) or False - - """ Restore on connect setting is available to be provided by Platform entities - if required""" - self._restore_on_reconnect = ( - self._config.get(CONF_RESTORE_ON_RECONNECT) or False - ) - self.set_logger(logger, self._dev_config_entry[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() - - # Update HA - self.schedule_update_ha_state() - - signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}" - - self.async_on_remove( - async_dispatcher_connect(self.hass, signal, _update_handler) - ) - - signal = f"localtuya_entity_{self._dev_config_entry[CONF_DEVICE_ID]}" - async_dispatcher_send(self.hass, signal, self.entity_id) - - @property - def extra_state_attributes(self): - """Return entity specific state attributes to be saved. - - These attributes are then available for restore when the - entity is restored at startup. - """ - attributes = {} - if self._state is not None: - attributes[ATTR_STATE] = self._state - elif self._last_state is not None: - attributes[ATTR_STATE] = self._last_state - - self.debug("Entity %s - Additional attributes: %s", self.name, attributes) - return attributes - - @property - def device_info(self): - """Return device information for the device registry.""" - model = self._dev_config_entry.get(CONF_MODEL, "Tuya generic") - return { - "identifiers": { - # Serial numbers are unique identifiers within a specific domain - (DOMAIN, f"local_{self._dev_config_entry[CONF_DEVICE_ID]}") - }, - "name": self._dev_config_entry[CONF_FRIENDLY_NAME], - "manufacturer": "Tuya", - "model": f"{model} ({self._dev_config_entry[CONF_DEVICE_ID]})", - "sw_version": self._dev_config_entry[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._dev_config_entry[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. - """ - state = self.dps(self._dp_id) - self._state = state - - # Keep record in last_state as long as not during connection/re-connection, - # as last state will be used to restore the previous state - if (state is not None) and (not self._device.is_connecting): - self._last_state = state - - def status_restored(self, stored_state): - """Device status was restored. - - Override in subclasses and update entity specific state. - """ - raw_state = stored_state.attributes.get(ATTR_STATE) - if raw_state is not None: - self._last_state = raw_state - self.debug( - "Restoring state for entity: %s - state: %s", - self.name, - str(self._last_state), - ) - - def default_value(self): - """Return default value of this entity. - - Override in subclasses to specify the default value for the entity. - """ - # Check if default value has been set - if not, default to the entity defaults. - if self._default_value is None: - self._default_value = self.entity_default_value() - - return self._default_value - - def entity_default_value(self): # pylint: disable=no-self-use - """Return default value of the entity type. - - Override in subclasses to specify the default value for the entity. - """ - return 0 - - @property - def restore_on_reconnect(self): - """Return whether the last state should be restored on a reconnect. - - Useful where the device loses settings if powered off - """ - return self._restore_on_reconnect - - async def restore_state_when_connected(self): - """Restore if restore_on_reconnect is set, or if no status has been yet found. - - Which indicates a DPS that needs to be set before it starts returning - status. - """ - if (not self.restore_on_reconnect) and ( - (str(self._dp_id) in self._status) or (not self._is_passive_entity) - ): - self.debug( - "Entity %s (DP %d) - Not restoring as restore on reconnect is " - + "disabled for this entity and the entity has an initial status " - + "or it is not a passive entity", - self.name, - self._dp_id, - ) - return - - self.debug("Attempting to restore state for entity: %s", self.name) - # Attempt to restore the current state - in case reset. - restore_state = self._state - - # If no state stored in the entity currently, go from last saved state - if (restore_state == STATE_UNKNOWN) | (restore_state is None): - self.debug("No current state for entity") - restore_state = self._last_state - - # If no current or saved state, then use the default value - if restore_state is None: - if self._is_passive_entity: - self.debug("No last restored state - using default") - restore_state = self.default_value() - else: - self.debug("Not a passive entity and no state found - aborting restore") - return - - self.debug( - "Entity %s (DP %d) - Restoring state: %s", - self.name, - self._dp_id, - str(restore_state), - ) - - # Manually initialise - await self._device.set_dp(restore_state, self._dp_id) diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index 5c87e254..87c82a29 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -1,11 +1,24 @@ """Config flow for LocalTuya integration integration.""" + +import asyncio import errno import logging import time from importlib import import_module +from functools import partial +from collections.abc import Coroutine +from typing import Any +from copy import deepcopy + import homeassistant.helpers.config_validation as cv import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + SelectOptionDict, +) import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.const import ( @@ -15,67 +28,108 @@ CONF_DEVICES, CONF_ENTITIES, CONF_FRIENDLY_NAME, + CONF_ENTITY_CATEGORY, CONF_HOST, + CONF_ICON, CONF_ID, CONF_NAME, CONF_PLATFORM, CONF_REGION, CONF_SCAN_INTERVAL, CONF_USERNAME, + EntityCategory, ) from homeassistant.core import callback -from .cloud_api import TuyaCloudApi -from .common import pytuya +from .coordinator import pytuya, TuyaCloudApi +from .core.cloud_api import TUYA_ENDPOINTS +from .core.helpers import templates, get_gateway_by_deviceid, gen_localtuya_entities from .const import ( ATTR_UPDATED_AT, - CONF_ACTION, CONF_ADD_DEVICE, + CONF_CONFIGURE_CLOUD, CONF_DPS_STRINGS, CONF_EDIT_DEVICE, + CONF_ENABLE_ADD_ENTITIES, CONF_ENABLE_DEBUG, + CONF_GATEWAY_ID, CONF_LOCAL_KEY, CONF_MANUAL_DPS, CONF_MODEL, + CONF_NODE_ID, CONF_NO_CLOUD, + CONF_PRODUCT_KEY, CONF_PRODUCT_NAME, CONF_PROTOCOL_VERSION, CONF_RESET_DPIDS, - CONF_SETUP_CLOUD, + CONF_TUYA_GWID, + CONF_TUYA_IP, + CONF_TUYA_VERSION, CONF_USER_ID, - CONF_ENABLE_ADD_ENTITIES, - DATA_CLOUD, DATA_DISCOVERY, + DEFAULT_CATEGORIES, DOMAIN, + ENTITY_CATEGORY, PLATFORMS, + SUPPORTED_PROTOCOL_VERSIONS, + CONF_DEVICE_SLEEP_TIME, ) from .discovery import discover _LOGGER = logging.getLogger(__name__) -ENTRIES_VERSION = 2 +ENTRIES_VERSION = 4 PLATFORM_TO_ADD = "platform_to_add" +USE_TEMPLATE = "use_template" +TEMPLATES = "templates" NO_ADDITIONAL_ENTITIES = "no_additional_entities" SELECTED_DEVICE = "selected_device" +EXPORT_CONFIG = "export_config" -CUSTOM_DEVICE = "..." +TUYA_CATEGORY = "category" +DEVICE_CLOUD_DATA = "device_cloud_data" -CONF_ACTIONS = { - CONF_ADD_DEVICE: "Add a new device", - CONF_EDIT_DEVICE: "Edit a device", - CONF_SETUP_CLOUD: "Reconfigure Cloud API account", -} +# Using list method so we can translate options. +CONFIGURE_MENU = [CONF_ADD_DEVICE, CONF_EDIT_DEVICE, CONF_CONFIGURE_CLOUD] + + +def col_to_select( + opt_list: dict | list, multi_select=False, is_dps=False, custom_value=False +) -> SelectSelector: + """Convert collections to SelectSelectorConfig.""" + if type(opt_list) == dict: + return SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=str(v), label=k) for k, v in opt_list.items() + ], + mode=SelectSelectorMode.DROPDOWN, + custom_value=custom_value, + multiple=True if multi_select else False, + ) + ) + elif type(opt_list) == list: + # value used the same method as func available_dps_string, no spaces values. + return SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=str(kv).split(" ")[0] if is_dps else str(kv), + label=str(kv), + ) + for kv in opt_list + ], + mode=SelectSelectorMode.DROPDOWN, + custom_value=custom_value, + multiple=True if multi_select else False, + ) + ) -CONFIGURE_SCHEMA = vol.Schema( - { - vol.Required(CONF_ACTION, default=CONF_ADD_DEVICE): vol.In(CONF_ACTIONS), - } -) -CLOUD_SETUP_SCHEMA = vol.Schema( +CLOUD_CONFIGURE_SCHEMA = vol.Schema( { - vol.Required(CONF_REGION, default="eu"): vol.In(["eu", "us", "cn", "in"]), + vol.Required(CONF_REGION, default="eu"): col_to_select(TUYA_ENDPOINTS), vol.Optional(CONF_CLIENT_ID): cv.string, vol.Optional(CONF_CLIENT_SECRET): cv.string, vol.Optional(CONF_USER_ID): cv.string, @@ -84,246 +138,32 @@ } ) - DEVICE_SCHEMA = vol.Schema( { vol.Required(CONF_FRIENDLY_NAME): cv.string, vol.Required(CONF_HOST): cv.string, vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(CONF_LOCAL_KEY): cv.string, - vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In( - ["3.1", "3.2", "3.3", "3.4"] + vol.Required(CONF_PROTOCOL_VERSION, default="auto"): col_to_select( + ["auto"] + sorted(SUPPORTED_PROTOCOL_VERSIONS) ), vol.Required(CONF_ENABLE_DEBUG, default=False): bool, vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): cv.string, vol.Optional(CONF_RESET_DPIDS): str, + vol.Optional(CONF_DEVICE_SLEEP_TIME): int, + vol.Optional(CONF_NODE_ID, default=None): vol.Any(None, cv.string), } ) PICK_ENTITY_SCHEMA = vol.Schema( - {vol.Required(PLATFORM_TO_ADD, default="switch"): vol.In(PLATFORMS)} + {vol.Required(PLATFORM_TO_ADD, default="switch"): col_to_select(PLATFORMS)} ) -def devices_schema(discovered_devices, cloud_devices_list, add_custom_device=True): - """Create schema for devices step.""" - devices = {} - for dev_id, dev_host in discovered_devices.items(): - dev_name = dev_id - if dev_id in cloud_devices_list.keys(): - dev_name = cloud_devices_list[dev_id][CONF_NAME] - devices[dev_id] = f"{dev_name} ({dev_host})" - - if add_custom_device: - devices.update({CUSTOM_DEVICE: CUSTOM_DEVICE}) - - # devices.update( - # { - # ent.data[CONF_DEVICE_ID]: ent.data[CONF_FRIENDLY_NAME] - # for ent in entries - # } - # ) - return vol.Schema({vol.Required(SELECTED_DEVICE): vol.In(devices)}) - - -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): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_LOCAL_KEY): cv.string, - vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In( - ["3.1", "3.2", "3.3", "3.4"] - ), - vol.Required(CONF_ENABLE_DEBUG, default=False): bool, - vol.Optional(CONF_SCAN_INTERVAL): int, - vol.Optional(CONF_MANUAL_DPS): cv.string, - vol.Optional(CONF_RESET_DPIDS): cv.string, - vol.Required( - CONF_ENTITIES, description={"suggested_value": entity_names} - ): cv.multi_select(entity_names), - vol.Required(CONF_ENABLE_ADD_ENTITIES, default=False): bool, - } - ) - - -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 - - reset_ids = None - try: - interface = await pytuya.connect( - data[CONF_HOST], - data[CONF_DEVICE_ID], - data[CONF_LOCAL_KEY], - float(data[CONF_PROTOCOL_VERSION]), - data[CONF_ENABLE_DEBUG], - ) - if CONF_RESET_DPIDS in data: - reset_ids_str = data[CONF_RESET_DPIDS].split(",") - reset_ids = [] - for reset_id in reset_ids_str: - reset_ids.append(int(reset_id.strip())) - _LOGGER.debug( - "Reset DPIDs configured: %s (%s)", - data[CONF_RESET_DPIDS], - reset_ids, - ) - try: - detected_dps = await interface.detect_available_dps() - except Exception as ex: - try: - _LOGGER.debug( - "Initial state update failed (%s), trying reset command", ex - ) - if len(reset_ids) > 0: - await interface.reset(reset_ids) - detected_dps = await interface.detect_available_dps() - except Exception as ex: - _LOGGER.debug("No DPS able to be detected: %s", ex) - detected_dps = {} - - # if manual DPs are set, merge these. - _LOGGER.debug("Detected DPS: %s", detected_dps) - if CONF_MANUAL_DPS in data: - manual_dps_list = [dps.strip() for dps in data[CONF_MANUAL_DPS].split(",")] - _LOGGER.debug( - "Manual DPS Setting: %s (%s)", data[CONF_MANUAL_DPS], manual_dps_list - ) - # merge the lists - for new_dps in manual_dps_list + (reset_ids or []): - # If the DPS not in the detected dps list, then add with a - # default value indicating that it has been manually added - if str(new_dps) not in detected_dps: - detected_dps[new_dps] = -1 - - 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 - - _LOGGER.debug("Total DPS: %s", detected_dps) - - return dps_string_list(detected_dps) - - -async def attempt_cloud_connection(hass, user_input): - """Create device.""" - cloud_api = TuyaCloudApi( - hass, - user_input.get(CONF_REGION), - user_input.get(CONF_CLIENT_ID), - user_input.get(CONF_CLIENT_SECRET), - user_input.get(CONF_USER_ID), - ) - - res = await cloud_api.async_get_access_token() - if res != "ok": - _LOGGER.error("Cloud API connection failed: %s", res) - return cloud_api, {"reason": "authentication_failed", "msg": res} - - res = await cloud_api.async_get_devices_list() - if res != "ok": - _LOGGER.error("Cloud API get_devices_list failed: %s", res) - return cloud_api, {"reason": "device_list_failed", "msg": res} - _LOGGER.info("Cloud API connection succeeded.") - - return cloud_api, {} +CONF_MASS_CONFIGURE = "mass_configure" +MASS_CONFIGURE_SCHEMA = {vol.Optional(CONF_MASS_CONFIGURE, default=False): bool} +CUSTOM_DEVICE = {"Add Device Manually": "..."} class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -356,6 +196,11 @@ async def async_step_user(self, user_input=None): if not res: return await self._create_entry(user_input) errors["base"] = res["reason"] + # 1004 = Secret, 1106 = USER ID, 2009 = Client ID + if "1106" in res["msg"]: + res["msg"] = f"{res['msg']} Check UserID or country code!" + if "1004" in res["msg"]: + res["msg"] = f"{res['msg']} Check Secret Key!" placeholders = {"msg": res["msg"]} defaults = {} @@ -363,7 +208,7 @@ async def async_step_user(self, user_input=None): return self.async_show_form( step_id="user", - data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults), + data_schema=schema_defaults(CLOUD_CONFIGURE_SCHEMA, **defaults), errors=errors, description_placeholders=placeholders, ) @@ -374,6 +219,8 @@ async def _create_entry(self, user_input): # return self.async_abort(reason="already_configured") await self.async_set_unique_id(user_input.get(CONF_USER_ID)) + self._abort_if_unique_id_configured() + user_input[CONF_DEVICES] = {} return self.async_create_entry( @@ -391,52 +238,52 @@ async def async_step_import(self, user_input): class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow): """Handle options flow for LocalTuya integration.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry): """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._entry_id = config_entry.entry_id + self.selected_device = None - self.editing_device = False - self.device_data = None + self.nodeID = None + + self.editing_device: bool = False + self.device_data: dict = None self.dps_strings = [] self.selected_platform = None self.discovered_devices = {} self.entities = [] + self.use_template = False + self.template_device = None + + self.cloud_data: TuyaCloudApi 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: - if user_input.get(CONF_ACTION) == CONF_SETUP_CLOUD: - return await self.async_step_cloud_setup() - if user_input.get(CONF_ACTION) == CONF_ADD_DEVICE: - return await self.async_step_add_device() - if user_input.get(CONF_ACTION) == CONF_EDIT_DEVICE: - return await self.async_step_edit_device() + configure_menu = CONFIGURE_MENU.copy() + # Remove Reconfigure existing device option if there is no existed devices. + if not self.config_entry.data[CONF_DEVICES]: + configure_menu.pop(configure_menu.index(CONF_EDIT_DEVICE)) - return self.async_show_form( - step_id="init", - data_schema=CONFIGURE_SCHEMA, - ) + self.cloud_data = self.hass.data[DOMAIN][self._entry_id].cloud_data + if not self.config_entry.data.get(CONF_NO_CLOUD): + # Refresh devices List data. + self.hass.async_create_task(self.cloud_data.async_get_devices_list()) - async def async_step_cloud_setup(self, user_input=None): + return self.async_show_menu(step_id="init", menu_options=configure_menu) + + async def async_step_configure_cloud(self, user_input=None): """Handle the initial step.""" errors = {} placeholders = {} if user_input is not None: + username = user_input.get(CONF_USERNAME) if user_input.get(CONF_NO_CLOUD): new_data = self.config_entry.data.copy() new_data.update(user_input) for i in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]: new_data[i] = "" - self.hass.config_entries.async_update_entry( - self.config_entry, - data=new_data, - ) - return self.async_create_entry( - title=new_data.get(CONF_USERNAME), data={} - ) + + return self._update_entry(new_data, new_title=username) cloud_api, res = await attempt_cloud_connection(self.hass, user_input) @@ -448,15 +295,9 @@ async def async_step_cloud_setup(self, user_input=None): if CONF_MODEL not in dev and dev_id in cloud_devs: model = cloud_devs[dev_id].get(CONF_PRODUCT_NAME) new_data[CONF_DEVICES][dev_id][CONF_MODEL] = model - new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) - self.hass.config_entries.async_update_entry( - self.config_entry, - data=new_data, - ) - return self.async_create_entry( - title=new_data.get(CONF_USERNAME), data={} - ) + return self._update_entry(new_data, new_title=username) + errors["base"] = res["reason"] placeholders = {"msg": res["msg"]} @@ -465,8 +306,8 @@ async def async_step_cloud_setup(self, user_input=None): defaults[CONF_NO_CLOUD] = False return self.async_show_form( - step_id="cloud_setup", - data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults), + step_id="configure_cloud", + data_schema=schema_defaults(CLOUD_CONFIGURE_SCHEMA, **defaults), errors=errors, description_placeholders=placeholders, ) @@ -478,9 +319,35 @@ async def async_step_add_device(self, user_input=None): self.selected_device = None errors = {} if user_input is not None: - if user_input[SELECTED_DEVICE] != CUSTOM_DEVICE: + if user_input[SELECTED_DEVICE] != CUSTOM_DEVICE["Add Device Manually"]: self.selected_device = user_input[SELECTED_DEVICE] + if user_input.pop(CONF_MASS_CONFIGURE, False): + # Handle auto configure all recognized devices. + devices, fails = await setup_localtuya_devices( + self.hass, + self.config_entry.entry_id, + self.discovered_devices, + self.cloud_data.device_list, + log_fails=True, + ) + if devices: + devices_sucessed, devices_fails = "", "" + for sucess_dev in devices.values(): + devices_sucessed += f"\n{sucess_dev[CONF_FRIENDLY_NAME]}" + for fail_dev in fails.values(): + devices_fails += f"\n{fail_dev['name']}: {fail_dev['reason']}" + + msg = f"Sucessed devices: ``{len(devices)}``\n ```{devices_sucessed}\n```" + if fails: + msg += f" \n Failed devices: ``{len(fails)}``\n ```{devices_fails}\n```" + + return await self.async_step_confirm( + msg=msg, + confirm_callback=self._update_entry, + callback_args=(devices, CONF_DEVICES), + ) + return await self.async_step_configure_device() self.discovered_devices = {} @@ -489,28 +356,31 @@ async def async_step_add_device(self, user_input=None): if data and DATA_DISCOVERY in data: self.discovered_devices = data[DATA_DISCOVERY].devices else: - try: - self.discovered_devices = await discover() - except OSError as ex: - if ex.errno == errno.EADDRINUSE: - errors["base"] = "address_in_use" - else: - errors["base"] = "discovery_failed" - except Exception as ex: - _LOGGER.exception("discovery failed: %s", ex) - errors["base"] = "discovery_failed" + self.discovered_devices, errors = await discover_devices() - devices = { - dev_id: dev["ip"] - for dev_id, dev in self.discovered_devices.items() - if dev["gwId"] not in self.config_entry.data[CONF_DEVICES] - } + allDevices = mergeDevicesList( + self.discovered_devices, self.cloud_data.device_list + ) + + self.discovered_devices = allDevices + devices = {} + # To avoid duplicated entities we will get all devices in every hub. + entries = self.hass.config_entries.async_entries(DOMAIN) + configured_Devices = [] + for entry in entries: + for devID in entry.data[CONF_DEVICES].keys(): + configured_Devices.append(devID) + + for dev_id, dev in allDevices.items(): + if dev_id not in configured_Devices: + if dev.get(CONF_NODE_ID, None) is not None: + devices[dev_id] = "Sub Device" + else: + devices[dev_id] = dev.get(CONF_TUYA_IP, "") return self.async_show_form( step_id="add_device", - data_schema=devices_schema( - devices, self.hass.data[DOMAIN][DATA_CLOUD].device_list - ), + data_schema=devices_schema(devices, self.cloud_data.device_list), errors=errors, ) @@ -524,44 +394,92 @@ async def async_step_edit_device(self, user_input=None): dev_conf = self.config_entry.data[CONF_DEVICES][self.selected_device] self.dps_strings = dev_conf.get(CONF_DPS_STRINGS, gen_dps_strings()) self.entities = dev_conf[CONF_ENTITIES] - return await self.async_step_configure_device() devices = {} for dev_id, configured_dev in self.config_entry.data[CONF_DEVICES].items(): - devices[dev_id] = configured_dev[CONF_HOST] + if configured_dev.get(CONF_NODE_ID, None): + devices[dev_id] = "Sub Device" + else: + devices[dev_id] = configured_dev[CONF_HOST] return self.async_show_form( step_id="edit_device", data_schema=devices_schema( - devices, self.hass.data[DOMAIN][DATA_CLOUD].device_list, False + devices, + self.cloud_data.device_list, + False, + self.config_entry.data[CONF_DEVICES], ), errors=errors, ) + async def async_step_device_setup_method(self, user_input=None): + """Manage basic options.""" + DEVICE_SETUP_METHOD = [ + "auto_configure_device", + "pick_entity_type", + "choose_template", + ] + return self.async_show_menu( + step_id="device_setup_method", + menu_options=DEVICE_SETUP_METHOD, + ) + async def async_step_configure_device(self, user_input=None): """Handle input of basic info.""" errors = {} + placeholders = {} dev_id = self.selected_device + cloud_devs = self.cloud_data.device_list if user_input is not None: try: self.device_data = user_input.copy() + self.selected_device: str = dev_id or user_input.get(CONF_DEVICE_ID) + self.nodeID: str = self.nodeID or user_input.get(CONF_NODE_ID) if dev_id is not None: - # self.device_data[CONF_PRODUCT_KEY] = self.devices[ - # self.selected_device - # ]["productKey"] - cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list if dev_id in cloud_devs: self.device_data[CONF_MODEL] = cloud_devs[dev_id].get( CONF_PRODUCT_NAME ) + # Pulls some of device data that aren't required from user in config_flow. + if device := self.discovered_devices.get(dev_id): + self.device_data[CONF_PRODUCT_KEY] = device.get("productKey") + if gateway_id := device.get(CONF_GATEWAY_ID): + self.device_data[CONF_GATEWAY_ID] = gateway_id + + # Handle Inputs on edit device mode. if self.editing_device: - if user_input[CONF_ENABLE_ADD_ENTITIES]: + dev_config: dict = self.config_entry.data[CONF_DEVICES].get( + dev_id, {} + ) + if self.device_data.pop(EXPORT_CONFIG, False): + dev_config = self.config_entry.data[CONF_DEVICES][dev_id].copy() + await self.hass.async_add_executor_job( + templates.export_config, + dev_config, + self.device_data[CONF_FRIENDLY_NAME], + ) + return self.async_create_entry(title="", data={}) + # We will restore device details if it's already existed! + for res_conf in [CONF_GATEWAY_ID, CONF_MODEL, CONF_PRODUCT_KEY]: + if dev_config.get(res_conf): + self.device_data[res_conf] = dev_config.get(res_conf) + # Remove the values that assigned as "- or empty space" + for rm_conf in [CONF_RESET_DPIDS, CONF_MANUAL_DPS]: + if rm_conf in user_input and user_input[rm_conf] in ["-", " "]: + self.device_data.pop(rm_conf) + + self.dps_strings = merge_dps_manual_strings( + self.device_data.get(CONF_MANUAL_DPS, ""), self.dps_strings + ) + if self.device_data.pop(CONF_ENABLE_ADD_ENTITIES, False): self.editing_device = False user_input[CONF_DEVICE_ID] = dev_id self.device_data.update( { CONF_DEVICE_ID: dev_id, + CONF_NODE_ID: self.nodeID, CONF_DPS_STRINGS: self.dps_strings, } ) @@ -570,46 +488,71 @@ async def async_step_configure_device(self, user_input=None): self.device_data.update( { CONF_DEVICE_ID: dev_id, + CONF_NODE_ID: self.nodeID, CONF_DPS_STRINGS: self.dps_strings, CONF_ENTITIES: [], } ) + if len(user_input[CONF_ENTITIES]) == 0: - return self.async_abort( - reason="no_entities", - description_placeholders={}, - ) + # If user unchecked all entities. + return self.async_abort(reason="no_entities") + if user_input[CONF_ENTITIES]: entity_ids = [ - int(entity.split(":")[0]) - for entity in user_input[CONF_ENTITIES] + int(e.split(":")[0]) for e in user_input[CONF_ENTITIES] ] - device_config = self.config_entry.data[CONF_DEVICES][dev_id] + if self.use_template: + device_config = self.template_device + else: + device_config = self.config_entry.data[CONF_DEVICES][dev_id] self.entities = [ entity for entity in device_config[CONF_ENTITIES] - if entity[CONF_ID] in entity_ids + if int(entity[CONF_ID]) in entity_ids ] return await self.async_step_configure_entity() - self.dps_strings = await validate_input(self.hass, user_input) - return await self.async_step_pick_entity_type() + valid_data = await validate_input( + self.hass, self.config_entry.entry_id, user_input + ) + self.dps_strings = valid_data[CONF_DPS_STRINGS] + # We will also get protocol version from valid date in case auto used. + self.device_data[CONF_PROTOCOL_VERSION] = valid_data[ + CONF_PROTOCOL_VERSION + ] + + return await self.async_step_device_setup_method() + # 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 as ex: - _LOGGER.exception("Unexpected exception: %s", ex) + except (OSError, ValueError, pytuya.DecodeError) as ex: + _LOGGER.debug("Unexpected exception: %s", ex) + placeholders["ex"] = str(ex) errors["base"] = "unknown" + except Exception as ex: + _LOGGER.debug("Unexpected exception: %s", ex) + raise ex defaults = {} if self.editing_device: # If selected device exists as a config entry, load config from it - defaults = self.config_entry.data[CONF_DEVICES][dev_id].copy() - cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list - placeholders = {"for_device": f" for device `{dev_id}`"} + defaults = ( + self.device_data + if self.use_template + else self.config_entry.data[CONF_DEVICES][dev_id].copy() + ) + + self.nodeID = defaults.get(CONF_NODE_ID, None) + placeholders["for_device"] = f" for device `{dev_id}`" + if self.nodeID: + placeholders.update( + {"for_device": f"for Sub-Device `{dev_id}.NodeID {self.nodeID}`"} + ) if dev_id in cloud_devs: cloud_local_key = cloud_devs[dev_id].get(CONF_LOCAL_KEY) if defaults[CONF_LOCAL_KEY] != cloud_local_key: @@ -621,27 +564,39 @@ async def async_step_configure_device(self, user_input=None): defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY) note = "\nNOTE: a new local_key has been retrieved using cloud API" placeholders = {"for_device": f" for device `{dev_id}`.{note}"} - defaults[CONF_ENABLE_ADD_ENTITIES] = False + if self.nodeID: + placeholders = { + "for_device": f" for sub-device `{dev_id}.\nNodeID {self.nodeID}.{note}`" + } schema = schema_defaults(options_schema(self.entities), **defaults) else: - defaults[CONF_PROTOCOL_VERSION] = "3.3" - defaults[CONF_HOST] = "" - defaults[CONF_DEVICE_ID] = "" - defaults[CONF_LOCAL_KEY] = "" - defaults[CONF_FRIENDLY_NAME] = "" - if dev_id is not None: + # user_in will restore input if an error occurred instead of clears all fields. + user_in = user_input or {} + defaults[CONF_PROTOCOL_VERSION] = user_in.get(CONF_PROTOCOL_VERSION, "auto") + defaults[CONF_HOST] = user_in.get(CONF_HOST, "") + defaults[CONF_DEVICE_ID] = user_in.get(CONF_DEVICE_ID, "") + defaults[CONF_LOCAL_KEY] = user_in.get(CONF_LOCAL_KEY, "") + defaults[CONF_FRIENDLY_NAME] = user_in.get(CONF_FRIENDLY_NAME, "") + defaults[CONF_NODE_ID] = user_in.get(CONF_NODE_ID, "") + + if defaults[CONF_DEVICE_ID] in [cloud_devs, self.selected_device]: + dev_id = defaults[CONF_DEVICE_ID] + + if dev_id is not None and dev_id in self.discovered_devices: # Insert default values from discovery and cloud if present - device = self.discovered_devices[dev_id] - defaults[CONF_HOST] = device.get("ip") - defaults[CONF_DEVICE_ID] = device.get("gwId") - defaults[CONF_PROTOCOL_VERSION] = device.get("version") - cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list - if dev_id in cloud_devs: - defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY) - defaults[CONF_FRIENDLY_NAME] = cloud_devs[dev_id].get(CONF_NAME) + device = self.discovered_devices.get(dev_id, {}) + defaults[CONF_HOST] = device.get(CONF_TUYA_IP) + defaults[CONF_DEVICE_ID] = device.get(CONF_TUYA_GWID) + defaults[CONF_PROTOCOL_VERSION] = device.get(CONF_TUYA_VERSION) + defaults[CONF_NODE_ID] = device.get(CONF_NODE_ID, None) + + if dev_id in cloud_devs: + defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY) + defaults[CONF_FRIENDLY_NAME] = cloud_devs[dev_id].get(CONF_NAME) + schema = schema_defaults(DEVICE_SCHEMA, **defaults) - placeholders = {"for_device": ""} + placeholders["for_device"] = "" return self.async_show_form( step_id="configure_device", @@ -650,6 +605,53 @@ async def async_step_configure_device(self, user_input=None): description_placeholders=placeholders, ) + async def async_step_auto_configure_device(self, user_input=None): + """Handle asking which templates to use""" + + errors = {} + placeholders = {} + + # Gather the informations + is_cloud = not self.config_entry.data.get(CONF_NO_CLOUD) + dev_id = self.selected_device + category = None + node_id = self.nodeID + device_data = self.cloud_data.device_list.get(dev_id) + if device_data: + category = self.cloud_data.device_list[dev_id].get(TUYA_CATEGORY, "") + + localtuya_data = { + DEVICE_CLOUD_DATA: device_data, + CONF_DPS_STRINGS: self.dps_strings, + CONF_FRIENDLY_NAME: self.device_data.get(CONF_FRIENDLY_NAME), + } + + dev_data = gen_localtuya_entities(localtuya_data, category) + + # Process to add the device to localtuya HA Config. + if dev_data: + self.entities = dev_data + return await self.async_step_pick_entity_type( + {NO_ADDITIONAL_ENTITIES: True} + ) + + if not is_cloud: + err_msg = f"This feature requires cloud API setup for now" + elif not device_data: + err_msg = f"Couldn't find your device in the cloud account you using" + elif not category: + err_msg = f"Your device category isn't supported" + elif not dev_data: + err_msg = f"Couldn't find the data for your device category: {category}." + + placeholders = {"err_msg": err_msg} + + return self.async_show_menu( + step_id="auto_configure_device", + menu_options=["device_setup_method"], + description_placeholders=placeholders, + ) + 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: @@ -663,14 +665,11 @@ async def async_step_pick_entity_type(self, user_input=None): dev_id = self.device_data.get(CONF_DEVICE_ID) new_data = self.config_entry.data.copy() - new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) new_data[CONF_DEVICES].update({dev_id: config}) + return self._update_entry(new_data) - self.hass.config_entries.async_update_entry( - self.config_entry, - data=new_data, - ) - return self.async_create_entry(title="", data={}) + if user_input.get(USE_TEMPLATE): + return await self.async_step_choose_template() self.selected_platform = user_input[PLATFORM_TO_ADD] return await self.async_step_configure_entity() @@ -685,15 +684,31 @@ async def async_step_pick_entity_type(self, user_input=None): return self.async_show_form(step_id="pick_entity_type", data_schema=schema) - def available_dps_strings(self): - """Return list of DPs use by the device's entities.""" - available_dps = [] - used_dps = [str(entity[CONF_ID]) for entity in self.entities] - for dp_string in self.dps_strings: - dp = dp_string.split(" ")[0] - if dp not in used_dps: - available_dps.append(dp_string) - return available_dps + async def async_step_choose_template(self, user_input=None): + """Handle asking which templates to use""" + if user_input is not None: + self.use_template = True + filename = user_input.get(TEMPLATES) + _config = await self.hass.async_add_executor_job( + templates.import_config, filename + ) + dev_conf = self.device_data + dev_conf[CONF_ENTITIES] = _config + dev_conf[CONF_DPS_STRINGS] = self.dps_strings + dev_conf[CONF_NODE_ID] = self.nodeID + self.device_data = dev_conf + + self.entities = dev_conf[CONF_ENTITIES] + self.template_device = self.device_data + self.editing_device = True + return await self.async_step_configure_device() + templates_list = await self.hass.async_add_executor_job( + templates.list_templates + ) + schema = vol.Schema( + {vol.Required(TEMPLATES): col_to_select(templates_list, custom_value=True)} + ) + return self.async_show_form(step_id="choose_template", data_schema=schema) async def async_step_entity(self, user_input=None): """Manage entity settings.""" @@ -703,17 +718,11 @@ async def async_step_entity(self, user_input=None): entity[CONF_ID] = self.current_entity[CONF_ID] entity[CONF_PLATFORM] = self.current_entity[CONF_PLATFORM] self.device_data[CONF_ENTITIES].append(entity) - if len(self.entities) == len(self.device_data[CONF_ENTITIES]): - self.hass.config_entries.async_update_entry( - self.config_entry, - title=self.device_data[CONF_FRIENDLY_NAME], - data=self.device_data, - ) - return self.async_create_entry(title="", data={}) + return self._update_entry(self.device_data) - schema = platform_schema( - self.current_entity[CONF_PLATFORM], self.dps_strings, allow_id=False + schema = await platform_schema( + self.hass, self.current_entity[CONF_PLATFORM], self.dps_strings, False ) return self.async_show_form( step_id="entity", @@ -722,7 +731,7 @@ async def async_step_entity(self, user_input=None): schema, self.dps_strings, **self.current_entity ), description_placeholders={ - "id": self.current_entity[CONF_ID], + "id": int(self.current_entity[CONF_ID]), "platform": self.current_entity[CONF_PLATFORM], }, ) @@ -735,30 +744,30 @@ async def async_step_configure_entity(self, user_input=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] + entity[CONF_ICON] = self.current_entity.get(CONF_ICON, "") self.device_data[CONF_ENTITIES].append(entity) - if len(self.entities) == len(self.device_data[CONF_ENTITIES]): # finished editing device. Let's store the new config entry.... dev_id = self.device_data[CONF_DEVICE_ID] new_data = self.config_entry.data.copy() entry_id = self.config_entry.entry_id - # removing entities from registry (they will be recreated) + # Removing the unwanted entites. + entitesNames = [ + name.get(CONF_FRIENDLY_NAME) + for name in self.device_data[CONF_ENTITIES] + ] ent_reg = er.async_get(self.hass) reg_entities = { ent.unique_id: ent.entity_id for ent in er.async_entries_for_config_entry(ent_reg, entry_id) if dev_id in ent.unique_id + and ent.original_name not in entitesNames } for entity_id in reg_entities.values(): ent_reg.async_remove(entity_id) new_data[CONF_DEVICES][dev_id] = self.device_data - new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) - self.hass.config_entries.async_update_entry( - self.config_entry, - data=new_data, - ) - return self.async_create_entry(title="", data={}) + return self._update_entry(new_data) else: user_input[CONF_PLATFORM] = self.selected_platform self.entities.append(strip_dps_values(user_input, self.dps_strings)) @@ -769,17 +778,19 @@ async def async_step_configure_entity(self, user_input=None): return await self.async_step_pick_entity_type(user_input) if self.editing_device: - schema = platform_schema( - self.current_entity[CONF_PLATFORM], self.dps_strings, allow_id=False + schema = await platform_schema( + self.hass, self.current_entity[CONF_PLATFORM], self.dps_strings, False ) schema = schema_defaults(schema, self.dps_strings, **self.current_entity) placeholders = { - "entity": f"entity with DP {self.current_entity[CONF_ID]}", + "entity": f"entity with DP {int(self.current_entity[CONF_ID])}", "platform": self.current_entity[CONF_PLATFORM], } else: available_dps = self.available_dps_strings() - schema = platform_schema(self.selected_platform, available_dps) + schema = await platform_schema( + self.hass, self.selected_platform, available_dps + ) placeholders = { "entity": "an entity", "platform": self.selected_platform, @@ -792,14 +803,58 @@ async def async_step_configure_entity(self, user_input=None): description_placeholders=placeholders, ) - async def async_step_yaml_import(self, user_input=None): - """Manage YAML imports.""" - _LOGGER.error( - "Configuration via YAML file is no longer supported by this integration." + async def async_step_confirm( + self, + msg: str, + confirm_callback: Coroutine = None, + callback_args: tuple[Any, ...] | None = None, + ): + """Create a confirmation config flow page. If submitted, the `confirm_callback` will be called.""" + if confirm_callback: + if callback_args: + self._confirm_callback = partial(confirm_callback, *callback_args) + else: + self._confirm_callback = confirm_callback + + placeholders = {} + placeholders["message"] = msg + + if not msg: + return self._confirm_callback() + + return self.async_show_form( + step_id="confirm", description_placeholders=placeholders ) - # if user_input is not None: - # return self.async_create_entry(title="", data={}) - # return self.async_show_form(step_id="yaml_import") + + # menu = ["confirm", "init"] + # return self.async_show_menu( + # step_id="confirm", menu_options=menu, description_placeholders=placeholders + # ) + + @callback + def _update_entry(self, new_data, target_obj="", new_title=""): + """Update entry data and save etnry,""" + _data = deepcopy(dict(self.config_entry.data)) + if target_obj: + _data[target_obj].update(new_data) + else: + _data.update(new_data) + _data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + + self.hass.config_entries.async_update_entry( + self.config_entry, data=_data, title=new_title or self.config_entry.title + ) + return self.async_create_entry(title=new_title, data={}) + + def available_dps_strings(self): + """Return list of DPs use by the device's entities.""" + available_dps = [] + used_dps = [str(entity[CONF_ID]) for entity in self.entities] + for dp_string in self.dps_strings: + dp = dp_string.split(" ")[0] + if dp not in used_dps: + available_dps.append(dp_string) + return available_dps @property def current_entity(self): @@ -817,3 +872,478 @@ class InvalidAuth(exceptions.HomeAssistantError): class EmptyDpsList(exceptions.HomeAssistantError): """Error to indicate no datapoints found.""" + + +async def setup_localtuya_devices( + hass: config_entries.HomeAssistant, + entry_id: str, + discovered_devices: dict, + devices_cloud_data: dict, + log_fails=False, +): + """Return a dict of configured devices ready to import into devices data.""" + # Store devices data + devices_cfg = [] + devices = {} + fails = {} + + def update_fails(dev_id: str, reason: str, msg: str = None): + name = devices_cloud_data[dev_id].get(CONF_NAME, dev_id) + fails.update({dev_id: {"name": name, "reason": reason}}) + if log_fails: + msg = f"[ name: {name} — id: {dev_id} — reason: {reason or repr(reason)}]" + _LOGGER.warning(f"Failed to configure device: {msg}") + + # To avoid duplicated entities we will get all devices in every hub. + entries = hass.config_entries.async_entries(DOMAIN) + configured_Devices = [] + for entry in entries: + for devID in entry.data[CONF_DEVICES].keys(): + configured_Devices.append(devID) + + for dev_id, data in discovered_devices.items(): + # Skip configured devices. + if dev_id in configured_Devices: + continue + if dev_cloud_data := devices_cloud_data.get(dev_id): + # Create localtuya devices data and store them into devices_config. + device_data = { + CONF_FRIENDLY_NAME: dev_cloud_data.get(CONF_NAME, dev_id), + CONF_DEVICE_ID: dev_id, + CONF_HOST: data[CONF_TUYA_IP], + CONF_LOCAL_KEY: dev_cloud_data.get(CONF_LOCAL_KEY), + CONF_PROTOCOL_VERSION: data[CONF_TUYA_VERSION], + CONF_ENABLE_DEBUG: False, + CONF_NODE_ID: dev_cloud_data.get(CONF_NODE_ID), + CONF_MODEL: dev_cloud_data.get(CONF_MODEL), + CONF_PRODUCT_KEY: data.get("productKey"), + } + # If device is sub and has Gateway ID store gatewayID + if sub_gwid := data.get(CONF_GATEWAY_ID): + device_data.update({CONF_GATEWAY_ID: sub_gwid}) + + # Store device to device_data. + devices_cfg.append(device_data) + + # Connect to the devices to ensure the are usable. + validate_devices = [validate_input(hass, entry_id, dev) for dev in devices_cfg] + results = await asyncio.gather(*validate_devices, return_exceptions=True) + + # Merge test results with devices config + for i in range(len(results)): + dev_id = devices_cfg[i].get(CONF_DEVICE_ID) + if not isinstance(results[i], dict): + update_fails(dev_id, results[i]) + continue + + devices.update({dev_id: {**devices_cfg[i], **results[i]}}) + + # Configure entities. + for dev_id, dev_data in deepcopy(devices).items(): + category = devices_cloud_data[dev_id].get("category") + dev_data[DEVICE_CLOUD_DATA] = devices_cloud_data[dev_id] + if category and (dps_strings := dev_data.get(CONF_DPS_STRINGS, False)): + dev_entites = gen_localtuya_entities(dev_data, category) + + # Configure entities fails + if not dev_entites: + devices.pop(dev_id) + update_fails(dev_id, f"no configured entities: {dev_entites} - {category}") + continue + + # Add configured entiteis + devices[dev_id].update({CONF_ENTITIES: dev_entites}) + + return devices, fails + + +async def discover_devices() -> tuple[dict[str, dict], dict[str, str]]: + """Start discovering Tuya devices within the network""" + errors = {} + discovered_devices = {} + try: + discovered_devices = await discover() + except OSError as ex: + if ex.errno == errno.EADDRINUSE: + errors["base"] = "address_in_use" + else: + errors["base"] = "discovery_failed" + except Exception as ex: + _LOGGER.exception("discovery failed: %s", ex) + errors["base"] = "discovery_failed" + return discovered_devices, errors + + +def devices_schema( + discovered_devices, cloud_devices_list, add_custom_device=True, existed_devices={} +): + """Create schema for devices step.""" + known_devices = {} + devices = {} + for dev_id, dev_host in discovered_devices.items(): + dev_name = dev_id + # when editing devices get INFOS from stored!. + if not add_custom_device and dev_id in existed_devices.keys(): + dev_name = existed_devices[dev_id].get(CONF_FRIENDLY_NAME, dev_id) + elif dev_id in cloud_devices_list.keys(): + dev_name = cloud_devices_list[dev_id][CONF_NAME] + + known_devices[f"{dev_name} ({dev_host})"] = dev_id + continue + + devices[f"{dev_name} ({dev_host})"] = dev_id + + known_devices = dict(sorted(known_devices.items())) + devices = {**known_devices, **devices} + if add_custom_device: + devices.update(CUSTOM_DEVICE) + else: # Sort devices in edit mode. + devices = dict(sorted(devices.items())) + + schema = vol.Schema( + { + vol.Required(SELECTED_DEVICE): col_to_select(devices), + } + ) + + return schema.extend(MASS_CONFIGURE_SCHEMA) if known_devices else schema + + +def mergeDevicesList(localList: dict, cloudList: dict, addSubDevices=True) -> dict: + """Merge CloudDevices with Discovered LocalDevices (in specific ways)!""" + # try Get SubDevices. + newList = localList.copy() + for _devID, _devData in cloudList.items(): + try: + is_online = _devData.get("online", None) + sub_device = _devData.get(CONF_NODE_ID, False) + # We skip offline devices and already merged devices. + if not is_online or _devID in localList: + continue + # Make sure the device isn't already in localList. + if addSubDevices and sub_device: + # infrared are ir remote sub-devices + if _devData.get(TUYA_CATEGORY, "").startswith("infrared"): + continue + + gateway = get_gateway_by_deviceid(_devID, cloudList) + local_gw = localList.get(gateway.id) + if local_gw: + # Create a data for sub_device [cloud and local gateway] to merge it with discovered devices. + dev_data = { + _devID: { + CONF_TUYA_IP: local_gw.get(CONF_TUYA_IP), + CONF_TUYA_GWID: _devID, + CONF_TUYA_VERSION: local_gw.get(CONF_TUYA_VERSION, "auto"), + CONF_NODE_ID: _devData.get(CONF_NODE_ID, None), + CONF_GATEWAY_ID: local_gw.get(CONF_TUYA_GWID), + } + } + newList.update(dev_data) + except Exception as ex: + _LOGGER.debug(f"An error occurred while trying to pull sub-devices {ex}") + continue + return newList + + +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): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_LOCAL_KEY): cv.string, + vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): col_to_select( + sorted(SUPPORTED_PROTOCOL_VERSIONS) + ), + vol.Required(CONF_ENABLE_DEBUG, default=False): bool, + vol.Optional(CONF_SCAN_INTERVAL): int, + vol.Optional(CONF_MANUAL_DPS): cv.string, + vol.Optional(CONF_RESET_DPIDS): cv.string, + vol.Optional(CONF_DEVICE_SLEEP_TIME): int, + vol.Required( + CONF_ENTITIES, description={"suggested_value": entity_names} + ): cv.multi_select(entity_names), + # col_to_select(entity_names, multi_select=True) + vol.Required(CONF_ENABLE_ADD_ENTITIES, default=False): bool, + vol.Optional(EXPORT_CONFIG, default=False): bool, + } + ) + + +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: dict[str, dict], cloud_dp_codes: dict[str, dict]) -> list: + """Return list of friendly DPS values.""" + strs = [] + + # Merge DPs that found through cloud with local. + for dp, func in cloud_dp_codes.items(): + # Default Manual dp value is -1, we will replace it if it in cloud. + add_dp = dp not in dps_data or dps_data.get(dp) == -1 + if add_dp and ((value := func.get("value")) or value is not None): + dps_data[dp] = f"{value}, cloud pull" + + for dp, value in dps_data.items(): + if (dp_data := cloud_dp_codes.get(dp)) and (code := dp_data.get("code")): + strs.append(f"{dp} ( code: {code} , value: {value} )") + else: + strs.append(f"{dp} ( value: {value} )") + + return sorted(strs, key=lambda i: int(i.split()[0])) + + +def gen_dps_strings(): + """Generate list of DPS values.""" + return [f"{dp} (value: ?)" for dp in range(1, 256)] + + +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 merge_dps_manual_strings(manual_dps: list, dps_strings: list): + """Split manual_dps by comma and assign -1 as default value. Return merged with dps string.""" + manual_list = [] + avaliable_dps = [dp.split(" ")[0] for dp in dps_strings] + + for dp in manual_dps.split(","): + dp = dp.strip() + if dp.isdigit() and dp not in avaliable_dps and dp != "0": + manual_list.append(f"{dp} ( value: -1 )") + + return sorted(dps_strings + manual_list, key=lambda i: int(i.split(" ")[0])) + + +async def platform_schema( + hass: core.HomeAssistant, platform, dps_strings, allow_id=True, yaml=False +): + """Generate input validation schema for a platform.""" + # decide default value of device by platform. + schema = {} + if yaml: + # In YAML mode we force the specified platform to match flow schema + schema[vol.Required(CONF_PLATFORM)] = col_to_select([platform]) + if allow_id: + schema[vol.Required(CONF_ID)] = col_to_select(dps_strings, is_dps=True) + schema[vol.Optional(CONF_FRIENDLY_NAME, default="")] = vol.Any(None, cv.string) + schema[ + vol.Required(CONF_ENTITY_CATEGORY, default=str(default_category(platform))) + ] = col_to_select(ENTITY_CATEGORY) + + plat_schema = await hass.async_add_import_executor_job( + flow_schema, platform, dps_strings + ) + + return vol.Schema(schema).extend(plat_schema) + + +def default_category(_platform): + """Auto Select default category depends on the platform.""" + if any(_platform in i for i in DEFAULT_CATEGORIES["CONTROL"]): + return None + elif any(_platform in i for i in DEFAULT_CATEGORIES["CONFIG"]): + return EntityCategory.CONFIG + elif any(_platform in i for i in DEFAULT_CATEGORIES["DIAGNOSTIC"]): + return EntityCategory.DIAGNOSTIC + else: + return None + + +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) + + +async def validate_input(hass: core.HomeAssistant, entry_id, data): + """Validate the user input allows us to connect.""" + logger = pytuya.ContextualLogger() + logger.set_logger(_LOGGER, data[CONF_DEVICE_ID], True, data[CONF_FRIENDLY_NAME]) + + detected_dps = {} + error = None + interface = None + reset_ids = None + close = True + bypass_connection = False # On users risk, only used for low-power power devices + bypass_handshake = False # In-case device is passive. + + cid = data.get(CONF_NODE_ID, None) + localtuya_devices = hass.data[DOMAIN][entry_id].devices + try: + conf_protocol = data[CONF_PROTOCOL_VERSION] + auto_protocol = conf_protocol == "auto" + # If sub device we will search if gateway is existed if not create new connection. + if ( + cid + and (existed_interface := localtuya_devices.get(data[CONF_HOST])) + and existed_interface.connected + and not existed_interface.is_connecting + ): + interface = existed_interface._interface + close = False + else: + # If 'auto' will be loop through supported protocols. + for ver in SUPPORTED_PROTOCOL_VERSIONS: + try: + version = ver if auto_protocol else conf_protocol + interface = await asyncio.wait_for( + pytuya.connect( + data[CONF_HOST], + data[CONF_DEVICE_ID], + data[CONF_LOCAL_KEY], + float(version), + data[CONF_ENABLE_DEBUG], + ), + 5, + ) + + detected_dps = await interface.detect_available_dps(cid=cid) + + # Break the loop if input isn't auto. + if not auto_protocol: + break + + # If Auto: using DPS detected we will assume this is the correct version if dps found. + if len(detected_dps) > 0: + # Set the conf_protocol to the worked version to return it and update self.device_data. + conf_protocol = version + break + + # If connection to host is failed raise wrong address. + except (OSError, ValueError, pytuya.DecodeError) as ex: + error = ex + break + except: + continue + finally: + if not auto_protocol and data.get(CONF_DEVICE_SLEEP_TIME, 0) > 0: + bypass_connection = True + if not error and not interface: + error = InvalidAuth + + if CONF_RESET_DPIDS in data: + reset_ids_str = data[CONF_RESET_DPIDS].split(",") + reset_ids = [] + for reset_id in reset_ids_str: + reset_ids.append(int(reset_id.strip())) + logger.debug( + "Reset DPIDs configured: %s (%s)", data[CONF_RESET_DPIDS], reset_ids + ) + try: + # If reset dpids set - then assume reset is needed before status. + if (reset_ids is not None) and (len(reset_ids) > 0): + logger.debug("Resetting command for DP IDs: %s", reset_ids) + # Assume we want to request status updated for the same set of DP_IDs as the reset ones. + interface.set_updatedps_list(reset_ids) + + # Reset the interface + await interface.reset(reset_ids, cid=cid) + + # Detect any other non-manual DPS strings + if not detected_dps: + detected_dps = await interface.detect_available_dps(cid=cid) + + except (ValueError, pytuya.DecodeError) as ex: + error = ex + except Exception as ex: + logger.debug(f"No DPS able to be detected {ex}") + detected_dps = {} + + # if manual DPs are set, merge these. + # detected_dps_device used to pervent user from bypass handshake manual dps. + detected_dps_device = detected_dps.copy() + logger.debug("Detected DPS: %s", detected_dps) + if CONF_MANUAL_DPS in data: + manual_dps_list = [dps.strip() for dps in data[CONF_MANUAL_DPS].split(",")] + logger.debug( + "Manual DPS Setting: %s (%s)", data[CONF_MANUAL_DPS], manual_dps_list + ) + # merge the lists + for new_dps in manual_dps_list + (reset_ids or []): + # If the DPS not in the detected dps list, then add with a + # default value indicating that it has been manually added + if str(new_dps) == "0": + bypass_handshake = True + continue + if str(new_dps) not in detected_dps: + detected_dps[new_dps] = -1 + + except (ConnectionRefusedError, ConnectionResetError) as ex: + raise CannotConnect from ex + except (OSError, ValueError, pytuya.DecodeError) as ex: + error = ex + finally: + if interface and close: + await interface.close() + + # Get DP descriptions from the cloud, if the device is there. + cloud_dp_codes = {} + cloud_data: TuyaCloudApi = hass.data[DOMAIN][entry_id].cloud_data + if device_cloud_data := cloud_data.device_list.get(data[CONF_DEVICE_ID]): + cloud_dp_codes = device_cloud_data.get("dps_data", {}) + + # Indicate an error if no datapoints found as the rest of the flow + # won't work in this case + if not bypass_connection and error: + raise error + # If bypass handshake. otherwise raise faild to make handshake with device. + # --- Cloud: We will use the DPS found on cloud if exists. + # --- No cloud: user will have to input the DPS manually. + if not detected_dps_device and not ( + (cloud_dp_codes or detected_dps) and bypass_handshake + ): + raise EmptyDpsList + + logger.debug("Total DPS: %s", detected_dps) + return { + CONF_DPS_STRINGS: dps_string_list(detected_dps, cloud_dp_codes), + CONF_PROTOCOL_VERSION: conf_protocol, + } + + +async def attempt_cloud_connection(hass, user_input): + """Create device.""" + cloud_api = TuyaCloudApi( + hass, + user_input.get(CONF_REGION), + user_input.get(CONF_CLIENT_ID), + user_input.get(CONF_CLIENT_SECRET), + user_input.get(CONF_USER_ID), + ) + + msg, res = await cloud_api.async_connect() + + if res != "ok": + return cloud_api, {"reason": msg, "msg": res} + + return cloud_api, {} diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py index 75f7bbd2..bf74e525 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -1,35 +1,66 @@ """Constants for localtuya integration.""" -DOMAIN = "localtuya" +from dataclasses import dataclass +from typing import Any +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_ENTITIES, + CONF_FRIENDLY_NAME, + CONF_HOST, + CONF_ID, + CONF_SCAN_INTERVAL, + EntityCategory, + Platform, +) +DOMAIN = "localtuya" DATA_DISCOVERY = "discovery" -DATA_CLOUD = "cloud_data" + +# Order on priority +SUPPORTED_PROTOCOL_VERSIONS = ["3.3", "3.1", "3.2", "3.4", "3.5"] + # Platforms in this list must support config flows -PLATFORMS = [ - "binary_sensor", - "climate", - "cover", - "fan", - "light", - "number", - "select", - "sensor", - "switch", - "vacuum", -] - -TUYA_DEVICES = "tuya_devices" +PLATFORMS = { + "Alarm Control Panel": Platform.ALARM_CONTROL_PANEL, + "Binary Sensor": Platform.BINARY_SENSOR, + "Button": Platform.BUTTON, + "Climate": Platform.CLIMATE, + "Cover": Platform.COVER, + "Fan": Platform.FAN, + "Humidifier": Platform.HUMIDIFIER, + "Light": Platform.LIGHT, + "Lock": Platform.LOCK, + "Number": Platform.NUMBER, + "Remote": Platform.REMOTE, + "Select": Platform.SELECT, + "Sensor": Platform.SENSOR, + "Siren": Platform.SIREN, + "Switch": Platform.SWITCH, + "Vacuum": Platform.VACUUM, + "Water Heater": Platform.WATER_HEATER, +} ATTR_CURRENT = "current" ATTR_CURRENT_CONSUMPTION = "current_consumption" ATTR_VOLTAGE = "voltage" ATTR_UPDATED_AT = "updated_at" +# Tuya Devices +CONF_TUYA_IP = "ip" +CONF_TUYA_GWID = "gwId" +CONF_TUYA_VERSION = "version" + +# Status Payloads. +RESTORE_STATES = {"0": "restore"} + + # config flow CONF_LOCAL_KEY = "local_key" CONF_ENABLE_DEBUG = "enable_debug" CONF_PROTOCOL_VERSION = "protocol_version" +CONF_NODE_ID = "node_id" +CONF_GATEWAY_ID = "gateway_id" CONF_DPS_STRINGS = "dps_strings" CONF_MODEL = "model" CONF_PRODUCT_KEY = "product_key" @@ -38,25 +69,34 @@ CONF_ENABLE_ADD_ENTITIES = "add_entities" -CONF_ACTION = "action" CONF_ADD_DEVICE = "add_device" CONF_EDIT_DEVICE = "edit_device" -CONF_SETUP_CLOUD = "setup_cloud" +CONF_CONFIGURE_CLOUD = "configure_cloud" CONF_NO_CLOUD = "no_cloud" CONF_MANUAL_DPS = "manual_dps_strings" CONF_DEFAULT_VALUE = "dps_default_value" CONF_RESET_DPIDS = "reset_dpids" CONF_PASSIVE_ENTITY = "is_passive_entity" +CONF_DEVICE_SLEEP_TIME = "device_sleep_time" + +# ALARM +CONF_ALARM_SUPPORTED_STATES = "alarm_supported_states" + +# Binary_sensor, Siren +CONF_STATE_ON = "state_on" # light CONF_BRIGHTNESS_LOWER = "brightness_lower" CONF_BRIGHTNESS_UPPER = "brightness_upper" CONF_COLOR = "color" CONF_COLOR_MODE = "color_mode" +CONF_COLOR_MODE_SET = "color_mode_set" 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" +CONF_SCENE_VALUES = "scene_values" +CONF_SCENE_VALUES_FRIENDLY = "scene_values_friendly" # switch CONF_CURRENT = "current" @@ -68,6 +108,7 @@ CONF_POSITIONING_MODE = "positioning_mode" CONF_CURRENT_POSITION_DP = "current_position_dp" CONF_SET_POSITION_DP = "set_position_dp" +CONF_STOP_SWITCH_DP = "stop_switch_dp" CONF_POSITION_INVERTED = "position_inverted" CONF_SPAN_TIME = "span_time" @@ -84,30 +125,27 @@ # sensor CONF_SCALING = "scaling" +CONF_STATE_CLASS = "state_class" # 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_TEMP_MAX = "max_temperature_const" -CONF_TEMP_MIN = "min_temperature_const" +CONF_MIN_TEMP = "min_temperature" +CONF_MAX_TEMP = "max_temperature" CONF_PRECISION = "precision" CONF_TARGET_PRECISION = "target_precision" CONF_HVAC_MODE_DP = "hvac_mode_dp" CONF_HVAC_MODE_SET = "hvac_mode_set" -CONF_HVAC_FAN_MODE_DP = "hvac_fan_mode_dp" -CONF_HVAC_FAN_MODE_SET = "hvac_fan_mode_set" -CONF_HVAC_SWING_MODE_DP = "hvac_swing_mode_dp" -CONF_HVAC_SWING_MODE_SET = "hvac_swing_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_HVAC_ADD_OFF = "hvac_add_off" CONF_ECO_DP = "eco_dp" CONF_ECO_VALUE = "eco_value" +CONF_FAN_SPEED_LIST = "fan_speed_list" # vacuum CONF_POWERGO_DP = "powergo_dp" @@ -127,16 +165,66 @@ CONF_PAUSED_STATE = "paused_state" CONF_RETURN_MODE = "return_mode" CONF_STOP_STATUS = "stop_status" +CONF_PAUSE_DP = "pause_dp" # number CONF_MIN_VALUE = "min_value" CONF_MAX_VALUE = "max_value" -CONF_STEPSIZE_VALUE = "step_size" +CONF_STEPSIZE = "step_size" # select CONF_OPTIONS = "select_options" CONF_OPTIONS_FRIENDLY = "select_options_friendly" +# Remote +CONF_RECEIVE_DP = "receive_dp" +CONF_KEY_STUDY_DP = "key_study_dp" + +# Lock +CONF_JAMMED_DP = "jammed_dp" +CONF_LOCK_STATE_DP = "lock_state_dp" + +# Water Heater +CONF_TARGET_TEMPERATURE_LOW_DP = "target_temperature_low_dp" +CONF_TARGET_TEMPERATURE_HIGH_DP = "target_temperature_high_dp" + # States ATTR_STATE = "raw_state" CONF_RESTORE_ON_RECONNECT = "restore_on_reconnect" + +# Categories +ENTITY_CATEGORY = { + "None": None, + "Configuration": EntityCategory.CONFIG, + "Diagnostic": EntityCategory.DIAGNOSTIC, +} + +# Default Categories +DEFAULT_CATEGORIES = { + "CONTROL": ["switch", "climate", "fan", "vacuum", "light"], + "CONFIG": ["select", "number", "button"], + "DIAGNOSTIC": ["sensor", "binary_sensor"], +} + + +@dataclass +class DeviceConfig: + """Represent the main configuration for LocalTuya device.""" + + device_config: dict[str, Any] + + def __post_init__(self) -> None: + self.id: str = self.device_config[CONF_DEVICE_ID] + self.host: str = self.device_config[CONF_HOST] + self.local_key: str = self.device_config[CONF_LOCAL_KEY] + self.entities: list = self.device_config[CONF_ENTITIES] + self.protocol_version: str = self.device_config[CONF_PROTOCOL_VERSION] + self.sleep_time: int = self.device_config.get(CONF_DEVICE_SLEEP_TIME, 0) + self.scan_interval: int = self.device_config.get(CONF_SCAN_INTERVAL, 0) + self.enable_debug: bool = self.device_config.get(CONF_ENABLE_DEBUG, False) + self.name: str = self.device_config.get(CONF_FRIENDLY_NAME) + self.node_id: str | None = self.device_config.get(CONF_NODE_ID) + self.model: str = self.device_config.get(CONF_MODEL, "Tuya generic") + self.reset_dps: str = self.device_config.get(CONF_RESET_DPIDS, "") + self.manual_dps: str = self.device_config.get(CONF_MANUAL_DPS, "") + self.dps_strings: list = self.device_config.get(CONF_DPS_STRINGS, []) diff --git a/custom_components/localtuya/coordinator.py b/custom_components/localtuya/coordinator.py new file mode 100644 index 00000000..442440d8 --- /dev/null +++ b/custom_components/localtuya/coordinator.py @@ -0,0 +1,658 @@ +"""Tuya Device API""" + +from __future__ import annotations +import asyncio +import errno +import logging +import time +from datetime import timedelta +from typing import Any, NamedTuple +from functools import partial + + +from homeassistant.core import HomeAssistant, CALLBACK_TYPE, callback, State +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, CONF_DEVICES, CONF_HOST, CONF_DEVICE_ID +from homeassistant.helpers.event import async_track_time_interval, async_call_later +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, + dispatcher_send, +) + +from .core import pytuya +from .core.cloud_api import TuyaCloudApi +from .core.pytuya import ( + HEARTBEAT_INTERVAL, + TuyaListener, + ContextualLogger, + SubdeviceState, +) +from .const import ( + ATTR_UPDATED_AT, + CONF_GATEWAY_ID, + CONF_LOCAL_KEY, + CONF_NODE_ID, + CONF_TUYA_IP, + DATA_DISCOVERY, + DOMAIN, + DeviceConfig, + RESTORE_STATES, +) + +_LOGGER = logging.getLogger(__name__) +RECONNECT_INTERVAL = timedelta(seconds=5) +# Subdevice: Offline events before disconnecting the device, around 5 minutes +MIN_OFFLINE_EVENTS = 5 * 60 // HEARTBEAT_INTERVAL + + +class HassLocalTuyaData(NamedTuple): + """LocalTuya data stored in homeassistant data object.""" + + cloud_data: TuyaCloudApi + devices: dict[str, TuyaDevice] + + +class TuyaDevice(TuyaListener, ContextualLogger): + """Cache wrapper for pytuya.TuyaInterface.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry[Any], + device_config: dict, + fake_gateway=False, + ): + """Initialize the cache.""" + super().__init__() + self._hass = hass + self._entry = entry + self._hass_entry: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id] + self._device_config = DeviceConfig(device_config.copy()) + self.id = self._device_config.id + + self._status = {} + self._interface = None + self._local_key = self._device_config.local_key + + # For SubDevices + self.gateway: TuyaDevice = None + self.sub_devices: dict[str, TuyaDevice] = {} + self._fake_gateway = fake_gateway + self._node_id: str = self._device_config.node_id + self.subdevice_state = None + self._subdevice_off_count: int = 0 + + # last_update_time: Sleep timer, a device that reports the status every x seconds then goes into sleep. + self._last_update_time = time.time() - 5 + self._pending_status: dict[str, dict[str, Any]] = {} + + self._task_connect: asyncio.Task | None = None + self._task_reconnect: asyncio.Task | None = None + self._task_shutdown_entities: asyncio.Task | None = None + self._unsub_refresh: CALLBACK_TYPE | None = None + self._unsub_new_entity: CALLBACK_TYPE | None = None + + self._entities = [] + self.is_closing = False + + self._default_reset_dpids: list | None = None + dev = self._device_config + if reset_dps := dev.reset_dps: + self._default_reset_dpids = [int(id.strip()) for id in reset_dps.split(",")] + + # This has to be done in case the device type is type_0d + self.dps_to_request = {} + for dp in dev.dps_strings: + self.dps_to_request[dp.split(" ")[0]] = None + + self.set_logger(_LOGGER, dev.id, dev.enable_debug, self.friendly_name) + + @property + def friendly_name(self): + """Name string for log prefixes.""" + name = self._device_config.name + return name if not self._fake_gateway else (name + "/G") + + @property + def connected(self): + """Return if connected to device.""" + return self._interface and self._interface.is_connected + + @property + def is_connecting(self): + """Return whether device is currently connecting.""" + return self._task_connect is not None + + @property + def is_subdevice(self): + """Return whether this is a subdevice or not.""" + return self._node_id and not self._fake_gateway + + @property + def is_sleep(self): + """Return whether the device is sleep or not.""" + device_sleep = self._device_config.sleep_time + if device_sleep > 0: + setattr(self, "low_power", True) + last_update = time.time() - self._last_update_time + is_sleep = last_update < device_sleep + + return device_sleep > 0 and is_sleep + + def add_entities(self, entities): + """Set the entities associated with this device.""" + self._entities.extend(entities) + + async def async_connect(self, _now=None) -> None: + """Connect to device if not already connected.""" + if self.is_closing or self.is_connecting: + return + + if self.connected: + return self._dispatch_status() + + self._task_connect = asyncio.create_task(self._make_connection()) + if not self.is_sleep: + await self._task_connect + + async def _connect_subdevices(self): + """Gateway: connect to sub-devices one by one.""" + if not self.sub_devices: + return + + for subdevice in self.sub_devices.values(): + if not self.connected or self.is_closing: + break + if subdevice.subdevice_state != SubdeviceState.ABSENT: + await subdevice.async_connect() + + async def _make_connection(self): + """Subscribe localtuya entity events.""" + if self.is_sleep and not self._status: + self.status_updated(RESTORE_STATES) + + name, host = self._device_config.name, self._device_config.host + retry = 0 + max_retries = 3 + update_localkey = False + + self.debug(f"Trying to connect to: {host}...", force=True) + # Connect to the device, interface should be connected for next steps. + while retry < max_retries and not self.is_closing: + retry += 1 + try: + if self.is_subdevice: + gateway = self._get_gateway() + if not gateway: + update_localkey = True + break + if not gateway.connected and gateway.is_connecting: + return await self.abort_connect() + self._interface = gateway._interface + if not self._interface: + break + if self._device_config.enable_debug: + self._interface.enable_debug(True, gateway.friendly_name) + else: + self._interface = await pytuya.connect( + self._device_config.host, + self._device_config.id, + self._local_key, + float(self._device_config.protocol_version), + self._device_config.enable_debug, + self, + ) + self._interface.enable_debug( + self._device_config.enable_debug, self.friendly_name + ) + self._interface.add_dps_to_request(self.dps_to_request) + break # Succeed break while loop + except asyncio.CancelledError: + await self.abort_connect() + self._task_connect = None + return + except OSError as e: + await self.abort_connect() + if e.errno == errno.EHOSTUNREACH and not self.is_sleep: + self.warning(f"Connection failed: {e}") + break + except Exception as ex: # pylint: disable=broad-except + await self.abort_connect() + if not self.is_sleep: + self.warning(f"Failed to connect to {host}: {str(ex)}") + if "key" in str(ex): + update_localkey = True + break + + # Get device status and configure DPS. + if self.connected and not self.is_closing: + try: + # If reset dpids set - then assume reset is needed before status. + reset_dpids = self._default_reset_dpids + if (reset_dpids is not None) and (len(reset_dpids) > 0): + self.debug(f"Resetting cmd for DP IDs: {reset_dpids}") + # Assume we want to request status updated for the same set of DP_IDs as the reset ones. + self._interface.set_updatedps_list(reset_dpids) + + # Reset the interface + await self._interface.reset(reset_dpids, cid=self._node_id) + + self.debug("Retrieving initial state") + # Usually we use status instead of detect_available_dps, but some device doesn't reports all dps when ask for status. + status = await self._interface.status(cid=self._node_id) + if status is None: + raise Exception("Failed to retrieve status") + + self.status_updated(status) + except (UnicodeDecodeError, pytuya.DecodeError) as e: + self.exception(f"Handshake with {host} failed: due to {type(e)}: {e}") + await self.abort_connect() + update_localkey = True + except asyncio.CancelledError: + await self.abort_connect() + self._task_connect = None + return + except Exception as e: + if not (self._fake_gateway and "Not found" in str(e)): + e = "Sub device is not connected" if self.is_subdevice else e + self.warning(f"Handshake with {host} failed: {e}") + await self.abort_connect() + if self.is_subdevice: + update_localkey = True + except: + if self._fake_gateway: + self.warning(f"Failed to use {name} as gateway.") + await self.abort_connect() + update_localkey = True + + # Connect and configure the entities, at this point the device should be ready to get commands. + if self.connected and not self.is_closing: + self.debug(f"Success: connected to: {host}", force=True) + # Attempt to restore status for all entities that need to first set + # the DPS value before the device will respond with status. + for entity in self._entities: + await entity.restore_state_when_connected() + + if self._unsub_new_entity is None: + + def _new_entity_handler(entity_id): + self.debug(f"New entity {entity_id} was added to {host}") + self._dispatch_status() + + signal = f"localtuya_entity_{self._device_config.id}" + self._unsub_new_entity = async_dispatcher_connect( + self._hass, signal, _new_entity_handler + ) + + if (scan_inv := int(self._device_config.scan_interval)) > 0: + self._unsub_refresh = async_track_time_interval( + self._hass, self._async_refresh, timedelta(seconds=scan_inv) + ) + + self._task_connect = None + # Ensure the connected sub-device is in its gateway's sub_devices + # and reset offline/absent counters + if self.gateway: + self.gateway.sub_devices[self._node_id] = self + if self.is_subdevice: + self.subdevice_state_updated(SubdeviceState.ONLINE) + + if not self._status and "0" in self._device_config.manual_dps.split(","): + self.status_updated(RESTORE_STATES) + + if self._pending_status: + await self.set_status() + + if self.sub_devices: + asyncio.create_task(self._connect_subdevices()) + + self._interface.keep_alive(len(self.sub_devices) > 0) + + # If not connected try to handle the errors. + if not self.connected and not self.is_closing: + if self._task_reconnect is None: + self._task_reconnect = asyncio.create_task(self._async_reconnect()) + if update_localkey: + # Check if the cloud device info has changed!. + await self.update_local_key() + + self._task_connect = None + + async def abort_connect(self): + """Abort the connect process to the interface[device]""" + if self.is_subdevice: + self._interface = None + self._task_connect = None + + if self._interface is not None: + await self._interface.close() + self._interface = None + + async def check_connection(self): + """Ensure that the device is not still connecting; if it is, wait for it.""" + if not self.connected and self._task_connect: + await self._task_connect + if not self.connected and self.gateway and self.gateway._task_connect: + await self.gateway._task_connect + if not self.connected: + self.error(f"Not connected to device {self._device_config.name}") + + async def close(self): + """Close connection and stop re-connect loop.""" + if self.is_closing: + return + + self.is_closing = True + + tasks = [self._task_shutdown_entities, self._task_reconnect, self._task_connect] + pending_tasks = [task for task in tasks if task and task.cancel()] + await asyncio.gather(*pending_tasks, return_exceptions=True) + + # Close subdevices first, to prevent them try to reconnect + # after gateway disconnected. + for subdevice in self.sub_devices.values(): + await subdevice.close() + + if self._unsub_new_entity: + self._unsub_new_entity() + self._unsub_new_entity = None + + if self._unsub_refresh: + self._unsub_refresh() + self._unsub_refresh = None + + await self.abort_connect() + + if self.gateway: + self.gateway.filter_subdevices() + self.debug("Closed connection", force=True) + + async def update_local_key(self): + """Retrieve updated local_key from Cloud API and update the config_entry.""" + dev_id = self._device_config.id + + cloud_api = self._hass_entry.cloud_data + await cloud_api.async_get_devices_list(force_update=True) + + cloud_devs = cloud_api.device_list + if dev_id in cloud_devs: + cloud_localkey = cloud_devs[dev_id].get(CONF_LOCAL_KEY) + if not cloud_localkey or self._local_key == cloud_localkey: + return + + new_data = self._entry.data.copy() + self._local_key = cloud_localkey + + if self._node_id: + from .core.helpers import get_gateway_by_deviceid + + # Update Node ID. + if new_node_id := cloud_devs[dev_id].get(CONF_NODE_ID): + new_data[CONF_DEVICES][dev_id][CONF_NODE_ID] = new_node_id + + # Update Gateway ID and IP + if new_gw := get_gateway_by_deviceid(dev_id, cloud_devs): + self.info(f"Gateway ID has been updated to: {new_gw.id}") + new_data[CONF_DEVICES][dev_id][CONF_GATEWAY_ID] = new_gw.id + + discovery = self._hass.data[DOMAIN].get(DATA_DISCOVERY) + if discovery and (local_gw := discovery.devices.get(new_gw.id)): + new_ip = local_gw.get(CONF_TUYA_IP, self._device_config.host) + new_data[CONF_DEVICES][dev_id][CONF_HOST] = new_ip + self.info(f"IP has been updated to: {new_ip}") + + new_data[CONF_DEVICES][dev_id][CONF_LOCAL_KEY] = self._local_key + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + self._hass.config_entries.async_update_entry(self._entry, data=new_data) + self.info(f"Local-key has been updated") + + async def set_status(self): + """Send self._pending_status payload to device.""" + await self.check_connection() + if self._interface and self._pending_status: + payload, self._pending_status = self._pending_status.copy(), {} + try: + await self._interface.set_dps(payload, cid=self._node_id) + except Exception as ex: # pylint: disable=broad-except + self.debug(f"Failed to set values {payload} --> {ex}", force=True) + elif not self.connected: + self.error(f"Device is not connected.") + + async def set_dp(self, state, dp_index): + """Change value of a DP of the Tuya device.""" + if self._interface is not None: + self._pending_status.update({dp_index: state}) + await asyncio.sleep(0.001) + await self.set_status() + else: + if self.is_sleep: + return self._pending_status.update({str(dp_index): state}) + + async def set_dps(self, states): + """Change value of a DPs of the Tuya device.""" + if self._interface is not None: + self._pending_status.update(states) + await asyncio.sleep(0.001) + await self.set_status() + else: + if self.is_sleep: + return self._pending_status.update(states) + + async def _async_refresh(self, _now): + if self.connected: + self.debug("Refreshing dps for device") + # This a workaround for >= 3.4 devices, since there is an issue on waiting for the correct seqno + try: + await self._interface.update_dps(cid=self._node_id) + except TimeoutError: + pass + + async def _async_reconnect(self): + """Task: continuously attempt to reconnect to the device.""" + attempts = 0 + while True: + try: + # for sub-devices, if it is reported as offline then no need for reconnect. + if ( + self.is_subdevice + and self._subdevice_off_count >= MIN_OFFLINE_EVENTS + ): + await asyncio.sleep(1) + continue + + # for sub-devices, if the gateway isn't connected then no need for reconnect. + if self.gateway and ( + not self.gateway.connected or self.gateway.is_connecting + ): + await asyncio.sleep(3) + continue + + if not self._task_connect: + await self.async_connect() + if self._task_connect: + await self._task_connect + + if self.connected: + if not self.is_sleep and attempts > 0: + self.info(f"Reconnect succeeded on attempt: {attempts}") + break + + if self.is_closing: + break + + attempts += 1 + scale = ( + 2 + if (self.subdevice_state == SubdeviceState.ABSENT) + or (attempts > MIN_OFFLINE_EVENTS) + else 1 + ) + await asyncio.sleep(scale * RECONNECT_INTERVAL.total_seconds()) + except asyncio.CancelledError as e: + self.debug(f"Reconnect task has been canceled: {e}", force=True) + break + + self._task_reconnect = None + + def _dispatch_status(self): + signal = f"localtuya_{self._device_config.id}" + dispatcher_send(self._hass, signal, self._status) + + def _handle_event(self, old_status: dict, new_status: dict, deviceID=None): + """Handle events in HA when devices updated.""" + + def fire_event(event, data: dict): + event_data = {CONF_DEVICE_ID: deviceID or self._device_config.id} + event_data.update(data.copy()) + # Send an event with status, The default length of event without data is 2. + if len(event_data) > 1: + self._hass.bus.async_fire(f"localtuya_{event}", event_data) + + event = "states_update" + device_triggered = "device_triggered" + device_dp_triggered = "device_dp_triggered" + + # Device Initializing. if not old_states. + # States update event. + if old_status and old_status != new_status: + data = {"old_states": old_status, "new_states": new_status} + fire_event(event, data) + + # Device triggered event. + if old_status and new_status is not None: + event = device_triggered + data = {"states": new_status} + fire_event(event, data) + + if self._interface is not None: + if len(self._interface.dispatched_dps) == 1: + event = device_dp_triggered + dpid_trigger = list(self._interface.dispatched_dps)[0] + dpid_value = self._interface.dispatched_dps.get(dpid_trigger) + data = {"dp": dpid_trigger, "value": dpid_value} + fire_event(event, data) + + async def _shutdown_entities(self, exc=""): + """Shutdown device entities""" + # Delay shutdown. + if not self.is_closing: + try: + await asyncio.sleep(3 + self._device_config.sleep_time) + except asyncio.CancelledError as e: + self.debug(f"Shutdown entities task has been canceled: {e}", force=True) + return + + if self.connected or self.is_sleep: + self._task_shutdown_entities = None + return + + signal = f"localtuya_{self._device_config.id}" + dispatcher_send(self._hass, signal, None) + + if self.is_closing: + return + + if self.is_subdevice: + self.info(f"Sub-device disconnected due to: {exc}") + elif hasattr(self, "low_power"): + m, s = divmod((int(time.time()) - self._last_update_time), 60) + h, m = divmod(m, 60) + self.info(f"The device is still out of reach since: {h}h:{m}m:{s}s") + else: + self.info(f"Disconnected due to: {exc}") + + self._task_shutdown_entities = None + + @callback + def status_updated(self, status: dict): + """Device updated status.""" + if self._fake_gateway: + # Fake gateways are only used to pass commands no need to update status. + return + self._last_update_time = int(time.time()) + + self._handle_event(self._status, status) + self._status.update(status) + self._dispatch_status() + + @callback + def disconnected(self, exc=""): + """Device disconnected.""" + if not self._interface: + return + self._interface = None + + if self._unsub_refresh: + self._unsub_refresh() + self._unsub_refresh = None + + for subdevice in self.sub_devices.values(): + subdevice.disconnected("Gateway disconnected") + + if self._task_connect is not None: + self._task_connect.cancel() + self._task_connect = None + + # If it disconnects unexpectedly. + if self.is_closing: + return + + if self._task_reconnect is None: + self._task_reconnect = asyncio.create_task(self._async_reconnect()) + + if self._task_shutdown_entities is not None: + self._task_shutdown_entities.cancel() + self._task_shutdown_entities = asyncio.create_task( + self._shutdown_entities(exc=exc) + ) + + @callback + def subdevice_state_updated(self, state: SubdeviceState): + """Handle the reported states for Sub-Devices.""" + node_id = self._node_id + old_state = self.subdevice_state + self.subdevice_state = state + + # This will trigger if state is absent twice. + if old_state == state and state == SubdeviceState.ABSENT: + self._subdevice_off_count = 0 + return self.disconnected("Device is absent") + elif state == SubdeviceState.ABSENT: + return self.info(f"Sub-device is absent {node_id}") + elif old_state == SubdeviceState.ABSENT: + self.info(f"Sub-device is back {node_id}") + + is_online = state == SubdeviceState.ONLINE + off_count = self._subdevice_off_count + self._subdevice_off_count = 0 if is_online else off_count + 1 + + if is_online: + return self.info(f"Sub-device is online {node_id}") if off_count else None + else: + off_count += 1 + if off_count == 1: + self.warning(f"Sub-device is offline {node_id}") + elif off_count == MIN_OFFLINE_EVENTS: + self.disconnected("Device is offline") + + def filter_subdevices(self): + """Remove closed subdevices that are closed.""" + self.sub_devices = dict( + filter(lambda dev: not dev[1].is_closing, self.sub_devices.items()) + ) + + def _get_gateway(self): + """Return the gateway device of this sub device.""" + if not self._node_id or (gateway := self.gateway) is None: + return None # Should never happen + + # Ensure that sub-device still on the same gateway device. + if gateway._local_key != self._local_key: + if self.subdevice_state != SubdeviceState.ABSENT: + self.warning("Sub-device localkey doesn't match the gateway localkey") + # This will become ONLINE after successful connect + self.subdevice_state = SubdeviceState.ABSENT + return None + else: + return gateway diff --git a/custom_components/localtuya/core/__init__.py b/custom_components/localtuya/core/__init__.py new file mode 100644 index 00000000..1f58fadc --- /dev/null +++ b/custom_components/localtuya/core/__init__.py @@ -0,0 +1 @@ +"""The core of localtuya""" diff --git a/custom_components/localtuya/core/__pycache__/__init__.cpython-313.pyc b/custom_components/localtuya/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..93cbab89 Binary files /dev/null and b/custom_components/localtuya/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/__pycache__/cloud_api.cpython-313.pyc b/custom_components/localtuya/core/__pycache__/cloud_api.cpython-313.pyc new file mode 100644 index 00000000..e4eeee6e Binary files /dev/null and b/custom_components/localtuya/core/__pycache__/cloud_api.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/__pycache__/helpers.cpython-313.pyc b/custom_components/localtuya/core/__pycache__/helpers.cpython-313.pyc new file mode 100644 index 00000000..70f0c9d2 Binary files /dev/null and b/custom_components/localtuya/core/__pycache__/helpers.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/cloud_api.py b/custom_components/localtuya/core/cloud_api.py new file mode 100644 index 00000000..e6373226 --- /dev/null +++ b/custom_components/localtuya/core/cloud_api.py @@ -0,0 +1,356 @@ +"""Class to perform requests to Tuya Cloud APIs.""" + +import asyncio +import functools +import hashlib +import hmac +import json +import logging +import time + +import requests +from requests.adapters import HTTPAdapter, Retry + + +DEVICES_UPDATE_INTERVAL = 300 +DEVICES_UPDATE_INTERVAL_FORCED = 10 + +TUYA_ENDPOINTS = { + # Regions code + "Central Europe Data Center": "eu", + "China Data Center": "cn", + "Eastern America Data Center": "ea", + "India Data Center": "in", + "Western America Data Center": "us", + "Western Europe Data Center": "we", +} + + +# Signature algorithm. +def calc_sign(msg, key): + """Calculate signature for request.""" + sign = ( + hmac.new( + msg=bytes(msg, "latin-1"), + key=bytes(key, "latin-1"), + digestmod=hashlib.sha256, + ) + .hexdigest() + .upper() + ) + return sign + + +class CustomAdapter(logging.LoggerAdapter): + """Adapter logger for cloud api.""" + + def process(self, msg, kwargs): + return f"[{self.extra.get('prefix', '')}] {msg}", kwargs + + +class TuyaCloudApi: + """Class to send API calls.""" + + def __init__(self, hass, region_code, client_id, secret, user_id): + """Initialize the class.""" + self._logger = CustomAdapter( + logging.getLogger(__name__), {"prefix": user_id[:3] + "..." + user_id[-3:]} + ) + + self._hass = hass + self._client_id = client_id + self._secret = secret + self._user_id = user_id + self._access_token = "" + self._token_expire_time: int = 0 + + if region_code == "ea": + self._base_url = "https://openapi-ueaz.tuyaus.com" + elif region_code == "we": + self._base_url = "https://openapi-weaz.tuyaeu.com" + else: + self._base_url = f"https://openapi.tuya{region_code}.com" + + self.device_list = {} + self.cached_device_list = {} + + self._last_devices_update = int(time.time()) + + def generate_payload(self, method, timestamp, url, headers, body=None): + """Generate signed payload for requests.""" + payload = self._client_id + self._access_token + timestamp + + payload += method + "\n" + # Content-SHA256 + payload += hashlib.sha256(bytes((body or "").encode("utf-8"))).hexdigest() + payload += ( + "\n" + + "".join( + [ + "%s:%s\n" % (key, headers[key]) # Headers + for key in headers.get("Signature-Headers", "").split(":") + if key in headers + ] + ) + + "\n/" + + url.split("//", 1)[-1].split("/", 1)[-1] # Url + ) + # self._logger.debug("PAYLOAD: %s", payload) + return payload + + async def async_make_request(self, method, url, body=None, headers={}): + """Perform requests.""" + # obtain new token if expired. + if not self.token_validate and self._token_expire_time != -1: + if (res := await self.async_get_access_token()) and res != "ok": + return self._logger.debug(f"Refresh Token failed due to: {res}") + + timestamp = str(int(time.time() * 1000)) + payload = self.generate_payload(method, timestamp, url, headers, body) + default_par = { + "client_id": self._client_id, + "access_token": self._access_token, + "sign": calc_sign(payload, self._secret), + "t": timestamp, + "sign_method": "HMAC-SHA256", + } + full_url = self._base_url + url + max_retries = 3 + request_timeout = 3 + + # Create requests session. + request_session = requests.Session() + # Setup retries configuration + retries = Retry(total=max_retries, backoff_factor=0.3) + request_session.mount(full_url, HTTPAdapter(max_retries=retries)) + if method == "GET": + func = functools.partial( + request_session.get, + full_url, + headers=dict(default_par, **headers), + timeout=request_timeout, + ) + elif method == "POST": + func = functools.partial( + request_session.post, + full_url, + headers=dict(default_par, **headers), + data=json.dumps(body), + timeout=request_timeout, + ) + # self._logger.debug("BODY: [%s]", body) + elif method == "PUT": + func = functools.partial( + request_session.put, + full_url, + headers=dict(default_par, **headers), + data=json.dumps(body), + timeout=request_timeout, + ) + + try: + resp = await self._hass.async_add_executor_job(func) + except requests.exceptions.ReadTimeout as ex: + self._logger.debug(f"Requests read timeout: {ex}") + return + # r = json.dumps(r.json(), indent=2, ensure_ascii=False) # Beautify the format + return resp + + async def async_get_access_token(self) -> str | None: + """Obtain a valid access token.""" + # Reset access token + self._token_expire_time = -1 + self._access_token = "" + + try: + resp = await self.async_make_request("GET", "/v1.0/token?grant_type=1") + except requests.exceptions.ConnectionError: + self._token_expire_time = 0 + return "Request failed, status ConnectionError" + + if not resp: + self._token_expire_time = 0 + return + if not resp.ok: + return "Request failed, status " + str(resp.status) + + r_json = resp.json() + if not r_json["success"]: + return f"Error {r_json['code']}: {r_json['msg']}" + + req_results = r_json["result"] + + expire_time = int(req_results.get("expire_time", 3600)) + self._token_expire_time = int(time.time()) + expire_time + self._access_token = resp.json()["result"]["access_token"] + return "ok" + + async def async_get_devices_list(self, force_update=False) -> str | None: + """Obtain the list of devices associated to a user. - force_update will ignore last update check.""" + interval = ( + DEVICES_UPDATE_INTERVAL + if not force_update + else DEVICES_UPDATE_INTERVAL_FORCED + ) + if ( + self.device_list + and int(time.time()) - (self._last_devices_update + interval) < 0 + ): + return self._logger.debug(f"Devices has been updated a minutes ago.") + + resp = await self.async_make_request( + "GET", url=f"/v1.0/users/{self._user_id}/devices" + ) + + if not resp: + return + if not resp.ok: + return "Request failed, status " + str(resp.status) + + r_json = resp.json() + if not r_json["success"]: + # self._logger.debug( + # "Request failed, reply is %s", + # json.dumps(r_json, indent=2, ensure_ascii=False) + # ) + return f"Error {r_json['code']}: {r_json['msg']}" + + self.device_list = {dev["id"]: dev for dev in r_json["result"]} + + # Get Devices DPS Data. + get_functions = [ + self._hass.async_create_task(self.get_device_functions(devid)) + for devid in self.device_list + ] + # await asyncio.run(*get_functions) + + self._last_devices_update = int(time.time()) + return "ok" + + async def async_get_device_specifications(self, device_id) -> dict[str, dict]: + """Obtain the DP ID mappings for a device.""" + resp = await self.async_make_request( + "GET", url=f"/v1.1/devices/{device_id}/specifications" + ) + + if not resp: + return + if not resp.ok: + return {}, "Request failed, status " + str(resp.status) + + r_json = resp.json() + if not r_json["success"]: + return {}, f"Error {r_json['code']}: {r_json['msg']}" + + return r_json["result"], "ok" + + async def async_get_device_query_properties(self, device_id) -> dict[dict, str]: + """Obtain the DP ID mappings for a device correctly! Note: This won't works if the subscription expired.""" + resp = await self.async_make_request( + "GET", url=f"/v2.0/cloud/thing/{device_id}/shadow/properties" + ) + + if not resp: + return + if not resp.ok: + return {}, "Request failed, status " + str(resp.status) + + r_json = resp.json() + if not r_json["success"]: + return {}, f"Error {r_json['code']}: {r_json['msg']}" + + return r_json["result"], "ok" + + async def async_get_device_query_things_data_model( + self, device_id + ) -> dict[str, dict]: + """Obtain the DP ID mappings for a device.""" + resp = await self.async_make_request( + "GET", url=f"/v2.0/cloud/thing/{device_id}/model" + ) + + if not resp: + return + if not resp.ok: + return {}, "Request failed, status " + str(resp.status) + + r_json = resp.json() + if not r_json["success"]: + return {}, f"Error {r_json['code']}: {r_json['msg']}" + + return r_json["result"], "ok" + + async def get_device_functions(self, device_id) -> dict[str, dict]: + """Pull Devices Properties and Specifications to devices_list""" + cached = device_id in self.cached_device_list + if cached and (dps_data := self.cached_device_list[device_id].get("dps_data")): + self.device_list[device_id]["dps_data"] = dps_data + return + + device_data = {} + get_data = [ + self.async_get_device_specifications(device_id), + self.async_get_device_query_properties(device_id), + self.async_get_device_query_things_data_model(device_id), + ] + try: + specs, query_props, query_model = await asyncio.gather(*get_data) + except requests.exceptions.ConnectionError as ex: + self._logger.debug(f"Failed to get DPS functions for {device_id} - {ex}") + return + + if query_props[1] == "ok": + device_data = {str(p["dp_id"]): p for p in query_props[0].get("properties")} + if specs[1] == "ok": + for func in specs[0].get("functions", {}): + if str(func.get("dp_id")) in device_data: + device_data[str(func["dp_id"])].update(func) + elif dp_id := func.get("dp_id"): + device_data[str(dp_id)] = func + if query_model[1] == "ok": + model_data = json.loads(query_model[0]["model"]) + services = model_data.get("services", [{}])[0] + properties = services.get("properties") + for dp_data in properties if properties else {}: + refactored = { + "id": dp_data.get("abilityId"), + # "code": dp_data.get("code"), + "accessMode": dp_data.get("accessMode"), + # values: json.loads later + "values": str(dp_data.get("typeSpec")).replace("'", '"'), + } + if str(dp_data["abilityId"]) in device_data: + device_data[str(dp_data["abilityId"])].update(refactored) + else: + refactored["code"] = dp_data.get("code") + device_data[str(dp_data["abilityId"])] = refactored + + if "28841002" in str(query_props[1]): + # No permissions This affect auto configure feature. + self.device_list[device_id]["localtuya_note"] = str(query_props[1]) + + if device_data: + self.device_list[device_id]["dps_data"] = device_data + self.cached_device_list.update({device_id: self.device_list[device_id]}) + + return device_data + + async def async_connect(self): + """Connect to cloudAPI""" + if (res := await self.async_get_access_token()) and res != "ok": + self._logger.error("Cloud API connection failed: %s", res) + return "authentication_failed", res + if res and (res := await self.async_get_devices_list()) and res != "ok": + self._logger.error("Cloud API connection failed: %s", res) + return "device_list_failed", res + if res: + self._logger.info("Cloud API connection succeeded.") + return True, res + + @property + def token_validate(self): + """Return whether token is expired or not""" + cur_time = int(time.time()) + expire_time = self._token_expire_time - 30 + + return expire_time >= cur_time diff --git a/custom_components/localtuya/core/ha_entities/__init__.py b/custom_components/localtuya/core/ha_entities/__init__.py new file mode 100644 index 00000000..d09d0f5b --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/__init__.py @@ -0,0 +1,300 @@ +""" + Tuya Devices: https://xzetsubou.github.io/hass-localtuya/auto_configure/ + + This functionality is similar to HA Tuya, as it retrieves the category and searches for the corresponding categories. + The categories data has been improved & modified to work seamlessly with localtuya + + Device Data: You can obtain all the data for your device from Home Assistant by directly downloading the diagnostics or using entry diagnostics. + Alternative: Use Tuya IoT. + + Add a new device or modify an existing one: + 1. Make sure the device category doesn't already exist. If you are creating a new one, you can modify existing categories. + 2. In order to add a device, you need to specify the category of the device you want to add inside the entity type dictionary. + + Add entities to devices: + 1. Open the file with the name of the entity type on which you want to make changes [e.g. switches.py] and search for your device category. + 2. You can add entities inside the tuple value of the dictionary by including LocalTuyaEntity and passing the parameters for the entity configurations. + 3. These configurations include "id" (required), "icon" (optional), "device_class" (optional), "state_class" (optional), and "name" (optional) [Using COVERS as an example] + Example: "3 ( code: percent_state , value: 0 )" - Refer to the Device Data section above for more details. + current_state_dp = DPCode.PERCENT_STATE < This maps the "percent_state" code DP to the current_state_dp configuration. + + If the configuration is not DPS, it will be inserted through "custom_configs". This is used to inject any configuration into the entity configuration + Example: custom_configs={"positioning_mode": "position"}. I hope that clarifies the concept + + Check URL above for more details. +""" + +import json +from .base import LocalTuyaEntity, CONF_DPS_STRINGS, CLOUD_VALUE, DPType +from enum import Enum +from homeassistant.const import Platform, CONF_FRIENDLY_NAME, CONF_PLATFORM, CONF_ID + +import logging + +# Supported files +from .alarm_control_panels import ALARMS # not added yet +from .binary_sensors import BINARY_SENSORS +from .buttons import BUTTONS +from .climates import CLIMATES +from .covers import COVERS +from .fans import FANS +from .humidifiers import HUMIDIFIERS +from .lights import LIGHTS +from .numbers import NUMBERS +from .remotes import REMOTES +from .selects import SELECTS +from .sensors import SENSORS +from .sirens import SIRENS +from .switches import SWITCHES +from .vacuums import VACUUMS +from .locks import LOCKS +from .water_heaters import WATER_HEATERS + +# The supported PLATFORMS [ Platform: Data ] +DATA_PLATFORMS = { + Platform.ALARM_CONTROL_PANEL: ALARMS, + Platform.BINARY_SENSOR: BINARY_SENSORS, + Platform.BUTTON: BUTTONS, + Platform.CLIMATE: CLIMATES, + Platform.COVER: COVERS, + Platform.FAN: FANS, + Platform.HUMIDIFIER: HUMIDIFIERS, + Platform.LIGHT: LIGHTS, + Platform.LOCK: LOCKS, + Platform.NUMBER: NUMBERS, + Platform.REMOTE: REMOTES, + Platform.SELECT: SELECTS, + Platform.SENSOR: SENSORS, + Platform.SIREN: SIRENS, + Platform.SWITCH: SWITCHES, + Platform.VACUUM: VACUUMS, + Platform.WATER_HEATER: WATER_HEATERS, +} + +_LOGGER = logging.getLogger(__name__) + +TUYA_CATEGORY = "category" +DEVICE_CLOUD_DATA = "device_cloud_data" + + +def gen_localtuya_entities(localtuya_data: dict, tuya_category: str) -> list[dict]: + """Return localtuya entities using the data that provided from TUYA""" + detected_dps: list = localtuya_data.get(CONF_DPS_STRINGS) + + if not tuya_category or not detected_dps: + _LOGGER.debug(f"Missing category: {tuya_category} or DPS: {detected_dps}") + return + + device_name: str = localtuya_data.get(CONF_FRIENDLY_NAME).strip() + device_cloud_data: dict = localtuya_data.get(DEVICE_CLOUD_DATA, {}) + dps_data = device_cloud_data.get("dps_data", {}) + + entities = {} + + for platform, tuya_data in DATA_PLATFORMS.items(): + # TODO: Refactor needed here. + if cat_data := tuya_data.get(tuya_category): + for ent_data in cat_data: + main_confs = ent_data.data + localtuya_conf = ent_data.localtuya_conf + localtuya_entity_configs = ent_data.entity_configs + # Conditions + contains_any: list[str] = ent_data.contains_any + entity = {} + + # used_dp = 0 + for k, code in localtuya_conf.items(): + if type(code) == Enum: + code = code.value + + # If there's multi possible codes. + if isinstance(code, tuple): + for _code in code: + if any(_code in dp.lower().split() for dp in detected_dps): + code = parse_enum(_code) + break + else: + code = None + + for dp_data in detected_dps: + dp_data: str = dp_data.lower() + # Same method we use in config_flow to get dp. + dp_id = dp_data.split(" ")[0] + + if k in entity: + # if the k already configured break the loop!. + _LOGGER.debug(f"{k} Already configured with: {entity[k]}.") + break + + if contains_any is not None: + if not any(cond in dp_data for cond in contains_any): + continue + + if code and code.lower() in dp_data.split(): + entity[k] = dp_id + + # Pull dp values from cloud. still unsure to apply this to all. + # This is due to the fact that some local values may not same with the values provided from cloud. + # For now, this is applied only to numbers values. + for k, v in localtuya_entity_configs.items(): + if isinstance(v, CLOUD_VALUE): + config_dp = entity.get(v.dp_config) + dp_values = get_dp_values(config_dp, dps_data, v) or {} + + # special case for lights + # if v.value_key in dp_values and "kelvin" in k: + # value = dp_values.get(v.value_key) + # dp_values[v.value_key] = convert_to_kelvin(value) + + entity[k] = dp_values.get(v.value_key, v.default_value) + else: + entity[k] = v + + if entity: + # Entity most contains ID + if not entity.get(CONF_ID): + continue + # Workaround to Prevent duplicated id. + if entity[CONF_ID] in entities: + _LOGGER.debug(f"{device_name}: Duplicated ID: {entity}") + continue + + entity.update(main_confs) + entity[CONF_PLATFORM] = platform + entities[entity.get(CONF_ID)] = entity + _LOGGER.debug(f"{device_name}: Entity configured: {entity}") + + # sort entites by id + sorted_ids = sorted(entities, key=int) + + # convert to list of configs + list_entities = [entities.get(id) for id in sorted_ids] + + _LOGGER.debug(f"{device_name}: Configured entities: {list_entities}") + # return [] + return list_entities + + +def parse_enum(dp_code: Enum) -> str: + """Get enum value if code type is enum""" + try: + parsed_dp_code = dp_code.value + except: + parsed_dp_code = dp_code + + return parsed_dp_code + + +def get_dp_values(dp: str, dps_data: dict, req_info: CLOUD_VALUE = None) -> dict: + """Get DP Values""" + if not dp or not dps_data: + return + + dp_data = dps_data.get(dp, {}) + dp_values = dp_data.get("values") + dp_type = dp_data.get("type") + + if not dp_values or not (dp_values := json.loads(dp_values)): + return + + # Some DPS doesn't have the type, in high level data. + if not dp_type and (_type := dp_values.get("type")): + dp_type = _type.capitalize() + # Fix type names. + dp_type = DPType.INTEGER if dp_type == "Value" else dp_type + + # Integer values: min, max, scale, step + if dp_values and dp_type == DPType.INTEGER: + # We only need the scaling factor, other values will be scaled from via later on. + # dp_values["min"] = scale(dp_values.get("min"), val_scale) + valid_type = req_info.prefer_type and req_info.prefer_type in (str, float, int) + pref_type = req_info.prefer_type if valid_type else int + val_scale = dp_values.get("scale", 1) + dp_values["min"] = pref_type(dp_values.get("min")) + dp_values["max"] = pref_type(dp_values.get("max")) + dp_values["step"] = pref_type(dp_values.get("step")) + + pref_type = req_info.prefer_type if valid_type else float + dp_values["scale"] = pref_type(scale(1, val_scale, float)) + + # Scale if requested. + if req_info.scale: + for v in ("min", "max", "step"): + value = dp_values[v] + dp_values[v] = pref_type(scale(value, val_scale)) + + return dp_values + + # ENUM Values: range: list of values. + if dp_values and dp_type == DPType.ENUM: + range_values = dp_values.get("range", []) + + dp_values["min"] = range_values[0] if range_values else 0 # first value + dp_values["max"] = range_values[-1] if range_values else 0 # Last value + dp_values["range"] = convert_list(range_values, req_info) + return dp_values + + # Sensors don't have type + if dp_values and not dp_type: + # we need scaling factor for sensors. + if "scale" in dp_values: + dp_values["scale"] = scale(1, dp_values["scale"], float) + return dp_values + + +def scale(value: int, scale: int, _type: type = int) -> float: + """Return scaled value.""" + value = _type(value) / (10**scale) + if value.is_integer(): + value = int(value) + return value + + +def convert_list(_list: list, req_info: CLOUD_VALUE = str): + """Return list to dict values.""" + if not _list: + return [] + + prefer_type = req_info.prefer_type + + if prefer_type == str: + # Return str "value1,value2,value3" + to_str = ",".join(str(v) for v in _list) + return to_str + + if prefer_type == dict: + # Return dict {value_1: Value 1, value_2: Value 2, value_3: Value 3} + to_dict = {} + for k in _list: + if k.lower() in req_info.remap_values: + k_name = req_info.remap_values.get(k.lower()) + else: + # k_name = k.replace("_", " ").capitalize() # Default name + k_name = k # Default name + if isinstance(req_info.default_value, dict): + k_name = req_info.default_value.get(k, k_name) + + if req_info.reverse_dict: + to_dict.update({k_name: k}) + else: + to_dict.update({k: k_name}) + return to_dict + + # otherwise return prefer type list + return _list + + +def convert_to_kelvin(value): + """Convert Tuya color temperature to kelvin""" + # Given data points + v0, k0 = 0, 2700 # (0, 2700) + v1, k1 = 1000, 6500 # (1000, 6500) + + # Calculate slope (m) and y-intercept (b) using the given points + m = (k1 - k0) / (v1 - v0) + b = k0 - m * v0 + + # Use the linear equation to calculate the color temperature (K) + kelvin = m * value + b + + return kelvin diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/__init__.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..3ef62e9d Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/alarm_control_panels.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/alarm_control_panels.cpython-313.pyc new file mode 100644 index 00000000..f7d49f6d Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/alarm_control_panels.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/base.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/base.cpython-313.pyc new file mode 100644 index 00000000..bf2f7d72 Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/base.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/binary_sensors.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/binary_sensors.cpython-313.pyc new file mode 100644 index 00000000..4a5ddc04 Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/binary_sensors.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/buttons.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/buttons.cpython-313.pyc new file mode 100644 index 00000000..283b13c2 Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/buttons.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/climates.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/climates.cpython-313.pyc new file mode 100644 index 00000000..6fd5e61e Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/climates.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/covers.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/covers.cpython-313.pyc new file mode 100644 index 00000000..6f3a11eb Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/covers.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/fans.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/fans.cpython-313.pyc new file mode 100644 index 00000000..9cb04143 Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/fans.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/humidifiers.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/humidifiers.cpython-313.pyc new file mode 100644 index 00000000..a80c3916 Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/humidifiers.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/lights.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/lights.cpython-313.pyc new file mode 100644 index 00000000..3cd9c564 Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/lights.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/locks.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/locks.cpython-313.pyc new file mode 100644 index 00000000..cc9e68a9 Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/locks.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/numbers.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/numbers.cpython-313.pyc new file mode 100644 index 00000000..b2fdebaf Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/numbers.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/remotes.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/remotes.cpython-313.pyc new file mode 100644 index 00000000..af9201ca Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/remotes.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/selects.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/selects.cpython-313.pyc new file mode 100644 index 00000000..4a3db487 Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/selects.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/sensors.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/sensors.cpython-313.pyc new file mode 100644 index 00000000..7ee99bef Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/sensors.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/sirens.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/sirens.cpython-313.pyc new file mode 100644 index 00000000..8efaab5d Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/sirens.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/switches.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/switches.cpython-313.pyc new file mode 100644 index 00000000..dbb88522 Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/switches.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/vacuums.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/vacuums.cpython-313.pyc new file mode 100644 index 00000000..820227ff Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/vacuums.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/__pycache__/water_heaters.cpython-313.pyc b/custom_components/localtuya/core/ha_entities/__pycache__/water_heaters.cpython-313.pyc new file mode 100644 index 00000000..554f726b Binary files /dev/null and b/custom_components/localtuya/core/ha_entities/__pycache__/water_heaters.cpython-313.pyc differ diff --git a/custom_components/localtuya/core/ha_entities/alarm_control_panels.py b/custom_components/localtuya/core/ha_entities/alarm_control_panels.py new file mode 100644 index 00000000..93625f7d --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/alarm_control_panels.py @@ -0,0 +1,48 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE +from ...const import CONF_ALARM_SUPPORTED_STATES +from homeassistant.components.alarm_control_panel import AlarmControlPanelState + +MAP_ALARM_STATES = { + "disarmed": AlarmControlPanelState.DISARMED, + "arm": AlarmControlPanelState.ARMED_AWAY, + "home": AlarmControlPanelState.ARMED_HOME, + "sos": AlarmControlPanelState.TRIGGERED, +} + + +def localtuya_alarm(states: dict): + """Generate localtuya alarm configs""" + data = { + CONF_ALARM_SUPPORTED_STATES: CLOUD_VALUE( + states, "id", "range", dict, MAP_ALARM_STATES, True + ), + } + return data + + +# All descriptions can be found here: +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +ALARMS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Alarm Host + # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf + "mal": ( + LocalTuyaEntity( + id=DPCode.MASTER_MODE, + custom_configs=localtuya_alarm( + { + AlarmControlPanelState.DISARMED: "disarmed", + AlarmControlPanelState.ARMED_AWAY: "arm", + AlarmControlPanelState.ARMED_HOME: "home", + AlarmControlPanelState.TRIGGERED: "sos", + } + ), + ), + ), +} diff --git a/custom_components/localtuya/core/ha_entities/base.py b/custom_components/localtuya/core/ha_entities/base.py new file mode 100644 index 00000000..78d98760 --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/base.py @@ -0,0 +1,728 @@ +from enum import StrEnum +from dataclasses import dataclass, field +from typing import Any + +from homeassistant.const import ( + CONF_FRIENDLY_NAME, + CONF_ICON, + CONF_ENTITY_CATEGORY, + CONF_DEVICE_CLASS, + Platform, + EntityCategory, +) +from ...const import CONF_CLEAN_AREA_DP, CONF_DPS_STRINGS, CONF_STATE_CLASS + + +# Obtain values from cloud data. +@dataclass +class CLOUD_VALUE: + """Retrieve a value from stored cloud data + + `default_value`: The value that will be used if it fails to retrieve from the cloud.\n + `dp_config(str)`: The dp config key that will be used to look for the values into it.\n + `value_key(str)`: The "key" name of the targeted value.\n + `prefer_type`: Convert values + Integer: Type(value) ( int, float or str ).\n + Enums: convert the values to [dict or str splitted by comma, default is list].\n + `remap_values(dict)`: Used to remap dict values, if prefer_type is dict.\n + `reverse_dict(bool)`: Reverse dict keys, value, if prefer_type is dict.\n + `scale(bool)`: For integers, scale final value.\n + """ + + default_value: Any + dp_config: str + value_key: str + prefer_type: type = None + remap_values: dict[str, Any] = field(default_factory=dict) + reverse_dict: bool = False + scale: bool = False + + +class LocalTuyaEntity: + """ + Localtuya entity config. + Each platform has unique custom_configs to give the required data to validate entity setups. + e.g. Switch req( Friendly_Name and DP(Code) ) + """ + + def __init__( + self, + name: str = "", + icon: str = "", + entity_category="None", + device_class=None, + state_class=None, + custom_configs: dict[str, Any | tuple[Any, CLOUD_VALUE]] = {}, + condition_contains_any: list = None, + **kwargs, + ): + # platform, name, icon, entity_category, device_class, *key + # self.platform = platform + self.name = name + self.data = { + CONF_FRIENDLY_NAME: name, + CONF_ICON: icon, + CONF_ENTITY_CATEGORY: entity_category, + } + + # Optional + if device_class: + self.data[CONF_DEVICE_CLASS] = device_class + + # Optional + if state_class: + self.data[CONF_STATE_CLASS] = state_class + + self.entity_configs = custom_configs + + self.contains_any = condition_contains_any + + # Replace key with id if needed + if kwargs.get("key", False): + kwargs["id"] = kwargs.pop("key") + # e.g.e CONF_ID etc.. + + self.localtuya_conf = kwargs + + +class DPType(StrEnum): + """Data point types.""" + + BOOLEAN = "Boolean" + ENUM = "Enum" + INTEGER = "Integer" + JSON = "Json" + RAW = "Raw" + STRING = "String" + + +class DPCode(StrEnum): + """Data Point Codes used by Tuya. + + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + """ + + AC_CURRENT = "ac_current" + AC_VOLT = "ac_volt" + ADD_ELE = "add_ele" + ADD_ELE1 = "add_ele1" + ADD_ELE2 = "add_ele2" + AIR_QUALITY = "air_quality" + AIR_RETURN = "air_return" + ALARMPERIOD = "AlarmPeriod" + ALARMSWITCH = "AlarmSwitch" + ALARMTYPE = "Alarmtype" + ALARM_DELAY_TIME = "alarm_delay_time" + ALARM_LOCK = "alarm_lock" + ALARM_MESSAGE = "alarm_message" + ALARM_RINGTONE = "alarm_ringtone" + ALARM_SETTING = "alarm_setting" + ALARM_STATE = "alarm_state" + ALARM_SWITCH = "alarm_switch" # Alarm switch + ALARM_TIME = "alarm_time" # Alarm time + ALARM_VOLUME = "alarm_volume" # Alarm volume + ALL_ENERGY = "all_energy" + AMBIEN = "ambien" + ANGLE_HORIZONTAL = "angle_horizontal" + ANGLE_VERTICAL = "angle_vertical" + ANION = "anion" # Ionizer unit + ANTILOCK_STATUS = "antilock_status" + APPOINTMENT_TIME = "appointment_time" + ARMING_SWITCH = "arming_switch" + ARM_DOWN_PERCENT = "arm_down_percent" + ARM_UP_PERCENT = "arm_up_percent" + AUTOMATIC_LOCK = "automatic_lock" + AUTO_CLEAN = "auto_clean" + AUTO_LOCK_TIME = "auto_lock_time" + BACKLIGHT_SWITCH = "backlight_switch" + BASIC_ANTI_FLICKER = "basic_anti_flicker" + BASIC_DEVICE_VOLUME = "basic_device_volume" + BASIC_FLIP = "basic_flip" + BASIC_INDICATOR = "basic_indicator" + BASIC_NIGHTVISION = "basic_nightvision" + BASIC_OSD = "basic_osd" + BASIC_PRIVATE = "basic_private" + BASIC_WDR = "basic_wdr" + BASS_CONTROL = "bass_control" + BATTERY = "battery" + BATTERYSTATUS = "BatteryStatus" + BATTERY_PERCENTAGE = "battery_percentage" # Battery percentage + BATTERY_STATE = "battery_state" # Battery state + BATTERY_VALUE = "battery_value" # Battery value + BREAK_CLEAN = "break_clean" + BRIGHTNESS_MAX_1 = "brightness_max_1" + BRIGHTNESS_MAX_2 = "brightness_max_2" + BRIGHTNESS_MAX_3 = "brightness_max_3" + BRIGHTNESS_MIN_1 = "brightness_min_1" + BRIGHTNESS_MIN_2 = "brightness_min_2" + BRIGHTNESS_MIN_3 = "brightness_min_3" + BRIGHT_CONTROLLER = "bright_controller" + BRIGHT_STATE = "bright_state" # Brightness status + BRIGHT_VALUE = "bright_value" # Brightness + BRIGHT_VALUE_1 = "bright_value_1" + BRIGHT_VALUE_2 = "bright_value_2" + BRIGHT_VALUE_3 = "bright_value_3" + BRIGHT_VALUE_4 = "bright_value_4" + BRIGHT_VALUE_V2 = "bright_value_v2" + CALLPHONE = "callphone" + CH2O_STATE = "ch2o_state" + CH2O_VALUE = "ch2o_value" + CH4_SENSOR_STATE = "ch4_sensor_state" + CH4_SENSOR_VALUE = "ch4_sensor_value" + CHILDLOCK = "childlock" + CHILD_LOCK = "child_lock" # Child lock + CISTERN = "cistern" + CLEAN_AREA = "clean_area" + CLEAN_RECORD = "clean_record" + CLEAN_TIME = "clean_time" + CLEAR_ENERGY = "clear_energy" + CLICK_SUSTAIN_TIME = "click_sustain_time" + CLOSED_OPENED = "closed_opened" + CLOSED_OPENED_KIT = "closed_opened_kit" + CLOUD_RECIPE_NUMBER = "cloud_recipe_number" + CO2_STATE = "co2_state" + CO2_VALUE = "co2_value" # CO2 concentration + COEF_B_RESET = "coef_b_reset" + COIL_OUT = "coil_out" + COLLECTION_MODE = "collection_mode" + COLOR_DATA_V2 = "color_data_v2" + COLOUR_DATA = "colour_data" # Colored light mode + COLOUR_DATA_HSV = "colour_data_hsv" # Colored light mode + COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode + COMPRESSOR_COMMAND = "compressor_command" + CONCENTRATION_SET = "concentration_set" # Concentration setting + CONTROL = "control" + CONTROL_2 = "control_2" + CONTROL_3 = "control_3" + CONTROL_4 = "control_4" + CONTROL_BACK = "control_back" + CONTROL_BACK_MODE = "control_back_mode" + COOK_TEMPERATURE = "cook_temperature" + COOK_TIME = "cook_time" + COUNTDOWN = "countdown" # Countdown + COUNTDOWN_1 = "countdown_1" # Countdown 1 + COUNTDOWN_2 = "countdown_2" # Countdown 2 + COUNTDOWN_3 = "countdown_3" # Countdown 3 + COUNTDOWN_4 = "countdown_4" # Countdown 4 + COUNTDOWN_5 = "countdown_5" # Countdown 5 + COUNTDOWN_6 = "countdown_6" # Countdown 6 + COUNTDOWN_LEFT = "countdown_left" + COUNTDOWN_SET = "countdown_set" # Countdown setting + COUNTDOWN_USB = "countdown" # Countdown + COUNTDOWN_USB1 = "countdown_usb1" # Countdown USBS 1 + COUNTDOWN_USB2 = "countdown_usb2" # Countdown USBS 2 + COUNTDOWN_USB3 = "countdown_usb3" # Countdown USBS 3 + COUNTDOWN_USB4 = "countdown_usb4" # Countdown USBS 4 + COUNTDOWN_USB5 = "countdown_usb5" # Countdown USBS 5 + COUNTDOWN_USB6 = "countdown_usb6" # Countdown USBS 6 + CO_STATE = "co_state" + CO_STATUS = "co_status" + CO_VALUE = "co_value" + CRUISE_MODE = "cruise_mode" + CRY_DETECTION_SWITCH = "cry_detection_switch" + CUP_NUMBER = "cup_number" # NUmber of cups + CURRENT_A = "current_a" + CURRENT_A_CALIBRATION = "current_a_calibration" + CURRENT_B = "current_b" + CURRENT_B_CALIBRATION = "current_b_calibration" + CURRENT_C = "current_c" + CURRENT_C_CALIBRATION = "current_c_calibration" + CUR_CURRENT = "cur_current" # Actual current + CUR_CURRENT1 = "cur_current1" + CUR_CURRENT2 = "cur_current2" + CUR_POWER = "cur_power" # Actual power + CUR_POWER1 = "cur_power1" + CUR_POWER2 = "cur_power2" + CUR_VOLTAGE = "cur_voltage" # Actual voltage + CUR_VOLTAGE1 = "cur_voltage1" + CUR_VOLTAGE2 = "cur_voltage2" + C_F = "c_f" # Temperature unit switching + DAY_ENERGY = "day_energy" + DECIBEL_SENSITIVITY = "decibel_sensitivity" + DECIBEL_SWITCH = "decibel_switch" + DEFROST = "defrost" + DEHUMIDITY_SET_ENUM = "dehumidify_set_enum" + DEHUMIDITY_SET_VALUE = "dehumidify_set_value" + DELAY_SET = "delay_set" + DEVICE_STATE1 = "device_state1" + DEVICE_STATE2 = "device_state2" + DIRECTION_A = "direction_a" + DIRECTION_B = "direction_b" + DIRECTION_C = "direction_c" + DIRECTION_CONTROL = "direction_control" + DISINFECTION = "disinfection" + DOORBELL = "doorbell" + DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor + DOORCONTACT_STATE_2 = "doorcontact_state_2" + DOORCONTACT_STATE_3 = "doorcontact_state_3" + DOOR_UNCLOSED = "door_unclosed" + DOOR_UNCLOSED_TRIGGER = "door_unclosed_trigger" + DOWN_CONFIRM = "down_confirm" # cover reset. + DO_NOT_DISTURB = "do_not_disturb" + DUSTER_CLOTH = "duster_cloth" + ECO = "eco" + ECO2 = "eco2" + EDGE_BRUSH = "edge_brush" + ELECTRICITY_LEFT = "electricity_left" + EMISSION = "emission" + ENERGY = "energy" + ENERGY_A_CALIBRATION_FWD = "energy_a_calibration_fwd" + ENERGY_A_CALIBRATION_REV = "energy_a_calibration_rev" + ENERGY_B_CALIBRATION_FWD = "energy_b_calibration_fwd" + ENERGY_B_CALIBRATION_REV = "energy_b_calibration_rev" + ENERGY_C_CALIBRATION_FWD = "energy_c_calibration_fwd" + ENERGY_C_CALIBRATION_REV = "energy_c_calibration_rev" + ENERGY_FORWORD_A = "energy_forword_a" + ENERGY_FORWORD_B = "energy_forword_b" + ENERGY_FORWORD_C = "energy_forword_c" + ENERGY_RESERSE_A = "energy_reserse_A" + ENERGY_RESERSE_B = "energy_reserse_b" + ENERGY_RESERSE_C = "energy_reserse_c" + ENERGY_REVERSE_A = "energy_reverse_a" + ENERGY_REVERSE_B = "energy_reverse_b" + ENERGY_REVERSE_C = "energy_reverse_c" + FAN_BEEP = "fan_beep" # Sound + FAN_COOL = "fan_cool" # Cool wind + FAN_COUNTDOWN = "fan_countdown" + FAN_COUNTDOWN_2 = "fan_countdown_2" + FAN_COUNTDOWN_3 = "fan_countdown_3" + FAN_COUNTDOWN_4 = "fan_countdown_4" + FAN_DIRECTION = "fan_direction" # Fan direction + FAN_HORIZONTAL = "fan_horizontal" # Horizontal swing flap angle + FAN_MODE = "fan_mode" + FAN_SPEED = "fan_speed" + FAN_SPEED_ENUM = "fan_speed_enum" # Speed mode + FAN_SPEED_PERCENT = "fan_speed_percent" # Stepless speed + FAN_SWITCH = "fan_switch" + FAN_VERTICAL = "fan_vertical" # Vertical swing flap angle + FAR_DETECTION = "far_detection" + FAULT = "fault" + FEED_REPORT = "feed_report" + FEED_STATE = "feed_state" + FILTER = "filter" + FILTER_LIFE = "filter" + FILTER_RESET = "filter_reset" # Filter (cartridge) reset + FLIGHT_BRIGHT_MODE = "flight_bright_mode" + FLOODLIGHT_LIGHTNESS = "floodlight_lightness" + FLOODLIGHT_SWITCH = "floodlight_switch" + FORWARD_ENERGY_TOTAL = "forward_energy_total" + FOUT_WAY_VALVE = "fout_way_valve" + FREQ_CALIBRATION = "freq_calibration" + GAS_SENSOR_STATE = "gas_sensor_state" + GAS_SENSOR_STATUS = "gas_sensor_status" + GAS_SENSOR_VALUE = "gas_sensor_value" + HIGHTPROTECTVALUE = "hightprotectvalue" + HIJACK = "hijack" + HUMIDIFIER = "humidifier" # Humidification + HUMIDITY = "humidity" # Humidity + HUMIDITY_CURRENT = "humidity_current" # Current humidity + HUMIDITY_INDOOR = "humidity_indoor" # Indoor humidity + HUMIDITY_SET = "humidity_set" # Humidity setting + HUMIDITY_VALUE = "humidity_value" # Humidity + HUMI_STATUS = "humi_status" + HUM_ALARM = "hum_alarm" + HUM_PERIODIC_REPORT = "hum_periodic_report" + HUM_SENSITIVITY = "hum_sensitivity" + IDU_ERROR = "idu_error" + ILLUMINANCE_VALUE = "illuminance_value" + INNERDRY = "innerdry" + INSTALLATION_HEIGHT = "installation_height" + INTERVAL_TIME = "interval_time" + IPC_WORK_MODE = "ipc_work_mode" + IR_SEND = "ir_send" + IR_STUDY_CODE = "ir_study_code" + KEY_STUDY = "key_study" + KNOB_SWITCH_MODE_1 = "knob_switch_mode_1" + LED_TYPE_1 = "led_type_1" + LED_TYPE_2 = "led_type_2" + LED_TYPE_3 = "led_type_3" + LEVEL = "level" + LEVEL_CURRENT = "level_current" + LIGHT = "light" # Light + LIGHT_MODE = "light_mode" + LIQUID_DEPTH = "liquid_depth" + LIQUID_DEPTH_MAX = "liquid_depth_max" + LIQUID_LEVEL_PERCENT = "liquid_level_percent" + LIQUID_STATE = "liquid_state" + LOADSTATUS = "loadstatus" + LOCK = "lock" # Lock / Child lock + LOCK_MOTOR_STATE = "lock_motor_state" + LOWER_TEMP = "lower_temp" + LOWER_TEMP_F = "lower_temp_f" + LOWPROTECTVALUE = "lowprotectvalue" + LOW_POWER_THRESHOLD = "low_power_threshold" + LUX = "lux" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + MACH_OPERATE = "mach_operate" + MANUAL_FEED = "manual_feed" + MASTER_MODE = "master_mode" # alarm mode + MASTER_STATE = "master_state" # alarm mode + MATERIAL = "material" # Material + MAXHUM_SET = "maxhum_set" + MAXTEMP_SET = "maxtemp_set" + MAX_HUMI = "max_humi" + MAX_SET = "max_set" + MIDDLE_CONFIRM = "middle_confirm" # cover reset. + MINIHUM_SET = "minihum_set" + MINITEMP_SET = "minitemp_set" + MINI_SET = "mini_set" + MIN_HUMI = "min_humi" + MOD = "mod" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + MODE = "mode" # Working mode / Mode + MODE_1 = "mode_1" # Working mode / Mode + MODE_2 = "mode_2" # Working mode / Mode + MODE_3 = "mode_3" # Working mode / Mode + MODE_4 = "mode_4" # Working mode / Mode + MODE_5 = "mode_5" # Working mode / Mode + MODE_6 = "mode_6" # Working mode / Mode + MOD_ON_TMR = "mod_on_tmr" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + MOD_ON_TMR_CD = "mod_on_tmr_cd" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + MOODLIGHTING = "moodlighting" # Mood light + MOTION_INTERVAL = "motion_interval" + MOTION_RECORD = "motion_record" + MOTION_SENSITIVITY = "motion_sensitivity" + MOTION_SWITCH = "motion_switch" # Motion switch + MOTION_TRACKING = "motion_tracking" + MOTOR_MODE = "motor_mode" + MOVEMENT_DETECT_PIC = "movement_detect_pic" + MUFFLING = "muffling" # Muffling + MUTE = "mute" + NEAR_DETECTION = "near_detection" + NET_STATE = "net_state" + NORMAL_OPEN_SWITCH = "normal_open_switch" + ODU_FAN_SPEED = "odu_fan_speed" + OPEN_CLOSE = "open_close" + OPPOSITE = "opposite" + OPTIMUMSTART = "optimumstart" + OTHEREVENT = "OtherEvent" + OUT_POWER = "out_power" + OVERCHARGE_SWITCH = "overcharge_switch" + OXYGEN = "oxygen" # Oxygen bar + PAUSE = "pause" + PERCENT_CONTROL = "percent_control" + PERCENT_CONTROL_2 = "percent_control_2" + PERCENT_CONTROL_3 = "percent_control_3" + PERCENT_CONTROL_4 = "percent_control_4" + PERCENT_STATE = "percent_state" + PERCENT_STATE_2 = "percent_state_2" + PERCENT_STATE_3 = "percent_state_3" + PERCENT_STATE_4 = "percent_state_4" + PHASE_A = "phase_a" + PHASE_B = "phase_b" + PHASE_C = "phase_c" + PHOTO_MODE = "photo_mode" + PIR = "pir" # Motion sensor + PIR_SENSITIVITY = "pir_sensitivity" + PIR_STATE = "pir_state" + PIR_TIME = "pir_time" + PLANT = "plant" + PLAY_INFO = "play_info" + PLAY_MODE = "play_mode" + PLAY_TIME = "play_time" + PM1 = "pm1" + PM10 = "pm10" + PM100_STATE = "pm100_state" + PM100_VALUE = "pm100_value" + PM10_STATE = "pm10_state" + PM10_VALUE = "pm10_value" + PM25 = "pm25" + PM25_STATE = "pm25_state" + PM25_VALUE = "pm25_value" + POSITION = "position" + POWDER_SET = "powder_set" # Powder + POWER = "power" + POWEREVENT = "PowerEvent" + POWER_A = "power_a" + POWER_ADJUSTMENT = "power_adjustmen" + POWER_A_CALIBRATION = "power_a_calibration" + POWER_B = "power_b" + POWER_B_CALIBRATION = "power_b_calibration" + POWER_C = "power_c" + POWER_C_CALIBRATION = "power_c_calibration" + POWER_FACTOR = "power_factor" + POWER_FACTOR_A = "power_factor_a" + POWER_FACTOR_B = "power_factor_b" + POWER_FACTOR_C = "power_factor_c" + POWER_GO = "power_go" + POWER_TYPE = "power_type" + POWER_TYPE1 = "power_type1" + POWER_TYPE2 = "power_type2" + PRESENCE_STATE = "presence_state" + PRESSURE_STATE = "pressure_state" + PRESSURE_VALUE = "pressure_value" + PRM_CONTENT = "prm_content" + PRM_TEMPERATURE = "prm_temperature" + PTZ_CONTROL = "ptz_control" + PTZ_STOP = "ptz_stop" + PUMP_RESET = "pump_reset" # Water pump reset + PV_CURRENT = "pv_current" + PV_POWER = "pv_power" + PV_VOLT = "pv_volt" + RECORD_MODE = "record_mode" + RECORD_SWITCH = "record_switch" # Recording switch + RELAY_STATUS = "relay_status" + RELAY_STATUS_1 = "relay_status_1" # Scene Switch cjkg + RELAY_STATUS_2 = "relay_status_2" # Scene Switch cjkg + RELAY_STATUS_3 = "relay_status_3" # Scene Switch cjkg + RELAY_STATUS_4 = "relay_status_4" # Scene Switch cjkg + RELAY_STATUS_5 = "relay_status_5" # Scene Switch cjkg + RELAY_STATUS_6 = "relay_status_6" # Scene Switch cjkg + RELAY_STATUS_7 = "relay_status_7" # Scene Switch cjkg + RELAY_STATUS_8 = "relay_status_8" # Scene Switch cjkg + REMAIN_TIME = "remain_time" + REMOTE_REGISTER = "remote_register" + REMOTE_UNLOCK_SWITCH = "remote_unlock_switch" + REPORT_PERIOD_SET = "report_period_set" + REPORT_RATE_CONTROL = "report_rate_control" + RESET_DUSTER_CLOTH = "reset_duster_cloth" + RESET_EDGE_BRUSH = "reset_edge_brush" + RESET_FILTER = "reset_filter" + RESET_LIMIT = "reset_limit" + RESET_MAP = "reset_map" + RESET_ROLL_BRUSH = "reset_roll_brush" + RESIDUAL_ELECTRICITY = "residual_electricity" + REVERSE_ENERGY_TOTAL = "reverse_energy_total" + ROLL_BRUSH = "roll_brush" + RUNNING_FAN_SPEED = "running_fan_speed" + SCENE_1 = "scene_1" + SCENE_10 = "scene_10" + SCENE_11 = "scene_11" + SCENE_12 = "scene_12" + SCENE_13 = "scene_13" + SCENE_14 = "scene_14" + SCENE_15 = "scene_15" + SCENE_16 = "scene_16" + SCENE_17 = "scene_17" + SCENE_18 = "scene_18" + SCENE_19 = "scene_19" + SCENE_2 = "scene_2" + SCENE_20 = "scene_20" + SCENE_3 = "scene_3" + SCENE_4 = "scene_4" + SCENE_5 = "scene_5" + SCENE_6 = "scene_6" + SCENE_7 = "scene_7" + SCENE_8 = "scene_8" + SCENE_9 = "scene_9" + SCENE_DATA = "scene_data" # Colored light mode + SCENE_DATA_V2 = "scene_data_v2" # Colored light mode + SEEK = "seek" + SENS = "sens" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + SENSITIVITY = "sensitivity" # Sensitivity + SENSORTYPE = "sensortype" + SENSOR_HUMIDITY = "sensor_humidity" + SENSOR_TEMPERATURE = "sensor_temperature" + SHAKE = "shake" # Oscillating + SHOCK_STATE = "shock_state" # Vibration status + SIREN_SWITCH = "siren_switch" + SITUATION_SET = "situation_set" + SLEEP = "sleep" # Sleep function + SLOW_FEED = "slow_feed" + SMART_WEATHER = "smart_weather" + SMOKE_SENSOR_STATE = "smoke_sensor_state" + SMOKE_SENSOR_STATUS = "smoke_sensor_status" + SMOKE_SENSOR_VALUE = "smoke_sensor_value" + SOS = "sos" # Emergency State + SOS_STATE = "sos_state" # Emergency mode + SOUND_EFFECTS = "sound_effects" + SOUND_MODE = "sound_mode" + SOURCE = "source" + SPEED = "speed" # Speed level + SPRAY_MODE = "spray_mode" # Spraying mode + SPRAY_VOLUME = "spray_volume" # Dehumidifier + STA = "sta" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + START = "start" # Start + STATUS = "status" + STERILIZATION = "sterilization" # Sterilization + STRIP_DIRECTION = "strip_direction" + STRIP_INPUT_POS = "strip_input_pos" + STUDY_CODE = "study_code" + SUB_CLASS = "sub_class" + SUB_STATE = "sub_state" + SUB_TYPE = "sub_type" + SUCTION = "suction" + SWING = "swing" # Swing mode + SWITCH = "switch" # Switch + SWITCH1 = "switch1" # Switch 1 no underscore + SWITCH1_VALUE = "switch1_value" # scene switch "wxkg" + SWITCH2 = "switch2" # Switch 2 no underscore + SWITCH2_VALUE = "switch2_value" # scene switch "wxkg" + SWITCH3 = "switch3" # Switch 3 no underscore + SWITCH3_VALUE = "switch3_value" # scene switch "wxkg" + SWITCH4 = "switch4" # Switch 4 no underscore + SWITCH4_VALUE = "switch4_value" # scene switch "wxkg" + SWITCH5 = "switch5" # Switch 5 no underscore + SWITCH5_VALUE = "switch5_value" # scene switch "wxkg" + SWITCH6 = "switch6" # Switch 6 no underscore + SWITCH6_VALUE = "switch6_value" # scene switch "wxkg" + SWITCH7 = "switch7" # Switch 7 no underscore + SWITCH8 = "switch8" # Switch 8 no underscore + SWITCH_1 = "switch_1" # Switch 1 + SWITCH_2 = "switch_2" # Switch 2 + SWITCH_3 = "switch_3" # Switch 3 + SWITCH_4 = "switch_4" # Switch 4 + SWITCH_5 = "switch_5" # Switch 5 + SWITCH_6 = "switch_6" # Switch 6 + SWITCH_7 = "switch_7" # Switch 7 + SWITCH_8 = "switch_8" # Switch 8 + SWITCH_ALARM_CALL = "switch_alarm_call" + SWITCH_ALARM_LIGHT = "switch_alarm_light" + SWITCH_ALARM_PROPEL = "switch_alarm_propel" + SWITCH_ALARM_SMS = "switch_alarm_sms" + SWITCH_ALARM_SOUND = "switch_alarm_sound" + SWITCH_BACKLIGHT = "switch_backlight" # Backlight switch + SWITCH_CHARGE = "switch_charge" + SWITCH_COLD = "switch_cold" + SWITCH_CONTROLLER = "switch_controller" + SWITCH_DISTURB = "switch_disturb" + SWITCH_FAN = "switch_fan" + SWITCH_HORIZONTAL = "switch_horizontal" # Horizontal swing flap switch + SWITCH_KB_LIGHT = "switch_kb_light" + SWITCH_KB_SOUND = "switch_kb_sound" + SWITCH_LED = "switch_led" # Switch + SWITCH_LED_1 = "switch_led_1" + SWITCH_LED_2 = "switch_led_2" + SWITCH_LED_3 = "switch_led_3" + SWITCH_LED_4 = "switch_led_4" + SWITCH_NIGHT_LIGHT = "switch_night_light" + SWITCH_SAVE_ENERGY = "switch_save_energy" + SWITCH_SOUND = "switch_sound" # Voice switch + SWITCH_SPRAY = "switch_spray" # Spraying switch + SWITCH_STOP = "switch_stop" + SWITCH_TYPE_1 = "switch_type_1" + SWITCH_TYPE_2 = "switch_type_2" + SWITCH_TYPE_3 = "switch_type_3" + SWITCH_TYPE_4 = "switch_type_4" + SWITCH_TYPE_5 = "switch_type_5" + SWITCH_USB1 = "switch_usb1" # USB 1 + SWITCH_USB2 = "switch_usb2" # USB 2 + SWITCH_USB3 = "switch_usb3" # USB 3 + SWITCH_USB4 = "switch_usb4" # USB 4 + SWITCH_USB5 = "switch_usb5" # USB 5 + SWITCH_USB6 = "switch_usb6" # USB 6 + SWITCH_VERTICAL = "switch_vertical" # Vertical swing flap switch + SWITCH_VOICE = "switch_voice" # Voice switch + SWITCH_WEATHER = "switch_weather" + SWITCH_WELCOME = "switch_welcome" + SYNC_REQUEST = "sync_request" + SYNC_RESPONSE = "sync_response" + SYSTEMMODE = "systemmode" + TBD = "tbd" + TEMP = "temp" # Temperature setting + TEMPACTIVATE = "tempactivate" + TEMPCOMP = "tempcomp" + TEMPCURRENT = "tempcurrent" # Current temperature in °C + TEMPERATURE = "temperature" + TEMPER_ALARM = "temper_alarm" # Tamper alarm + TEMPFLOOR = "TempFloor" + TEMPPROGRAM = "tempprogram" + TEMP_ALARM = "temp_alarm" + TEMP_BOILING_C = "temp_boiling_c" + TEMP_BOILING_F = "temp_boiling_f" + TEMP_CONTROLLER = "temp_controller" + TEMP_CURRENT = "temp_current" # Current temperature in °C + TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F + TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C + TEMP_LOW = "temp_low" + TEMP_PERIODIC_REPORT = "temp_periodic_report" + TEMP_SENSITIVITY = "temp_sensitivity" + TEMP_SET = "temp_set" # Set the temperature in °C + TEMP_SET_F = "temp_set_f" # Set the temperature in °F + TEMP_STATUS = "temp_status" + TEMP_UNIT_CONVERT = "temp_unit_convert" # Temperature unit switching + TEMP_UP = "temp_up" + TEMP_VALUE = "temp_value" # Color temperature + TEMP_VALUE_V2 = "temp_value_v2" + TIM = "tim" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + TIMER = "timer" + TIME_TOTAL = "time_total" + TIME_USE = "time_use" + TODAY_ACC_ENERGY = "today_acc_energy" + TODAY_ACC_ENERGY1 = "today_acc_energy1" + TODAY_ACC_ENERGY2 = "today_acc_energy2" + TODAY_ENERGY_ADD = "today_energy_add" + TODAY_ENERGY_ADD1 = "today_energy_add1" + TODAY_ENERGY_ADD2 = "today_energy_add2" + TOTAL_CLEAN_AREA = "total_clean_area" + TOTAL_CLEAN_COUNT = "total_clean_count" + TOTAL_CLEAN_TIME = "total_clean_time" + TOTAL_ENERGY = "total_energy" + TOTAL_ENERGY1 = "total_energy1" + TOTAL_ENERGY2 = "total_energy2" + TOTAL_FORWARD_ENERGY = "total_forward_energy" + TOTAL_PM = "total_pm" + TOTAL_POWER = "total_power" + TOTAL_TIME = "total_time" + TREBLE_CONTROL = "treble_control" + TVOC = "tvoc" + TV_SIZE = "tv_size" + UNLOCK_APP = "unlock_app" + UNLOCK_BLE = "unlock_ble" + UNLOCK_CARD = "unlock_card" + UNLOCK_DOUBLE = "unlock_double" + UNLOCK_DYNAMIC = "unlock_dynamic" + UNLOCK_EYE = "unlock_eye" + UNLOCK_FACE = "unlock_face" + UNLOCK_FINGERPRINT = "unlock_fingerprint" + UNLOCK_FINGER_VEIN = "unlock_finger_vein" + UNLOCK_HAND = "unlock_hand" + UNLOCK_IDENTITY_CARD = "unlock_identity_card" + UNLOCK_KEY = "unlock_key" + UNLOCK_PASSWORD = "unlock_password" + UNLOCK_PHONE_REMOTE = "unlock_phone_remote" + UNLOCK_REMOTE = "unlock_remote" + UNLOCK_REQUEST = "unlock_request" + UNLOCK_SPECIAL = "unlock_special" + UNLOCK_SWITCH = "unlock_switch" + UNLOCK_TEMPORARY = "unlock_temporary" + UNLOCK_VOICE_REMOTE = "unlock_voice_remote" + UPPER_TEMP = "upper_temp" + UPPER_TEMP_F = "upper_temp_f" + UP_CONFIRM = "up_confirm" # cover reset. + USE_TIME = "use_time" + USE_TIME_ONE = "use_time_one" + UV = "uv" # UV sterilization + VA_BATTERY = "va_battery" + VA_HUMIDITY = "va_humidity" + VA_TEMPERATURE = "va_temperature" + VIDEO_INTENSITY = "video_intensity" + VIDEO_MODE = "video_mode" + VIDEO_SCENE = "video_scene" + VOC_STATE = "voc_state" + VOC_VALUE = "voc_value" + VOICE_BT_PLAY = "voice_bt_play" + VOICE_LANGUAGE = "voice_language" + VOICE_MIC = "voice_mic" + VOICE_PLAY = "voice_play" + VOICE_SWITCH = "voice_switch" + VOICE_TIMES = "voice_times" + VOICE_VOL = "voice_vol" + VOLTAGE_A = "voltage_a" + VOLTAGE_COEF = "voltage_coef" + VOLTAGE_CURRENT = "voltage_current" + VOLUME_SET = "volume_set" + WARM = "warm" # Heat preservation + WARM_TIME = "warm_time" # Heat preservation time + WARN_POWER = "warn_power" + WARN_POWER1 = "warn_power1" + WARN_POWER2 = "warn_power2" + WATER = "water" + WATERSENSOR_STATE = "watersensor_state" + WATER_RESET = "water_reset" # Resetting of water usage days + WATER_SET = "water_set" # Water level + WATER_TEMP = "water_temp" + WATER_USE_DATA = "water_use_data" + WEATHER_DELAY = "weather_delay" + WET = "wet" # Humidification + WINDOWDETECT = "windowdetect" + WINDOW_CHECK = "window_check" + WINDOW_STATE = "window_state" + WINDSPEED = "windspeed" + WIRELESS_BATTERYLOCK = "wireless_batterylock" + WIRELESS_ELECTRICITY = "wireless_electricity" + WORK_MODE = "work_mode" # Working mode + WORK_POWER = "work_power" + WORK_STATE = "work_state" + WORK_STATUS = "work_status" + Y_MOP = "y_mop" + ZONE_ATTRIBUTE = "zone_attribute" + ZONE_NUMBER = "zone_number" diff --git a/custom_components/localtuya/core/ha_entities/binary_sensors.py b/custom_components/localtuya/core/ha_entities/binary_sensors.py new file mode 100644 index 00000000..748e5a77 --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/binary_sensors.py @@ -0,0 +1,424 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory +from homeassistant.components.binary_sensor import BinarySensorDeviceClass + +CONF_STATE_ON = "state_on" + +ALARM_ON = {CONF_STATE_ON: "alarm"} +STATE_TRUE = {CONF_STATE_ON: "true"} +ON_1 = {CONF_STATE_ON: "1"} +ON_FEEDING = {CONF_STATE_ON: "feeding"} +ON_PRESENCE = {CONF_STATE_ON: "presence"} + +ON_OPEN = {CONF_STATE_ON: "open"} +ON_OPENED = {CONF_STATE_ON: "opened"} + +ON_AQAB = {CONF_STATE_ON: "AQAB"} + + +def localtuya_binarySensor(state_on="1"): + """Define localtuya binary_sensor configs""" + data = {CONF_STATE_ON: state_on} + return data + + +# Commonly used sensors +TAMPER_BINARY_SENSOR = LocalTuyaEntity( + key=DPCode.TEMPER_ALARM, + name="Tamper", + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=STATE_TRUE, +) + +# Fault +FAULT_SENSOR = ( + LocalTuyaEntity( + id=DPCode.FAULT, + name="Fault", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=ON_1, + ), + LocalTuyaEntity( + id=DPCode.IDU_ERROR, + name="IDU Error", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=ON_1, + ), + # CZ - Energy monitor? + LocalTuyaEntity( + id=DPCode.POWER_TYPE, + name="Power State", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_binarySensor("warn"), + ), + LocalTuyaEntity( + id=DPCode.POWER_TYPE1, + name="Power 1 State", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_binarySensor("warn"), + ), + LocalTuyaEntity( + id=DPCode.POWER_TYPE2, + name="Power 2 State", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_binarySensor("warn"), + ), +) + + +BINARY_SENSORS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + LocalTuyaEntity( + id=DPCode.GAS_SENSOR_STATE, + name="Gas detection", + icon="mdi:gas-cylinder", + device_class=BinarySensorDeviceClass.GAS, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.CH4_SENSOR_STATE, + name="Methane detection", + device_class=BinarySensorDeviceClass.GAS, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.VOC_STATE, + name="VOC detection", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.PM10_STATE, + name="PM1.0 detection", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.PM25_STATE, + name="PM2.5 detection", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.PM100_STATE, + name="PM10 detection", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.CO_STATE, + name="CO detection", + icon="mdi:molecule-co", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.CO2_STATE, + name="CO2 detection", + icon="mdi:molecule-co2", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.CH2O_STATE, + name="Formaldehyde detection", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.DOORCONTACT_STATE, + name="Door", + device_class=BinarySensorDeviceClass.DOOR, + custom_configs=STATE_TRUE, + ), + LocalTuyaEntity( + id=DPCode.WATERSENSOR_STATE, + device_class=BinarySensorDeviceClass.MOISTURE, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.PRESSURE_STATE, + name="Pressure", + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.SMOKE_SENSOR_STATE, + name="Smoke detection", + icon="mdi:smoke-detector", + device_class=BinarySensorDeviceClass.SMOKE, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + LocalTuyaEntity( + id=DPCode.CO2_STATE, + icon="mdi:molecule-co2", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # CO Detector + # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v + "cobj": ( + LocalTuyaEntity( + id=DPCode.CO_STATE, + icon="mdi:molecule-co", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ON_1, + ), + LocalTuyaEntity( + id=DPCode.CO_STATUS, + icon="mdi:molecule-co", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + LocalTuyaEntity( + id=DPCode.FEED_STATE, + icon="mdi:information", + custom_configs=ON_FEEDING, + ), + ), + # Human Presence Sensor + # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs + "hps": ( + LocalTuyaEntity( + id=DPCode.PRESENCE_STATE, + device_class=BinarySensorDeviceClass.MOTION, + custom_configs=ON_PRESENCE, + ), + ), + # Formaldehyde Detector + # Note: Not documented + "jqbj": ( + LocalTuyaEntity( + id=DPCode.CH2O_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Methane Detector + # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm + "jwbj": ( + LocalTuyaEntity( + id=DPCode.CH4_SENSOR_STATE, + device_class=BinarySensorDeviceClass.GAS, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Door and Window Controller + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 + "mc": ( + LocalTuyaEntity( + id=DPCode.STATUS, + device_class=BinarySensorDeviceClass.DOOR, + custom_configs=ON_OPENED, + ), + LocalTuyaEntity( + id=DPCode.DOOR_UNCLOSED, + device_class=BinarySensorDeviceClass.DOOR, + custom_configs=STATE_TRUE, + ), + ), + # Door Window Sensor + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m + "mcs": ( + LocalTuyaEntity( + id=DPCode.DOORCONTACT_STATE, + device_class=BinarySensorDeviceClass.DOOR, + custom_configs=STATE_TRUE, + ), + TAMPER_BINARY_SENSOR, + ), + # Access Control + # https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet + "mk": ( + LocalTuyaEntity( + id=DPCode.CLOSED_OPENED_KIT, + device_class=BinarySensorDeviceClass.LOCK, + custom_configs=ON_AQAB, + ), + ), + # Luminance Sensor + # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 + "ldcg": ( + LocalTuyaEntity( + id=DPCode.TEMPER_ALARM, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=STATE_TRUE, + ), + TAMPER_BINARY_SENSOR, + ), + # PIR Detector + # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + "pir": ( + LocalTuyaEntity( + id=(DPCode.PIR, DPCode.PIR_STATE), + device_class=BinarySensorDeviceClass.MOTION, + custom_configs={CONF_STATE_ON: "pir"}, + ), + LocalTuyaEntity( + id=DPCode.STA, + device_class=BinarySensorDeviceClass.MOTION, + custom_configs={CONF_STATE_ON: "true"}, + ), + TAMPER_BINARY_SENSOR, + ), + # PM2.5 Sensor + # https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu + "pm2.5": ( + LocalTuyaEntity( + id=DPCode.PM25_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Gas Detector + # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw + "rqbj": ( + LocalTuyaEntity( + id=DPCode.GAS_SENSOR_STATUS, + device_class=BinarySensorDeviceClass.GAS, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.GAS_SENSOR_STATE, + device_class=BinarySensorDeviceClass.GAS, + custom_configs=ON_1, + ), + TAMPER_BINARY_SENSOR, + ), + # Water Detector + # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli + "sj": ( + LocalTuyaEntity( + id=DPCode.WATERSENSOR_STATE, + device_class=BinarySensorDeviceClass.MOISTURE, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Emergency Button + # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy + "sos": ( + LocalTuyaEntity( + id=DPCode.SOS_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=STATE_TRUE, + ), + TAMPER_BINARY_SENSOR, + ), + # Volatile Organic Compound Sensor + # Note: Undocumented in cloud API docs, based on test device + "voc": ( + LocalTuyaEntity( + id=DPCode.VOC_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Thermostatic Radiator Valve + # Not documented + "wkf": ( + LocalTuyaEntity( + id=DPCode.WINDOW_STATE, + device_class=BinarySensorDeviceClass.WINDOW, + custom_configs=ON_OPENED, + ), + ), + # Temperature and Humidity Sensor + # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 + "wsdcg": (TAMPER_BINARY_SENSOR,), + # Pressure Sensor + # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm + "ylcg": ( + LocalTuyaEntity( + id=DPCode.PRESSURE_STATE, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Smoke Detector + # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 + "ywbj": ( + LocalTuyaEntity( + id=DPCode.SMOKE_SENSOR_STATUS, + device_class=BinarySensorDeviceClass.SMOKE, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.SMOKE_SENSOR_STATE, + device_class=BinarySensorDeviceClass.SMOKE, + custom_configs=ALARM_ON, + condition_contains_any=["alarm"], + ), + LocalTuyaEntity( + id=DPCode.SMOKE_SENSOR_STATE, + device_class=BinarySensorDeviceClass.SMOKE, + custom_configs=ON_1, + ), + TAMPER_BINARY_SENSOR, + ), + # Vibration Sensor + # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno + "zd": ( + LocalTuyaEntity( + id=(DPCode.SHOCK_STATE, f"{DPCode.SHOCK_STATE}_vibration"), + device_class=BinarySensorDeviceClass.VIBRATION, + custom_configs={CONF_STATE_ON: "vibration"}, + condition_contains_any=["tilt", "true"], + ), + LocalTuyaEntity( + id=(DPCode.SHOCK_STATE, f"{DPCode.SHOCK_STATE}_drop"), + icon="mdi:icon=package-down", + custom_configs={CONF_STATE_ON: "drop"}, + condition_contains_any=["tilt", "true"], + ), + LocalTuyaEntity( + id=(DPCode.SHOCK_STATE, f"{DPCode.SHOCK_STATE}_tilt"), + name="Tilt", + icon="mdi:spirit-level", + custom_configs={CONF_STATE_ON: "tilt"}, + condition_contains_any=["tilt", "true"], + ), + ), +} + +BINARY_SENSORS["cl"] = FAULT_SENSOR +BINARY_SENSORS["wk"] = FAULT_SENSOR +BINARY_SENSORS["kg"] = FAULT_SENSOR +BINARY_SENSORS["pc"] = FAULT_SENSOR +BINARY_SENSORS["cz"] = FAULT_SENSOR +BINARY_SENSORS["cs"] = FAULT_SENSOR +BINARY_SENSORS["jsq"] = FAULT_SENSOR +BINARY_SENSORS["kt"] = FAULT_SENSOR +BINARY_SENSORS["sd"] = FAULT_SENSOR +BINARY_SENSORS["sfkzq"] = FAULT_SENSOR diff --git a/custom_components/localtuya/core/ha_entities/buttons.py b/custom_components/localtuya/core/ha_entities/buttons.py new file mode 100644 index 00000000..91af7068 --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/buttons.py @@ -0,0 +1,186 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory + +BUTTONS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Scene Switch + # https://developer.tuya.com/en/docs/iot/f?id=K9gf7nx6jelo8 + "cjkg": ( + LocalTuyaEntity( + id=DPCode.SCENE_1, + name="Scene 1", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_2, + name="Scene 2", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_3, + name="Scene 3", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_4, + name="Scene 4", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_5, + name="Scene 5", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_6, + name="Scene 6", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_7, + name="Scene 7", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_8, + name="Scene 8", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_9, + name="Scene 9", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_10, + name="Scene 10", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_11, + name="Scene 11", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_12, + name="Scene 12", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_13, + name="Scene 13", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_14, + name="Scene 14", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_15, + name="Scene 15", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_16, + name="Scene 16", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_17, + name="Scene 17", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_18, + name="Scene 18", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_18, + name="Scene 18", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_19, + name="Scene 19", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_20, + name="Scene 20", + icon="mdi:palette", + ), + ), + # Curtain + # Note: Multiple curtains isn't documented + # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df + "cl": ( + LocalTuyaEntity( + id=DPCode.REMOTE_REGISTER, + name="Pair Remote", + icon="mdi:remote", + entity_category=EntityCategory.CONFIG, + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + LocalTuyaEntity( + id=DPCode.RESET_DUSTER_CLOTH, + name="Reset Duster Cloth", + icon="mdi:restart", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.RESET_EDGE_BRUSH, + name="Reset Edge Brush", + icon="mdi:restart", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.RESET_FILTER, + name="Reset Filter", + icon="mdi:air-filter", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.RESET_MAP, + name="Reset Map", + icon="mdi:map-marker-remove", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.RESET_ROLL_BRUSH, + name="Reset Roll Brush", + icon="mdi:restart", + entity_category=EntityCategory.CONFIG, + ), + ), + # Wake Up Light II + # Not documented + "hxd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_USB6, + name="Snooze", + icon="mdi:sleep", + ), + ), + "cz": ( + LocalTuyaEntity( + id=DPCode.CLEAR_ENERGY, + name="Clear Energy", + icon="mdi:lightning-bolt-circle", + entity_category=EntityCategory.CONFIG, + ), + ), +} + +# Wireless Switch # also can come as knob switch. +# https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5 +BUTTONS["wxkg"] = BUTTONS["cjkg"] diff --git a/custom_components/localtuya/core/ha_entities/climates.py b/custom_components/localtuya/core/ha_entities/climates.py new file mode 100644 index 00000000..e91018cc --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/climates.py @@ -0,0 +1,268 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from homeassistant.components.climate import ( + HVACMode, + HVACAction, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, +) +from homeassistant.const import CONF_TEMPERATURE_UNIT + +from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE +from ...const import ( + CONF_ECO_VALUE, + CONF_HVAC_ACTION_SET, + CONF_HVAC_MODE_SET, + CONF_PRECISION, + CONF_PRESET_SET, + CONF_TARGET_PRECISION, + CONF_TEMPERATURE_STEP, + CONF_HVAC_ACTION_DP, + CONF_HVAC_MODE_DP, + CONF_CURRENT_TEMPERATURE_DP, + CONF_MAX_TEMP, + CONF_MIN_TEMP, + CONF_FAN_SPEED_LIST, + CONF_FAN_SPEED_DP, + CONF_TARGET_TEMPERATURE_DP, + CONF_PRESET_DP, +) + + +UNIT_C = "celsius" +UNIT_F = "fahrenheit" + +FAN_SPEEDS_DEFAULT = "auto,low,middle,high" + + +def localtuya_climate( + hvac_mode_set=None, + temp_step=1, + actions_set=None, + echo_value=None, + preset_set=None, + fans_speeds=FAN_SPEEDS_DEFAULT, + unit=None, + min_temperature=7, + max_temperature=35, + values_precsion=0.1, + target_precision=1, +) -> dict: + """Create localtuya climate configs""" + data = {} + for key, conf in { + CONF_HVAC_MODE_SET: CLOUD_VALUE( + hvac_mode_set, CONF_HVAC_MODE_DP, "range", dict, MAP_CLIMATE_MODES, True + ), + CONF_MIN_TEMP: CLOUD_VALUE( + min_temperature, CONF_TARGET_TEMPERATURE_DP, "min", scale=True + ), + CONF_MAX_TEMP: CLOUD_VALUE( + max_temperature, CONF_TARGET_TEMPERATURE_DP, "max", scale=True + ), + CONF_TEMPERATURE_STEP: CLOUD_VALUE( + str(temp_step), CONF_TARGET_TEMPERATURE_DP, "step", str, scale=True + ), + CONF_HVAC_ACTION_SET: CLOUD_VALUE( + actions_set, CONF_HVAC_ACTION_DP, "range", dict, MAP_CLIMATE_ACTIONS, True + ), + CONF_FAN_SPEED_LIST: CLOUD_VALUE(fans_speeds, CONF_FAN_SPEED_DP, "range", str), + CONF_ECO_VALUE: echo_value, + CONF_PRESET_SET: CLOUD_VALUE(preset_set, CONF_PRESET_DP, "range", dict), + CONF_TEMPERATURE_UNIT: unit, + CONF_PRECISION: CLOUD_VALUE( + str(values_precsion), CONF_CURRENT_TEMPERATURE_DP, "scale", str + ), + CONF_TARGET_PRECISION: CLOUD_VALUE( + str(target_precision), CONF_TARGET_TEMPERATURE_DP, "scale", str + ), + }.items(): + if conf: + data.update({key: conf}) + + return data + + +# Map used for cloud value obtain. +MAP_CLIMATE_MODES = { + "off": HVACMode.OFF, + "auto": HVACMode.AUTO, + "cold": HVACMode.COOL, + "freeze": HVACMode.COOL, + "cooling": HVACMode.COOL, + "hot": HVACMode.HEAT, + "heating": HVACMode.HEAT, + "manual": HVACMode.HEAT_COOL, + "wet": HVACMode.DRY, + "dehum": HVACMode.DRY, + "wind": HVACMode.FAN_ONLY, + "fan": HVACMode.FAN_ONLY, + "off": HVACMode.OFF, + "0": HVACMode.COOL, + "1": HVACMode.HEAT, + "2": HVACMode.FAN_ONLY, +} +MAP_CLIMATE_ACTIONS = { + "heating": HVACAction.HEATING, + "cooling": HVACAction.COOLING, + "warming": HVACAction.IDLE, + "opened": HVACAction.HEATING, + "closed": HVACAction.IDLE, +} + +CLIMATES: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Air conditioner + # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + "kt": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + current_temperature_dp=( + DPCode.TEMP_CURRENT, + DPCode.TEMP_CURRENT_F, + DPCode.TEMPCURRENT, + ), + hvac_mode_dp=(DPCode.SYSTEMMODE, DPCode.MODE), + hvac_action_dp=(DPCode.WORK_MODE, DPCode.WORK_STATUS, DPCode.WORK_STATE), + preset_dp=DPCode.MODE, + fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + custom_configs=localtuya_climate( + hvac_mode_set={ + HVACMode.AUTO: "auto", + HVACMode.COOL: "cold", + HVACMode.HEAT: "hot", + HVACMode.DRY: "wet", + }, + preset_set={}, + temp_step=1, + actions_set={ + HVACAction.HEATING: "heating", + HVACAction.COOLING: "cooling", + }, + unit=UNIT_C, + values_precsion=0.1, + target_precision=0.1, + ), + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46epy4j82 + ## Converted to Water Heaters + # "qn": ( + # LocalTuyaEntity( + # id=DPCode.SWITCH, + # target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + # current_temperature_dp=(DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F), + # hvac_mode_dp=DPCode.SWITCH, + # hvac_action_dp=(DPCode.WORK_STATE, DPCode.WORK_MODE, DPCode.WORK_STATUS), + # preset_dp=DPCode.MODE, + # fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + # custom_configs=localtuya_climate( + # hvac_mode_set={ + # HVACMode.OFF: False, + # HVACMode.HEAT: True, + # }, + # temp_step=1, + # actions_set={ + # HVACAction.HEATING: True, + # HVACAction.IDLE: False, + # }, + # values_precsion=0.1, + # target_precision=0.1, + # preset_set={}, + # ), + # ), + # ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx + ## Converted to Water Heaters + # "rs": ( + # LocalTuyaEntity( + # id=DPCode.SWITCH, + # target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + # current_temperature_dp=(DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F), + # hvac_action_dp=(DPCode.WORK_STATE, DPCode.WORK_MODE, DPCode.WORK_STATUS), + # preset_dp=DPCode.MODE, + # fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + # custom_configs=localtuya_climate( + # hvac_mode_set={ + # HVACMode.OFF: "off", + # HVACMode.HEAT: "hot", + # }, + # temp_step=1, + # actions_set={ + # HVACAction.HEATING: "heating", + # HVACAction.IDLE: "warming", + # }, + # unit=UNIT_C, + # values_precsion=0.1, + # target_precision=0.1, + # preset_set={}, + # ), + # ), + # ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": ( + LocalTuyaEntity( + id=(DPCode.SWITCH, DPCode.MODE), + target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + current_temperature_dp=( + DPCode.TEMP_CURRENT, + DPCode.TEMP_CURRENT_F, + DPCode.TEMPCURRENT, + ), + hvac_mode_dp=(DPCode.SYSTEMMODE, DPCode.SWITCH, DPCode.MODE), + hvac_action_dp=(DPCode.WORK_STATE, DPCode.WORK_MODE, DPCode.WORK_STATUS), + preset_dp=DPCode.MODE, + fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED, DPCode.SPEED), + custom_configs=localtuya_climate( + hvac_mode_set={HVACMode.HEAT: True, HVACMode.OFF: False}, + temp_step=1, + actions_set={ + HVACAction.HEATING: True, + HVACAction.IDLE: False, + }, + unit=UNIT_C, + values_precsion=0.1, + target_precision=0.1, + ), + ), + ), + # Thermostatic Radiator Valve + # Not documented + "wkf": ( + LocalTuyaEntity( + id=(DPCode.SWITCH, DPCode.MODE), + target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + current_temperature_dp=( + DPCode.TEMP_CURRENT, + DPCode.TEMP_CURRENT_F, + DPCode.TEMPCURRENT, + ), + hvac_mode_dp=(DPCode.SYSTEMMODE, DPCode.MODE), + hvac_action_dp=(DPCode.WORK_STATE, DPCode.WORK_MODE, DPCode.WORK_STATUS), + preset_dp=DPCode.MODE, + fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED, DPCode.SPEED), + custom_configs=localtuya_climate( + hvac_mode_set={ + HVACMode.HEAT: "manual", + HVACMode.AUTO: "auto", + }, + temp_step=1, + actions_set={HVACAction.HEATING: "opened", HVACAction.IDLE: "closed"}, + unit=UNIT_C, + values_precsion=0.1, + target_precision=0.1, + ), + ), + ), +} diff --git a/custom_components/localtuya/core/ha_entities/covers.py b/custom_components/localtuya/core/ha_entities/covers.py new file mode 100644 index 00000000..3c926870 --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/covers.py @@ -0,0 +1,142 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory +from homeassistant.components.cover import CoverDeviceClass + +# from const.py this is temporarily. +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" + + +def localtuya_cover(cmd_set, position_mode=None, inverted=False): + """Define localtuya cover configs""" + data = { + CONF_COMMANDS_SET: cmd_set, + CONF_POSITIONING_MODE: position_mode, + CONF_POSITION_INVERTED: inverted, + } + return data + + +COVERS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Curtain + # Note: Multiple curtains isn't documented + # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df + "cl": ( + LocalTuyaEntity( + id=DPCode.CONTROL, + name="Curtain", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_state=DPCode.SITUATION_SET, + current_position_dp=(DPCode.PERCENT_STATE, DPCode.PERCENT_CONTROL), + set_position_dp=DPCode.PERCENT_CONTROL, + ), + LocalTuyaEntity( + id=DPCode.CONTROL_2, + name="Curtain 2", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=(DPCode.PERCENT_STATE_2, DPCode.PERCENT_CONTROL_2), + set_position_dp=DPCode.PERCENT_CONTROL_2, + device_class=CoverDeviceClass.CURTAIN, + ), + LocalTuyaEntity( + id=DPCode.CONTROL_3, + name="Curtain 3", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=(DPCode.PERCENT_STATE_3, DPCode.PERCENT_CONTROL_3), + set_position_dp=DPCode.PERCENT_CONTROL_3, + device_class=CoverDeviceClass.CURTAIN, + ), + LocalTuyaEntity( + id=DPCode.CONTROL_4, + name="Curtain 4", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=(DPCode.PERCENT_STATE_4, DPCode.PERCENT_CONTROL_4), + set_position_dp=DPCode.PERCENT_CONTROL_4, + device_class=CoverDeviceClass.CURTAIN, + ), + LocalTuyaEntity( + id=DPCode.MACH_OPERATE, + name="Curtain", + custom_configs=localtuya_cover("fz_zz_stop", "position"), + current_position_dp=DPCode.POSITION, + set_position_dp=DPCode.POSITION, + device_class=CoverDeviceClass.CURTAIN, + ), + # switch_1 is an undocumented code that behaves identically to control + # It is used by the Kogan Smart Blinds Driver + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Blind", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=DPCode.PERCENT_CONTROL, + set_position_dp=DPCode.PERCENT_CONTROL, + device_class=CoverDeviceClass.BLIND, + ), + ), + # Garage Door Opener + # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee + "ckmkzq": ( + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Door", + custom_configs=localtuya_cover("open_close_stop", "position", True), + current_state=DPCode.DOORCONTACT_STATE, + device_class=CoverDeviceClass.GARAGE, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_2, + name="Door 2", + custom_configs=localtuya_cover("open_close_stop", "position", True), + current_state=DPCode.DOORCONTACT_STATE_2, + device_class=CoverDeviceClass.GARAGE, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_3, + name="Door 3", + custom_configs=localtuya_cover("open_close_stop", "position", True), + current_state=DPCode.DOORCONTACT_STATE_3, + device_class=CoverDeviceClass.GARAGE, + ), + ), + # Curtain Switch + # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 + "clkg": ( + LocalTuyaEntity( + id=DPCode.CONTROL, + name="Curtain", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=DPCode.PERCENT_CONTROL, + set_position_dp=DPCode.PERCENT_CONTROL, + device_class=CoverDeviceClass.CURTAIN, + ), + LocalTuyaEntity( + id=DPCode.CONTROL_2, + name="Curtain 2", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=DPCode.PERCENT_CONTROL_2, + set_position_dp=DPCode.PERCENT_CONTROL_2, + device_class=CoverDeviceClass.CURTAIN, + ), + ), + # Curtain Robot + # Note: Not documented + "jdcljqr": ( + LocalTuyaEntity( + id=DPCode.CONTROL, + name="Curtain", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=DPCode.PERCENT_STATE, + set_position_dp=DPCode.PERCENT_CONTROL, + device_class=CoverDeviceClass.CURTAIN, + ), + ), +} diff --git a/custom_components/localtuya/core/ha_entities/fans.py b/custom_components/localtuya/core/ha_entities/fans.py new file mode 100644 index 00000000..7ae8885a --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/fans.py @@ -0,0 +1,87 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import ( + DPCode, + LocalTuyaEntity, + CONF_DEVICE_CLASS, + EntityCategory, + CLOUD_VALUE, +) +from homeassistant.components.fan import DIRECTION_FORWARD, DIRECTION_REVERSE + +# from const.py this is temporarily +CONF_FAN_SPEED_CONTROL = "fan_speed_control" +CONF_FAN_OSCILLATING_CONTROL = "fan_oscillating_control" +CONF_FAN_DIRECTION = "fan_direction" + +CONF_FAN_SPEED_MIN = "fan_speed_min" +CONF_FAN_SPEED_MAX = "fan_speed_max" +CONF_FAN_DIRECTION_FWD = "fan_direction_forward" +CONF_FAN_DIRECTION_REV = "fan_direction_reverse" +CONF_FAN_DPS_TYPE = "fan_dps_type" +CONF_FAN_ORDERED_LIST = "fan_speed_ordered_list" + +FAN_SPEED_DP = ( + DPCode.FAN_SPEED_PERCENT, + DPCode.FAN_SPEED, + DPCode.SPEED, + DPCode.FAN_SPEED_ENUM, +) + +FANS_OSCILLATING = (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL) + + +def localtuya_fan(fwd, rev, min_speed, max_speed, order, dp_type): + """Define localtuya fan configs""" + data = { + CONF_FAN_DIRECTION_FWD: fwd, + CONF_FAN_DIRECTION_REV: rev, + CONF_FAN_SPEED_MIN: CLOUD_VALUE(min_speed, CONF_FAN_SPEED_CONTROL, "min"), + CONF_FAN_SPEED_MAX: CLOUD_VALUE(max_speed, CONF_FAN_SPEED_CONTROL, "max"), + CONF_FAN_ORDERED_LIST: CLOUD_VALUE(order, CONF_FAN_SPEED_CONTROL, "range", str), + CONF_FAN_DPS_TYPE: dp_type, + } + return data + + +FANS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Fan + "fs": ( + LocalTuyaEntity( + id=(DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), + name="Fan", + icon="mdi:fan", + fan_speed_control=FAN_SPEED_DP, + fan_direction=DPCode.FAN_DIRECTION, + fan_oscillating_control=FANS_OSCILLATING, + custom_configs=localtuya_fan( + DIRECTION_FORWARD, DIRECTION_REVERSE, 1, 100, "disabled", "int" + ), + ), + ), + # Normal switch with fan controller. + "tdq": ( + LocalTuyaEntity( + id=(DPCode.SWITCH_FAN, DPCode.FAN_SWITCH), + name="Fan", + icon="mdi:fan", + fan_speed_control=FAN_SPEED_DP, + fan_direction=DPCode.FAN_DIRECTION, + fan_oscillating_control=FANS_OSCILLATING, + custom_configs=localtuya_fan( + DIRECTION_FORWARD, DIRECTION_REVERSE, 1, 100, "disabled", "int" + ), + ), + ), +} +# Fan with Light +FANS["fsd"] = FANS["fs"] +# Fan wall switch +FANS["fskg"] = FANS["fs"] +# Air Purifier +FANS["kj"] = FANS["fs"] diff --git a/custom_components/localtuya/core/ha_entities/humidifiers.py b/custom_components/localtuya/core/ha_entities/humidifiers.py new file mode 100644 index 00000000..293980ba --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/humidifiers.py @@ -0,0 +1,84 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import ( + DPCode, + LocalTuyaEntity, + CONF_DEVICE_CLASS, + EntityCategory, + CLOUD_VALUE, +) +from homeassistant.components.humidifier import ( + HumidifierDeviceClass, + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, +) + +CONF_HUMIDIFIER_SET_HUMIDITY_DP = "humidifier_set_humidity_dp" +CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP = "humidifier_current_humidity_dp" +CONF_HUMIDIFIER_MODE_DP = "humidifier_mode_dp" +CONF_HUMIDIFIER_AVAILABLE_MODES = "humidifier_available_modes" + + +def localtuya_humidifier(modes): + """Define localtuya fan configs""" + + data = { + CONF_HUMIDIFIER_AVAILABLE_MODES: CLOUD_VALUE( + modes, CONF_HUMIDIFIER_MODE_DP, "range", dict + ), + ATTR_MIN_HUMIDITY: CLOUD_VALUE( + DEFAULT_MIN_HUMIDITY, CONF_HUMIDIFIER_SET_HUMIDITY_DP, "min" + ), + ATTR_MAX_HUMIDITY: CLOUD_VALUE( + DEFAULT_MAX_HUMIDITY, CONF_HUMIDIFIER_SET_HUMIDITY_DP, "max" + ), + } + return data + + +HUMIDIFIERS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + humidifier_current_humidity_dp=DPCode.HUMIDITY_INDOOR, + humidifier_set_humidity_dp=DPCode.DEHUMIDITY_SET_VALUE, + humidifier_mode_dp=(DPCode.MODE, DPCode.WORK_MODE), + custom_configs=localtuya_humidifier( + { + "dehumidify": "Dehumidify", + "drying": "Drying", + "continuous": "Continuous", + } + ), + device_class=HumidifierDeviceClass.DEHUMIDIFIER, + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + humidifier_current_humidity_dp=DPCode.HUMIDITY_CURRENT, + humidifier_set_humidity_dp=DPCode.HUMIDITY_SET, + humidifier_mode_dp=(DPCode.MODE, DPCode.WORK_MODE), + custom_configs=localtuya_humidifier( + { + "large": "Large", + "middle": "Middle", + "small": "Small", + } + ), + device_class=HumidifierDeviceClass.HUMIDIFIER, + ), + ), +} diff --git a/custom_components/localtuya/core/ha_entities/lights.py b/custom_components/localtuya/core/ha_entities/lights.py new file mode 100644 index 00000000..dfc580b9 --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/lights.py @@ -0,0 +1,423 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from typing import Any +from .base import DPCode, LocalTuyaEntity, EntityCategory, CLOUD_VALUE +from homeassistant.const import CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_SCENE + +from ...const import ( + CONF_BRIGHTNESS_LOWER, + CONF_BRIGHTNESS_UPPER, + CONF_COLOR_TEMP_MIN_KELVIN, + CONF_COLOR_TEMP_MAX_KELVIN, + CONF_COLOR_TEMP_REVERSE, + CONF_MUSIC_MODE, +) + + +def localtuya_light( + lower=29, upper=1000, min_kv=2700, max_kv=6500, temp_reverse=False, music_mode=False +) -> dict[str, Any | CLOUD_VALUE]: + """Define localtuya light configs""" + data = { + CONF_BRIGHTNESS_LOWER: CLOUD_VALUE(lower, CONF_BRIGHTNESS, "min"), + CONF_BRIGHTNESS_UPPER: CLOUD_VALUE(upper, CONF_BRIGHTNESS, "max"), + CONF_COLOR_TEMP_MIN_KELVIN: min_kv, # CLOUD_VALUE(min_kv, CONF_COLOR_TEMP, "min") + CONF_COLOR_TEMP_MAX_KELVIN: max_kv, # CLOUD_VALUE(max_kv, CONF_COLOR_TEMP, "max") + CONF_COLOR_TEMP_REVERSE: temp_reverse, + CONF_MUSIC_MODE: music_mode, + } + + return data + + +LIGHTS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Curtain Switch + # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 + "clkg": ( + LocalTuyaEntity( + id=DPCode.SWITCH_BACKLIGHT, + name="State light", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # String Lights + # https://developer.tuya.com/en/docs/iot/dc?id=Kaof7taxmvadu + "dc": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Strip Lights + # https://developer.tuya.com/en/docs/iot/dd?id=Kaof804aibg2l + "dd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + color_temp=(DPCode.TEMP_VALUE_V2, DPCode.TEMP_VALUE), + color=(DPCode.COLOUR_DATA_V2, DPCode.COLOUR_DATA), + scene=(DPCode.SCENE_DATA_V2, DPCode.SCENE_DATA), + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + # default_color_type=DEFAULT_COLOR_TYPE_DATA_V2, + ), + ), + # Light + # https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy + "dj": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + color_temp=(DPCode.TEMP_VALUE_V2, DPCode.TEMP_VALUE), + color=(DPCode.COLOUR_DATA_V2, DPCode.COLOUR_DATA), + scene=(DPCode.SCENE_DATA_V2, DPCode.SCENE_DATA), + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, True), + ), + # Not documented + # Based on multiple reports: manufacturer customized Dimmer 2 switches + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="light", + brightness=DPCode.BRIGHT_VALUE_1, + ), + ), + # Ceiling Fan Light + # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v + "fsd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + scene=(DPCode.SCENE_DATA, DPCode.SCENE_DATA_V2), + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + # Some ceiling fan lights use LIGHT for DPCode instead of SWITCH_LED + LocalTuyaEntity( + id=DPCode.LIGHT, + name=None, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Fan Switch + "fskg": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name="Light", + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + scene=(DPCode.SCENE_DATA, DPCode.SCENE_DATA_V2), + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + # Some ceiling fan lights use LIGHT for DPCode instead of SWITCH_LED + LocalTuyaEntity( + id=DPCode.LIGHT, + name=None, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Ambient Light + # https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g + "fwd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + scene=(DPCode.SCENE_DATA, DPCode.SCENE_DATA_V2), + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Motion Sensor Light + # https://developer.tuya.com/en/docs/iot/gyd?id=Kaof8a8hycfmy + "gyd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Humidifier Light + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color=DPCode.COLOUR_DATA_HSV, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + LocalTuyaEntity( + id=DPCode.SWITCH_BACKLIGHT, + name="State light", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + LocalTuyaEntity( + id=DPCode.LIGHT, + name="State light", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Air conditioner + # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + "kt": ( + LocalTuyaEntity( + id=DPCode.LIGHT, + name="State light", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Unknown light product + # Found as VECINO RGBW as provided by diagnostics + # Not documented + "mbd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color=DPCode.COLOUR_DATA, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Unknown product with light capabilities + # Fond in some diffusers, plugs and PIR flood lights + # Not documented + "qjdcz": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color=DPCode.COLOUR_DATA, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qn": ( + LocalTuyaEntity( + id=DPCode.LIGHT, + name="State light", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + LocalTuyaEntity( + id=DPCode.FLOODLIGHT_SWITCH, + brightness=DPCode.FLOODLIGHT_LIGHTNESS, + name="Floodlight", + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.BASIC_INDICATOR, + name="Indicator light", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgkg": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED_1, + brightness=DPCode.BRIGHT_VALUE_1, + brightness_upper=DPCode.BRIGHTNESS_MAX_1, + brightness_lower=DPCode.BRIGHTNESS_MIN_1, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED_2, + name="Light 2", + brightness=DPCode.BRIGHT_VALUE_2, + brightness_upper=DPCode.BRIGHTNESS_MAX_2, + brightness_lower=DPCode.BRIGHTNESS_MIN_2, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED_3, + name="Light 3", + brightness=DPCode.BRIGHT_VALUE_3, + brightness_upper=DPCode.BRIGHTNESS_MAX_3, + brightness_lower=DPCode.BRIGHTNESS_MIN_3, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Dimmer + # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 + "tgq": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + brightness_upper=DPCode.BRIGHTNESS_MAX_1, + brightness_lower=DPCode.BRIGHTNESS_MIN_1, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED_1, + name="Light 1", + brightness=DPCode.BRIGHT_VALUE_1, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED_2, + name="Light 2", + brightness=DPCode.BRIGHT_VALUE_2, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED_3, + name="Light 3", + brightness=DPCode.BRIGHT_VALUE_3, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED_4, + name="Light 4", + brightness=DPCode.BRIGHT_VALUE_4, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Wake Up Light II + # Not documented + "hxd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name="light", + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + brightness_upper=DPCode.BRIGHTNESS_MAX_1, + brightness_lower=DPCode.BRIGHTNESS_MIN_1, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Solar Light + # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 + "tyndj": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Ceiling Light + # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r + "xdd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_NIGHT_LIGHT, + name="night_light", + ), + ), + # Remote Control + # https://developer.tuya.com/en/docs/iot/ykq?id=Kaof8ljn81aov + "ykq": ( + LocalTuyaEntity( + id=DPCode.SWITCH_CONTROLLER, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_CONTROLLER, + color_temp=DPCode.TEMP_CONTROLLER, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + LocalTuyaEntity( + id=DPCode.LIGHT, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name="light_2", + brightness=DPCode.BRIGHT_VALUE_1, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), +} + +# HDMI Sync Box A1 +LIGHTS["hdmipmtbq"] = ( + *LIGHTS["tgkg"], + *LIGHTS["dj"], +) + +# Dimmer +LIGHTS["tdq"] = LIGHTS["tgkg"] + +# Scene Switch +# https://developer.tuya.com/en/docs/iot/f?id=K9gf7nx6jelo8 +LIGHTS["cjkg"] = LIGHTS["tgkg"] + +# Wireless Switch # also can come as knob switch. +# https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5 +LIGHTS["wxkg"] = LIGHTS["tgkg"] + + +# Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +LIGHTS["cz"] = LIGHTS["kg"] + +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +LIGHTS["pc"] = LIGHTS["kg"] + +# Dehumidifier +# https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha +LIGHTS["cs"] = LIGHTS["jsq"] diff --git a/custom_components/localtuya/core/ha_entities/locks.py b/custom_components/localtuya/core/ha_entities/locks.py new file mode 100644 index 00000000..ce2490a5 --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/locks.py @@ -0,0 +1,32 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import ( + DPCode, + LocalTuyaEntity, +) + + +def localtuya_lock(): + """Define localtuya lock configs""" + data = {} + return data + + +LOCKS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Locks + "ms": ( + LocalTuyaEntity( + id=(DPCode.REMOTE_UNLOCK_SWITCH, DPCode.SWITCH), + jammed_dp=DPCode.HIJACK, + lock_state_dp=(DPCode.CLOSED_OPENED, DPCode.OPEN_CLOSE), + ), + ), +} + +LOCKS["jtmspro"] = LOCKS["ms"] +LOCKS["jtmsbh"] = LOCKS["ms"] diff --git a/custom_components/localtuya/core/ha_entities/numbers.py b/custom_components/localtuya/core/ha_entities/numbers.py new file mode 100644 index 00000000..e0f17a8b --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/numbers.py @@ -0,0 +1,951 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from homeassistant.components.number import NumberDeviceClass +from homeassistant.const import ( + PERCENTAGE, + UnitOfTime, + UnitOfPower, + UnitOfTemperature, + CONF_UNIT_OF_MEASUREMENT, + UnitOfLength, +) + +from .base import DPCode, LocalTuyaEntity, EntityCategory, CLOUD_VALUE +from ...const import CONF_MIN_VALUE, CONF_MAX_VALUE, CONF_STEPSIZE, CONF_SCALING + + +def localtuya_numbers(_min, _max, _step=1, _scale=1, unit=None) -> dict: + """Will return dict with CONF MIN AND CONF MAX, scale 1 is default, 1=1""" + data = { + CONF_MIN_VALUE: CLOUD_VALUE(_min, "id", "min"), + CONF_MAX_VALUE: CLOUD_VALUE(_max, "id", "max"), + CONF_STEPSIZE: CLOUD_VALUE(_step, "id", "step"), + CONF_SCALING: CLOUD_VALUE(_scale, "id", "scale"), + } + + if unit: + data.update({CONF_UNIT_OF_MEASUREMENT: unit}) + + return data + + +NUMBERS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Smart panel with switches and zigbee hub ? + # Not documented + "dgnzk": ( + LocalTuyaEntity( + id=DPCode.VOICE_VOL, + name="Volume", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100), + icon="mdi:volume-equal", + ), + LocalTuyaEntity( + id=DPCode.PLAY_TIME, + name="Play time", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 7200, unit=UnitOfTime.SECONDS), + icon="mdi:motion-play-outline", + ), + LocalTuyaEntity( + id=DPCode.BASS_CONTROL, + name="Bass", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 15), + icon="mdi:speaker", + ), + LocalTuyaEntity( + id=DPCode.TREBLE_CONTROL, + name="Treble", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 15), + icon="mdi:music-clef-treble", + ), + ), + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + LocalTuyaEntity( + id=DPCode.ALARM_TIME, + name="Time", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 60), + ), + ), + # Smart Kettle + # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 + "bh": ( + LocalTuyaEntity( + id=DPCode.TEMP_SET, + name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100), + ), + LocalTuyaEntity( + id=DPCode.TEMP_SET_F, + name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(32, 212), + ), + LocalTuyaEntity( + id=DPCode.TEMP_BOILING_C, + name="Temperature After Boiling", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100), + ), + LocalTuyaEntity( + id=DPCode.TEMP_BOILING_F, + name="Temperature After Boiling", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(32, 212), + ), + LocalTuyaEntity( + id=DPCode.WARM_TIME, + name="Heat preservation time", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 360), + ), + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + LocalTuyaEntity( + id=DPCode.MANUAL_FEED, + name="Feed", + icon="mdi:bowl", + custom_configs=localtuya_numbers(1, 12), + ), + LocalTuyaEntity( + id=DPCode.VOICE_TIMES, + name="Voice prompt", + icon="mdi:microphone", + custom_configs=localtuya_numbers(0, 10), + ), + ), + # Light + # https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy + "dj": ( + LocalTuyaEntity( + id=DPCode.COUNTDOWN_1, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Light 1 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_2, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Light 2 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_3, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Light 3 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_4, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Light 4 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + ), + # Human Presence Sensor + # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs + "hps": ( + LocalTuyaEntity( + id=DPCode.SENSITIVITY, + name="sensitivity", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 9), + ), + LocalTuyaEntity( + id=DPCode.NEAR_DETECTION, + name="Near Detection CM", + icon="mdi:signal-distance-variant", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 1000), + ), + LocalTuyaEntity( + id=DPCode.FAR_DETECTION, + name="Far Detection CM", + icon="mdi:signal-distance-variant", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 1000), + ), + ), + # Coffee maker + # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f + "kfj": ( + LocalTuyaEntity( + id=DPCode.WATER_SET, + name="Water Level", + icon="mdi:cup-water", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 500), + ), + LocalTuyaEntity( + id=DPCode.TEMP_SET, + name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100), + ), + LocalTuyaEntity( + id=DPCode.WARM_TIME, + name="Heat preservation time", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 1440), + ), + LocalTuyaEntity( + id=DPCode.POWDER_SET, + name="Powder", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 24), + ), + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + LocalTuyaEntity( + id=DPCode.COUNTDOWN_1, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch 1 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_2, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch 2 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_3, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch 3 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_4, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch 4 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_5, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch 5 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_6, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch 6 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB1, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="USB1 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB2, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="USB2 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB3, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="USB3 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB4, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="USB4 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB5, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="USB5 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB6, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="USB6 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + # CZ - Energy monitor? + LocalTuyaEntity( + id=DPCode.WARN_POWER, + icon="mdi:alert-outline", + entity_category=EntityCategory.CONFIG, + name="Power Wanring Limit", + custom_configs=localtuya_numbers(0, 50000, 1, 1, UnitOfPower.WATT), + ), + LocalTuyaEntity( + id=DPCode.WARN_POWER1, + icon="mdi:alert-outline", + entity_category=EntityCategory.CONFIG, + name="Power 1 Wanring Limit", + custom_configs=localtuya_numbers(0, 50000, 1, 1, UnitOfPower.WATT), + ), + LocalTuyaEntity( + id=DPCode.WARN_POWER2, + icon="mdi:alert-outline", + entity_category=EntityCategory.CONFIG, + name="Power 2 Wanring Limit", + custom_configs=localtuya_numbers(0, 50000, 1, 1, UnitOfPower.WATT), + ), + LocalTuyaEntity( + id=DPCode.POWER_ADJUSTMENT, + icon="mdi:generator-mobile", + entity_category=EntityCategory.CONFIG, + name="Power Adjustment", + custom_configs=localtuya_numbers(20, 100, 1, 1, PERCENTAGE), + ), + # Fan "tdq" + LocalTuyaEntity( + id=DPCode.FAN_COUNTDOWN, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Fan Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.FAN_COUNTDOWN_2, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Fan 2 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.FAN_COUNTDOWN_3, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Fan 3 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.FAN_COUNTDOWN_4, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Fan 4 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + ), + # Smart Lock + # https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet + "mc": ( + LocalTuyaEntity( + id=( + DPCode.UNLOCK_APP, + DPCode.UNLOCK_FINGERPRINT, + DPCode.UNLOCK_CARD, + DPCode.UNLOCK_DYNAMIC, + DPCode.UNLOCK_TEMPORARY, + ), + name="Temporary Unlock", + icon="mdi:lock-open", + custom_configs=localtuya_numbers(0, 999, 1, 1, UnitOfTime.SECONDS), + ), + ), + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + LocalTuyaEntity( + id=DPCode.COOK_TEMPERATURE, + name="Cooking temperature", + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 500), + ), + LocalTuyaEntity( + id=DPCode.COOK_TIME, + name="Cooking time", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 360, 1, 1, UnitOfTime.MINUTES), + ), + LocalTuyaEntity( + id=DPCode.CLOUD_RECIPE_NUMBER, + name="Cloud Recipes", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 999999), + ), + LocalTuyaEntity( + id=DPCode.APPOINTMENT_TIME, + name="Appointment time", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 360), + ), + ), + # PIR Detector + # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + "pir": ( + LocalTuyaEntity( + id=DPCode.SENS, + icon="mdi:signal-distance-variant", + entity_category=EntityCategory.CONFIG, + name="Sensitivity", + custom_configs=localtuya_numbers(0, 4), + ), + LocalTuyaEntity( + id=DPCode.TIM, + icon="mdi:timer-10", + entity_category=EntityCategory.CONFIG, + name="Timer Duration", + custom_configs=localtuya_numbers(10, 900, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.LUX, + icon="mdi:brightness-6", + entity_category=EntityCategory.CONFIG, + name="Light level", + custom_configs=localtuya_numbers(0, 981, 1, 1, "lx"), + ), + LocalTuyaEntity( + id=DPCode.INTERVAL_TIME, + icon="mdi:timer-sand-complete", + entity_category=EntityCategory.CONFIG, + name="Interval", + custom_configs=localtuya_numbers(1, 720, 1, 1, UnitOfTime.MINUTES), + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + LocalTuyaEntity( + id=DPCode.VOLUME_SET, + name="volume", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100), + ), + ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": ( + LocalTuyaEntity( + id=(DPCode.ALARM_TIME, DPCode.ALARMPERIOD), + name="Alarm duration", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(1, 60), + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + LocalTuyaEntity( + id=DPCode.BASIC_DEVICE_VOLUME, + name="volume", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(1, 10), + ), + LocalTuyaEntity( + id=DPCode.FLOODLIGHT_LIGHTNESS, + name="Floodlight brightness", + icon="mdi:brightness-6", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(1, 100), + ), + ), + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgkg": ( + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MIN_1, + name="minimum_brightness", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MAX_1, + name="maximum_brightness", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MIN_2, + name="minimum_brightness_2", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MAX_2, + name="maximum_brightness_2", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MIN_3, + name="minimum_brightness_3", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MAX_3, + name="maximum_brightness_3", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + ), + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgq": ( + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MIN_1, + name="minimum_brightness", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MAX_1, + name="maximum_brightness", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MIN_2, + name="minimum_brightness_2", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MAX_2, + name="maximum_brightness_2", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + ), + # Vibration Sensor + # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno + "zd": ( + LocalTuyaEntity( + id=DPCode.SENSITIVITY, + name="Sensitivity", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 9), + ), + ), + # Fingerbot + # arm_down_percent: "{\"min\":50,\"max\":100,\"scale\":0,\"step\":1}" + # arm_up_percent: "{\"min\":0,\"max\":50,\"scale\":0,\"step\":1}" + # click_sustain_time: "values": "{\"unit\":\"s\",\"min\":2,\"max\":10,\"scale\":0,\"step\":1}" + "szjqr": ( + LocalTuyaEntity( + id=DPCode.ARM_DOWN_PERCENT, + name="Move Down", + icon="mdi:arrow-down-bold", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(50, 100, 1, 1, PERCENTAGE), + ), + LocalTuyaEntity( + id=DPCode.ARM_UP_PERCENT, + name="Move UP", + icon="mdi:arrow-up-bold", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 50, 1, 1, PERCENTAGE), + ), + LocalTuyaEntity( + id=DPCode.CLICK_SUSTAIN_TIME, + name="Down Delay", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(2, 10), + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + LocalTuyaEntity( + id=DPCode.TEMP, + name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer-lines", + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + LocalTuyaEntity( + id=DPCode.TEMP_SET, + name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer-lines", + custom_configs=localtuya_numbers(0, 50), + ), + LocalTuyaEntity( + id=DPCode.TEMP_SET_F, + name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer-lines", + custom_configs=localtuya_numbers(32, 212, 1), + ), + ), + # Thermostat + "wk": ( + LocalTuyaEntity( + id=DPCode.TEMPCOMP, + name="Calibration offset", + custom_configs=localtuya_numbers(-9, 9), + ), + LocalTuyaEntity( + id=DPCode.TEMPACTIVATE, + name="Calibration swing", + custom_configs=localtuya_numbers(1, 9), + ), + ), + # Temperature and Humidity Sensor + # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 + "wsdcg": ( + LocalTuyaEntity( + id=(DPCode.MAXTEMP_SET, DPCode.UPPER_TEMP, DPCode.UPPER_TEMP_F), + name="Max Temperature", + icon="mdi:thermometer-high", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(-200, 600, unit=UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=(DPCode.MINITEMP_SET, DPCode.LOWER_TEMP, DPCode.LOWER_TEMP_F), + name="Min Temperature", + icon="mdi:thermometer-low", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(-200, 600, unit=UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=(DPCode.MAXHUM_SET, DPCode.MAX_HUMI), + name="Max Humidity", + icon="mdi:water-percent", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100, unit=PERCENTAGE), + ), + LocalTuyaEntity( + id=(DPCode.MINIHUM_SET, DPCode.MIN_HUMI), + name="Min Humidity", + icon="mdi:water-percent", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100, unit=PERCENTAGE), + ), + LocalTuyaEntity( + id=DPCode.TEMP_PERIODIC_REPORT, + name="Report Temperature Period", + icon="mdi:timer-sand", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(1, 120, unit=UnitOfTime.MINUTES), + ), + LocalTuyaEntity( + id=DPCode.HUM_PERIODIC_REPORT, + name="Report Humidity Period", + icon="mdi:timer-sand", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(1, 120, unit=UnitOfTime.MINUTES), + ), + LocalTuyaEntity( + id=DPCode.TEMP_SENSITIVITY, + name="Temperature Sensitivity", + icon="mdi:thermometer-lines", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(3, 20, unit=UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=DPCode.HUM_SENSITIVITY, + name="Humidity Sensitivity", + icon="mdi:water-opacity", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(3, 20, unit=PERCENTAGE), + ), + ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf + "mal": ( + LocalTuyaEntity( + id=DPCode.DELAY_SET, + name="Delay Setting", + custom_configs=localtuya_numbers(0, 65535), + icon="mdi:clock-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ALARM_TIME, + name="Duration", + custom_configs=localtuya_numbers(0, 65535), + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ALARM_DELAY_TIME, + name="Delay Alarm", + custom_configs=localtuya_numbers(0, 65535), + icon="mdi:history", + entity_category=EntityCategory.CONFIG, + ), + ), + # Air conditioner + # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + "kt": ( + LocalTuyaEntity( + id=DPCode.TIMER, + name="Timer", + custom_configs=localtuya_numbers(0, 24, unit=UnitOfTime.HOURS), + icon="mdi:timer-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Electricity Meter + # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 + "zndb": ( + LocalTuyaEntity( + id=DPCode.ENERGY_A_CALIBRATION_FWD, + name="Energy A Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:lightning-bolt-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_B_CALIBRATION_FWD, + name="Energy A Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:lightning-bolt-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_C_CALIBRATION_FWD, + name="Energy A Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:lightning-bolt-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_A_CALIBRATION_REV, + name="Reverse Energy A Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:lightning-bolt-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_B_CALIBRATION_REV, + name="Reverse Energy B Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:lightning-bolt-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_C_CALIBRATION_REV, + name="Reverse Energy C Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:lightning-bolt-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.CURRENT_A_CALIBRATION, + name="Current A Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:power-cycle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.CURRENT_B_CALIBRATION, + name="Current B Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:power-cycle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.CURRENT_C_CALIBRATION, + name="Current C Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:power-cycle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.POWER_A_CALIBRATION, + name="Power A Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:power-cycle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.POWER_B_CALIBRATION, + name="Power B Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:power-cycle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.POWER_C_CALIBRATION, + name="Power C Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:power-cycle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.FREQ_CALIBRATION, + name="Frequency Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:sine-wave", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.VOLTAGE_COEF, + name="Voltage Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:flash-triangle-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.REPORT_RATE_CONTROL, + name="Report Period", + custom_configs=localtuya_numbers(3, 60, unit=UnitOfTime.SECONDS), + icon="mdi:timer-sand", + entity_category=EntityCategory.CONFIG, + ), + ), + # Ultrasonic level sensor + "ywcgq": ( + LocalTuyaEntity( + id=DPCode.MAX_SET, + name="Maximum", + custom_configs=localtuya_numbers(0, 100, unit=PERCENTAGE), + icon="mdi:pan-top-right", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.MINI_SET, + name="Minimum", + custom_configs=localtuya_numbers(0, 100, unit=PERCENTAGE), + icon="mdi:pan-bottom-left", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.LIQUID_DEPTH_MAX, + name="Depth Maximum", + custom_configs=localtuya_numbers(100, 2400, unit=UnitOfLength.METERS), + icon="mdi:arrow-collapse-down", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.INSTALLATION_HEIGHT, + name="Installation Height", + custom_configs=localtuya_numbers( + 200, 2500, _scale=0.001, unit=UnitOfLength.METERS + ), + icon="mdi:table-row-height", + entity_category=EntityCategory.CONFIG, + ), + ), +} + +# Wireless Switch # also can come as knob switch. +# https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5 +NUMBERS["wxkg"] = ( + LocalTuyaEntity( + id=DPCode.TEMP_VALUE, + name="Temperature", + icon="mdi:thermometer", + custom_configs=localtuya_numbers(0, 1000), + ), + *NUMBERS["kg"], +) + +# Water Valve +NUMBERS["sfkzq"] = NUMBERS["kg"] + +# Water Detector +# https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli +NUMBERS["sj"] = NUMBERS["wsdcg"] + +# Circuit Breaker +# https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 +NUMBERS["dlq"] = NUMBERS["zndb"] + +# HDMI Sync Box A1 +NUMBERS["hdmipmtbq"] = NUMBERS["dj"] + +# Scene Switch +# https://developer.tuya.com/en/docs/iot/f?id=K9gf7nx6jelo8 +NUMBERS["cjkg"] = NUMBERS["kg"] + +NUMBERS["cz"] = NUMBERS["kg"] +NUMBERS["tdq"] = NUMBERS["kg"] +NUMBERS["pc"] = NUMBERS["kg"] + +# Locker +NUMBERS["bxx"] = NUMBERS["mc"] +NUMBERS["gyms"] = NUMBERS["mc"] +NUMBERS["jtmspro"] = NUMBERS["mc"] +NUMBERS["hotelms"] = NUMBERS["mc"] +NUMBERS["ms_category"] = NUMBERS["mc"] +NUMBERS["jtmsbh"] = NUMBERS["mc"] +NUMBERS["mk"] = NUMBERS["mc"] +NUMBERS["videolock"] = NUMBERS["mc"] +NUMBERS["photolock"] = NUMBERS["mc"] diff --git a/custom_components/localtuya/core/ha_entities/remotes.py b/custom_components/localtuya/core/ha_entities/remotes.py new file mode 100644 index 00000000..546ffed2 --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/remotes.py @@ -0,0 +1,31 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity + + +CONF_RECEIVE_DP = "receive_dp" + + +# def localtuya_remote(_): +# """Define localtuya fan configs""" +# data = {} +# return data + + +REMOTES: dict[str, tuple[LocalTuyaEntity, ...]] = { + # IR Remote + # not documented + "wnykq": ( + LocalTuyaEntity( + id=(DPCode.IR_SEND, DPCode.CONTROL), + receive_dp=(DPCode.IR_STUDY_CODE, DPCode.STUDY_CODE), + key_study_dp=DPCode.KEY_STUDY, + ), + ), +} diff --git a/custom_components/localtuya/core/ha_entities/selects.py b/custom_components/localtuya/core/ha_entities/selects.py new file mode 100644 index 00000000..84cbdddd --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/selects.py @@ -0,0 +1,1325 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import ( + DPCode, + LocalTuyaEntity, + CONF_DEVICE_CLASS, + EntityCategory, + CLOUD_VALUE, +) + +# from const.py this is temporarily. + +from ...select import CONF_OPTIONS as OPS_VALS + + +def localtuya_selector(options): + """Generate localtuya select configs""" + data = {OPS_VALS: CLOUD_VALUE(options, "id", "range", dict)} + return data + + +COUNT_DOWN = { + "cancel": "Disable", + "1": "1 Hour", + "2": "2 Hours", + "3": "3 Hours", + "4": "4 Hours", + "5": "5 Hours", + "6": "6 Hours", +} +COUNT_DOWN_HOURS = { + "off": "Disable", + "1h": "1 Hour", + "2h": "2 Hours", + "3h": "3 Hours", + "4h": "4 Hours", + "5h": "5 Hours", + "6h": "6 Hours", +} + +SELECTS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Smart panel with switches and zigbee hub ? + # Not documented + "dgnzk": ( + LocalTuyaEntity( + id=DPCode.SOURCE, + name="Source", + icon="mdi:volume-source", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "cloud": "Cloud", + "local": "Local", + "aux": "Aux", + "bluetooth": "Bluetooth", + } + ), + ), + LocalTuyaEntity( + id=DPCode.PLAY_MODE, + name="Mode", + icon="mdi:cog-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "order": "Order", + "repeat_all": "Repeat ALL", + "repeat_one": "Repeat one", + "random": "Random", + } + ), + ), + LocalTuyaEntity( + id=DPCode.SOUND_EFFECTS, + name="Sound Effects", + icon="mdi:sine-wave", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "normal": "Normal", + "pop": "Pop", + "opera": "Opera", + "classical": "Classical", + "jazz": "Jazz", + "rock": "Rock", + "folk": "Folk", + "heavy_metal": "Metal", + "hip_hop": "HipHop", + "wave": "Wave", + } + ), + ), + ), + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + LocalTuyaEntity( + id=DPCode.ALARM_VOLUME, + name="volume", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "low": "Low", + "middle": "Middle", + "high": "High", + "mute": "Mute", + } + ), + ), + LocalTuyaEntity( + id=DPCode.ALARM_RINGTONE, + name="Ringtone", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + } + ), + ), + ), + # Heater + "kt": ( + LocalTuyaEntity( + id=(DPCode.C_F, DPCode.TEMP_UNIT_CONVERT), + name="Temperature Unit", + custom_configs=localtuya_selector({"c": "Celsius", "f": "Fahrenheit"}), + ), + ), + # Heater + "rs": ( + LocalTuyaEntity( + id=(DPCode.C_F, DPCode.TEMP_UNIT_CONVERT), + name="Temperature Unit", + custom_configs=localtuya_selector({"c": "Celsius", "f": "Fahrenheit"}), + ), + LocalTuyaEntity( + id=DPCode.CRUISE_MODE, + name="Cruise mode", + custom_configs=localtuya_selector( + {"all_day": "Always", "water_control": "Water", "single_cruise": "Once"} + ), + ), + ), + # Coffee maker + # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f + "kfj": ( + LocalTuyaEntity( + id=DPCode.CUP_NUMBER, + name="Cups", + icon="mdi:numeric", + custom_configs=localtuya_selector( + { + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "6": "6", + "7": "7", + "8": "8", + "9": "9", + "10": "10", + "11": "11", + "12": "12", + } + ), + ), + LocalTuyaEntity( + id=DPCode.CONCENTRATION_SET, + name="Concentration", + icon="mdi:altimeter", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"regular": "REGULAR", "middle": "MIDDLE", "bold": "BOLD"} + ), + ), + LocalTuyaEntity( + id=DPCode.MATERIAL, + name="Material", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector({"bean": "BEAN", "powder": "POWDER"}), + ), + LocalTuyaEntity( + id=DPCode.MODE, + name="Mode", + icon="mdi:coffee", + custom_configs=localtuya_selector( + { + "espresso": "Espresso", + "americano": "Americano", + "machiatto": "Machiatto", + "caffe_latte": "Latte", + "caffe_mocha": "Mocha", + "cappuccino": "Cappuccino", + } + ), + ), + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + LocalTuyaEntity( + id=DPCode.RELAY_STATUS, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_1, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 1", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_1, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 1", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_1, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 1", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_2, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 2", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_2, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 2", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_2, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 2", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_3, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 3", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_3, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 3", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_3, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 3", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_4, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 4", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_4, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 4", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_4, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 4", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_5, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 5", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_5, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 5", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_5, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 5", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_6, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 6", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_6, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 6", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_6, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 6", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.LIGHT_MODE, + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"relay": "State", "pos": "Position", "none": "OFF"} + ), + name="Light Mode", + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qn": ( + LocalTuyaEntity( + id=DPCode.LEVEL, + name="Temperature Level", + icon="mdi:thermometer-lines", + custom_configs=localtuya_selector( + {"1": "Level 1", "2": " Levell 2", "3": " Level 3"} + ), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + name="Set Countdown", + icon="mdi:timer-cog-outline", + custom_configs=localtuya_selector(COUNT_DOWN), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_SET, + name="Set Countdown", + icon="mdi:timer-cog-outline", + custom_configs=localtuya_selector(COUNT_DOWN_HOURS), + ), + ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": ( + LocalTuyaEntity( + id=DPCode.ALARM_VOLUME, + name="Volume", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"low": "LOW", "middle": "MIDDLE", "high": "HIGH", "mute": "MUTE"} + ), + ), + LocalTuyaEntity( + id=DPCode.ALARM_STATE, + name="State", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "alarm_sound": "Sound", + "alarm_light": "Light", + "alarm_sound_light": "Sound and Light", + "normal": "NNORMAL", + } + ), + ), + LocalTuyaEntity( + id=DPCode.BRIGHT_STATE, + name="Brightness", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"low": "LOW", "middle": "MIDDLE", "high": "HIGH", "strong": "MAX"} + ), + ), + LocalTuyaEntity( + id=DPCode.ALARM_SETTING, + name="Alarm Setting", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"0": "Setting 1", "0": "Setting 2", "2": "Setting 3", "3": "Setting 4"} + ), + ), + LocalTuyaEntity( + id=DPCode.ALARMTYPE, + name="Alarm Setting", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "6": "6", + "7": "7", + "8": "8", + "9": "9", + "10": "10", + "11": "11", + "12": "12", + } + ), + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + LocalTuyaEntity( + id=DPCode.IPC_WORK_MODE, + entity_category=EntityCategory.CONFIG, + name="Working mode", + custom_configs=localtuya_selector({"0": "Low Power", "1": "Continuous"}), + ), + LocalTuyaEntity( + id=DPCode.DECIBEL_SENSITIVITY, + icon="mdi:volume-vibrate", + entity_category=EntityCategory.CONFIG, + name="Decibel Sensitivity", + custom_configs=localtuya_selector( + {"0": "Low Sensitivity", "1": "High Sensitivity"} + ), + ), + LocalTuyaEntity( + id=DPCode.RECORD_MODE, + icon="mdi:record-rec", + entity_category=EntityCategory.CONFIG, + name="Record Mode", + custom_configs=localtuya_selector( + {"1": "Record Events Only", "2": "Allways Record"} + ), + ), + LocalTuyaEntity( + id=DPCode.BASIC_NIGHTVISION, + icon="mdi:theme-light-dark", + entity_category=EntityCategory.CONFIG, + name="IR Night Vision", + custom_configs=localtuya_selector({"0": "Auto", "1": "OFF", "2": "ON"}), + ), + LocalTuyaEntity( + id=DPCode.BASIC_ANTI_FLICKER, + icon="mdi:image-outline", + entity_category=EntityCategory.CONFIG, + name="Anti-Flicker", + custom_configs=localtuya_selector( + {"0": "Disable", "1": "50 Hz", "2": "60 Hz"} + ), + ), + LocalTuyaEntity( + id=DPCode.MOTION_SENSITIVITY, + icon="mdi:motion-sensor", + entity_category=EntityCategory.CONFIG, + name="Motion Sensitivity", + custom_configs=localtuya_selector({"0": "Low", "1": "Medium", "2": "High"}), + ), + LocalTuyaEntity( + id=DPCode.PTZ_CONTROL, + icon="mdi:image-filter-tilt-shift", + entity_category=EntityCategory.CONFIG, + name="PTZ control", + custom_configs=localtuya_selector( + { + "0": "UP", + "1": "Upper Right", + "2": "Right", + "3": "Bottom Right", + "4": "Down", + "5": "Bottom Left", + "6": "Left", + "7": "Upper Left", + } + ), + ), + LocalTuyaEntity( + id=DPCode.FLIGHT_BRIGHT_MODE, + entity_category=EntityCategory.CONFIG, + name="Brightness mode", + custom_configs=localtuya_selector({"0": "Manual", "1": "Auto"}), + ), + LocalTuyaEntity( + id=DPCode.PIR_SENSITIVITY, + icon="mdi:ray-start-arrow", + entity_category=EntityCategory.CONFIG, + name="PIR Sensitivity", + custom_configs=localtuya_selector({"0": "Low", "1": "Medium", "2": "High"}), + ), + ), + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgkg": ( + LocalTuyaEntity( + id=DPCode.RELAY_STATUS, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.LIGHT_MODE, + entity_category=EntityCategory.CONFIG, + name="Light Mode", + custom_configs=localtuya_selector( + {"relay": "State", "pos": "Position", "none": "OFF"} + ), + ), + LocalTuyaEntity( + id=DPCode.LED_TYPE_1, + entity_category=EntityCategory.CONFIG, + name="Led Type 1", + custom_configs=localtuya_selector( + {"led": "Led", "incandescent": "Incandescent", "halogen": "Halogen"} + ), + ), + LocalTuyaEntity( + id=DPCode.LED_TYPE_2, + entity_category=EntityCategory.CONFIG, + name="Led Type 2", + custom_configs=localtuya_selector( + {"led": "Led", "incandescent": "Incandescent", "halogen": "Halogen"} + ), + ), + LocalTuyaEntity( + id=DPCode.LED_TYPE_3, + entity_category=EntityCategory.CONFIG, + name="Led Type 3", + custom_configs=localtuya_selector( + {"led": "Led", "incandescent": "Incandescent", "halogen": "Halogen"} + ), + ), + ), + # Dimmer + # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 + "tgq": ( + LocalTuyaEntity( + id=DPCode.LED_TYPE_1, + entity_category=EntityCategory.CONFIG, + name="Led Type 1", + custom_configs=localtuya_selector( + {"led": "Led", "incandescent": "Incandescent", "halogen": "Halogen"} + ), + ), + LocalTuyaEntity( + id=DPCode.LED_TYPE_2, + entity_category=EntityCategory.CONFIG, + name="Led Type 2", + custom_configs=localtuya_selector( + {"led": "Led", "incandescent": "Incandescent", "halogen": "Halogen"} + ), + ), + ), + # Fingerbot + "szjqr": ( + LocalTuyaEntity( + id=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + name="Fingerbot Mode", + custom_configs=localtuya_selector( + {"click": "Click", "switch": "Switch", "toggle": "Toggle"} + ), + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + LocalTuyaEntity( + id=DPCode.CISTERN, + entity_category=EntityCategory.CONFIG, + icon="mdi:water-opacity", + name="Water Tank Adjustment", + custom_configs=localtuya_selector( + {"low": "Low", "middle": "Middle", "high": "High", "closed": "Closed"} + ), + ), + LocalTuyaEntity( + id=DPCode.COLLECTION_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:air-filter", + name="Dust Collection Mode", + custom_configs=localtuya_selector( + {"small": "Small", "middle": "Middle", "large": "Large"} + ), + ), + LocalTuyaEntity( + id=DPCode.VOICE_LANGUAGE, + entity_category=EntityCategory.CONFIG, + icon="mdi:air-filter", + name="Dust Collection Mode", + custom_configs=localtuya_selector({"cn": "Chinese", "en": "English"}), + ), + LocalTuyaEntity( + id=DPCode.DIRECTION_CONTROL, + entity_category=EntityCategory.CONFIG, + icon="mdi:arrow-all", + name="Direction", + custom_configs=localtuya_selector( + { + "foward": "Forward", + "backward": "Backward", + "turn_left": "Left", + "turn_right": "Right", + "stop": "Stop", + } + ), + ), + LocalTuyaEntity( + id=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:layers-outline", + name="Mode", + custom_configs=localtuya_selector( + { + "standby": "StandBy", + "random": "Random", + "smart": "Smart", + "wallfollow": "Follow Wall", + "mop": "Mop", + "spiral": "Spiral", + "left_spiral": "Spiral Left", + "right_spiral": "Spiral Right", + "right_bow": "Bow Right", + "left_bow": "Bow Left", + "partial_bow": "Bow Partial", + "chargego": "Charge", + } + ), + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge + "fs": ( + LocalTuyaEntity( + id=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:cog", + name="Mode", + custom_configs=localtuya_selector( + {"sleep": "Sleep", "normal": "Normal", "nature": "Nature"} + ), + ), + LocalTuyaEntity( + id=DPCode.FAN_VERTICAL, + entity_category=EntityCategory.CONFIG, + icon="mdi:format-vertical-align-center", + name="Vertical swing", + custom_configs=localtuya_selector( + {"30": "30 Deg", "60": "60 Deg", "90": "90 Deg"} + ), + ), + LocalTuyaEntity( + id=DPCode.FAN_HORIZONTAL, + entity_category=EntityCategory.CONFIG, + icon="mdi:format-horizontal-align-center", + name="Horizontal swing", + custom_configs=localtuya_selector( + {"30": "30 Deg", "60": "60 Deg", "90": "90 Deg"} + ), + ), + LocalTuyaEntity( + id=DPCode.WORK_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:ceiling-fan-light", + name="Light mode", + custom_configs=localtuya_selector( + {"white": "White", "colour": "Colour", "colourful": "Colourful"} + ), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector(COUNT_DOWN), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector(COUNT_DOWN_HOURS), + ), + ), + # Curtain + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc + "cl": ( + LocalTuyaEntity( + id=(DPCode.CONTROL_BACK_MODE, DPCode.CONTROL_BACK), + name="Motor Direction", + entity_category=EntityCategory.CONFIG, + icon="mdi:swap-vertical", + custom_configs=localtuya_selector({"forward": "Forward", "back": "Back"}), + ), + LocalTuyaEntity( + id=DPCode.MOTOR_MODE, + name="Motor Mode", + entity_category=EntityCategory.CONFIG, + icon="mdi:cog-transfer", + custom_configs=localtuya_selector( + {"contiuation": "Auto", "point": "Manual"} + ), + ), + LocalTuyaEntity( + id=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + name="Cover Mode", + custom_configs=localtuya_selector({"morning": "Morning", "night": "Night"}), + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + LocalTuyaEntity( + id=DPCode.SPRAY_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:spray", + name="Spraying mode", + custom_configs=localtuya_selector( + { + "auto": "AUTO", + "health": "Health", + "baby": "BABY", + "sleep": "SLEEP", + "humidity": "HUMIDITY", + "work": "WORK", + } + ), + ), + LocalTuyaEntity( + id=DPCode.LEVEL, + entity_category=EntityCategory.CONFIG, + icon="mdi:spray", + name="Spraying level", + custom_configs=localtuya_selector( + { + "level_1": "LEVEL 1", + "level_2": "LEVEL 2", + "level_3": "LEVEL 3", + "level_4": "LEVEL 4", + "level_5": "LEVEL 5", + "level_6": "LEVEL 6", + "level_7": "LEVEL 7", + "level_8": "LEVEL 8", + "level_9": "LEVEL 9", + "level_10": "LEVEL 10", + } + ), + ), + LocalTuyaEntity( + id=DPCode.MOODLIGHTING, + entity_category=EntityCategory.CONFIG, + icon="mdi:lightbulb-multiple", + name="Mood light", + custom_configs=localtuya_selector( + {"1": "1", "2": "2", "3": "3", "4": "4", "5": "5"} + ), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector(COUNT_DOWN), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector(COUNT_DOWN_HOURS), + ), + ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector(COUNT_DOWN), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector(COUNT_DOWN_HOURS), + ), + ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs": ( + LocalTuyaEntity( + id=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector( + {"cancel": "Disable", "2h": "2 Hours", "4h": "4 Hours", "8h": "8 Hours"} + ), + ), + LocalTuyaEntity( + id=DPCode.DEHUMIDITY_SET_ENUM, + name="Target Humidity", + entity_category=EntityCategory.CONFIG, + icon="mdi:water-percent", + custom_configs=localtuya_selector( + {"10": "10", "20": "20", "30": "30", "40": "40", "50": "50", "60": "60"} + ), + ), + LocalTuyaEntity( + id=DPCode.SPRAY_VOLUME, + name="Intensity", + entity_category=EntityCategory.CONFIG, + icon="mdi:volume-source", + custom_configs=localtuya_selector( + {"small": "Low", "middle": "Medium", "large": "High"} + ), + ), + LocalTuyaEntity( + id=DPCode.FAN_SPEED_ENUM, + name="Fan Speed", + entity_category=EntityCategory.CONFIG, + icon="mdi:fan", + custom_configs=localtuya_selector({"low": "Low", "high": "High"}), + ), + ), + # Water Detector + # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli + "sj": ( + LocalTuyaEntity( + id=(DPCode.C_F, DPCode.TEMP_UNIT_CONVERT), + name="Temperature Unit", + icon="mdi:cog", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector({"c": "Celsius", "f": "Fahrenheit"}), + ), + ), + # Water Valve + "sfkzq": ( + LocalTuyaEntity( + id=DPCode.SMART_WEATHER, + name="Smart Weather Mode", + icon="mdi:cog", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"cloudy": "Cloudy", "rainy": "Rainy", "snowy": "Snowy"} + ), + ), + ), + # sous vide cookers + # https://developer.tuya.com/en/docs/iot/f?id=K9r2v9hgmyk3h + "mzj": ( + LocalTuyaEntity( + id=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + name="Cooking Mode", + custom_configs=localtuya_selector( + { + "vegetables": "Vegetables", + "meat": "Meat", + "shrimp": "Shrimp", + "fish": "Fish", + "chicken": "Chicken", + "drumsticks": "Drumsticks", + "beef": "Beef", + "rice": "Rice", + } + ), + ), + ), + # PIR Detector + # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + "pir": ( + LocalTuyaEntity( + id=DPCode.MOD, + icon="mdi:cog", + entity_category=EntityCategory.CONFIG, + name="Mode", + custom_configs=localtuya_selector( + {"mode_auto": "AUTO", "mode_on": "ON", "mode_off": "OFF"} + ), + ), + LocalTuyaEntity( + id=DPCode.PIR_SENSITIVITY, + icon="mdi:ray-start-arrow", + entity_category=EntityCategory.CONFIG, + name="PIR Sensitivity", + custom_configs=localtuya_selector( + {"low": "Low", "middle": "Middle", "high": "High"} + ), + ), + LocalTuyaEntity( + id=DPCode.PIR_TIME, + icon="mdi:timer-sand", + entity_category=EntityCategory.CONFIG, + name="Reset Time", + custom_configs=localtuya_selector( + {"30s": "30 Seconds", "60s": "60 Seconds", "120s": "120 Seconds"} + ), + ), + ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": ( + LocalTuyaEntity( + id=DPCode.SENSORTYPE, + entity_category=EntityCategory.CONFIG, + name="Temperature sensor", + custom_configs=localtuya_selector( + {"0": "Internal", "1": "External", "2": "Both"} + ), + ), + ), + # Temperature and Humidity Sensor + # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 + "wsdcg": ( + LocalTuyaEntity( + id=(DPCode.C_F, DPCode.TEMP_UNIT_CONVERT), + name="Temperature Unit", + icon="mdi:cog", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector({"c": "Celsius", "f": "Fahrenheit"}), + ), + # LocalTuyaEntity( + # id=DPCode.TEMP_ALARM, + # name="Temperature Alarm", + # entity_category=EntityCategory.CONFIG, + # icon="mdi:bell-alert", + # custom_configs=localtuya_selector( + # {"loweralarm": "Low", "upperalarm": "High", "cancel": "Cancel"} + # ), + # ), + # LocalTuyaEntity( + # id=DPCode.HUM_ALARM, + # name="Humidity Alarm", + # icon="mdi:bell-alert", + # entity_category=EntityCategory.CONFIG, + # custom_configs=localtuya_selector( + # {"loweralarm": "Low", "upperalarm": "High", "cancel": "Cancel"} + # ), + # ), + ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf + "mal": ( + LocalTuyaEntity( + id=DPCode.ZONE_ATTRIBUTE, + entity_category=EntityCategory.CONFIG, + name="Zone Attribute", + custom_configs=localtuya_selector( + { + "MODE_HOME_ARM": "Home Arm", + "MODE_ARM": "Arm", + "MODE_24": "24H", + "MODE_DOORBELL": "Doorbell", + "MODE_24_SILENT": "Silent", + "HOME_ARM_NO_DELAY": "Home, Arm No delay", + "ARM_NO_DELAY": "Arm No delay", + } + ), + ), + LocalTuyaEntity( + id=DPCode.MASTER_STATE, + entity_category=EntityCategory.CONFIG, + name="Host Status", + custom_configs=localtuya_selector({"normal": "Normal", "alarm": "Alarm"}), + ), + LocalTuyaEntity( + id=DPCode.SUB_CLASS, + entity_category=EntityCategory.CONFIG, + name="Sub-device category", + custom_configs=localtuya_selector( + { + "remote_controller": "Remote Controller", + "detector": "Detector", + "socket": "Socket", + } + ), + ), + LocalTuyaEntity( + id=DPCode.SUB_TYPE, + entity_category=EntityCategory.CONFIG, + name="Sub-device type", + custom_configs=localtuya_selector( + { + "OTHER": "Other", + "DOOR": "Door", + "PIR": "Pir", + "SOS": "SoS", + "ROOM": "Room", + "WINDOW": "Window", + "BALCONY": "Balcony", + "FENCE": "Fence", + "SMOKE": "Smoke", + "GAS": "Gas", + "CO": "CO", + "WATER": "Water", + } + ), + ), + ), + # Smart Water Meter + # https://developer.tuya.com/en/docs/iot/f?id=Ka8n052xu7w4c + "znsb": ( + LocalTuyaEntity( + id=DPCode.REPORT_PERIOD_SET, + entity_category=EntityCategory.CONFIG, + name="Report Period", + custom_configs=localtuya_selector( + { + "1h": "1 Hours", + "2h": "2 Hours", + "3h": "3 Hours", + "4h": "4 Hours", + "6h": "6 Hours", + "8h": "8 Hours", + "12h": "12 Hours", + "24h": "24 Hours", + "48h": "48 Hours", + "72h": "72 Hours", + } + ), + icon="mdi:file-chart-outline", + ), + ), + # HDMI Sync Box A1 + "hdmipmtbq": ( + LocalTuyaEntity( + id=DPCode.VIDEO_SCENE, + entity_category=EntityCategory.CONFIG, + name="Video Type", + icon="mdi:camera-burst", + custom_configs=localtuya_selector({"game": "Gaming", "movie": "Movies"}), + ), + LocalTuyaEntity( + id=DPCode.VIDEO_MODE, + entity_category=EntityCategory.CONFIG, + name="Video Mode", + icon="mdi:format-wrap-square", + custom_configs=localtuya_selector( + { + "nor_closed": "Nor Closed", + "multiple_colour": "Multi Colors", + "single_colour": "Single Color", + } + ), + ), + LocalTuyaEntity( + id=DPCode.VIDEO_INTENSITY, + entity_category=EntityCategory.CONFIG, + name="Intensity", + icon="mdi:television-ambient-light", + custom_configs=localtuya_selector( + { + "low": "Low", + "middle": "Middle", + "high": "High", + "music": "Music", + } + ), + ), + LocalTuyaEntity( + id=DPCode.STRIP_INPUT_POS, + entity_category=EntityCategory.CONFIG, + name="Start Position", + icon="mdi:vector-square-minus", + custom_configs=localtuya_selector( + {"low_right": "Low Right", "low_left": "Low Left"} + ), + ), + LocalTuyaEntity( + id=DPCode.STRIP_DIRECTION, + entity_category=EntityCategory.CONFIG, + name="Strip Direction", + icon="mdi:subdirectory-arrow-right", + custom_configs=localtuya_selector( + {"clockwise": "Clockwise", "anti_clockwise": "Counter-Clockwise"} + ), + ), + LocalTuyaEntity( + id=DPCode.TV_SIZE, + entity_category=EntityCategory.CONFIG, + name="TV Size", + icon="mdi:move-resize", + custom_configs=localtuya_selector( + { + "55_to_64_inch": "55 - 64 Inches", + "65_to_74_inch": "65 - 74 Inches", + "above_75_inch": "75 Inches or Above", + } + ), + ), + ), +} +# Wireless Switch # also can come as knob switch. # and scene switch. +# https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5 +SELECTS["wxkg"] = ( + LocalTuyaEntity( + id=DPCode.WORK_MODE, + name="Display mode", + icon="mdi:square-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"brightness": "Brightness", "temperature": "Temperature"} + ), + ), + LocalTuyaEntity( + id=(DPCode.SWITCH1_VALUE, DPCode.SWITCH_TYPE_1), + name="Switch 1", + icon="mdi:square-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "single_click": "Single click", + "double_click": "Double click", + "long_press": "Long Press", + } + ), + condition_contains_any=["single_click", "double_click", "long_press"], + ), + LocalTuyaEntity( + id=(DPCode.SWITCH2_VALUE, DPCode.SWITCH_TYPE_2), + name="Switch 2", + icon="mdi:palette-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "single_click": "Single click", + "double_click": "Double click", + "long_press": "Long Press", + } + ), + condition_contains_any=["single_click", "double_click", "long_press"], + ), + LocalTuyaEntity( + id=(DPCode.SWITCH3_VALUE, DPCode.SWITCH_TYPE_3), + name="Switch 3", + icon="mdi:palette-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "single_click": "Single click", + "double_click": "Double click", + "long_press": "Long Press", + } + ), + condition_contains_any=["single_click", "double_click", "long_press"], + ), + LocalTuyaEntity( + id=(DPCode.SWITCH4_VALUE, DPCode.SWITCH_TYPE_4), + name="Switch 4", + icon="mdi:palette-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "single_click": "Single click", + "double_click": "Double click", + "long_press": "Long Press", + } + ), + condition_contains_any=["single_click", "double_click", "long_press"], + ), + LocalTuyaEntity( + id=(DPCode.SWITCH5_VALUE, DPCode.SWITCH_TYPE_5), + name="Switch 5", + icon="mdi:palette-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "single_click": "Single click", + "double_click": "Double click", + "long_press": "Long Press", + } + ), + condition_contains_any=["single_click", "double_click", "long_press"], + ), + LocalTuyaEntity( + id=DPCode.MODE, + name="Mode", + icon="mdi:cog", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"remote_control": "Remote", "wireless_switch": "Wireless"} + ), + condition_contains_any=["remote_control", "wireless_switch"], + ), + *SELECTS["kg"], +) + +# Scene Switch +# https://developer.tuya.com/en/docs/iot/f?id=K9gf7nx6jelo8 +SELECTS["cjkg"] = SELECTS["kg"] + +# Fan wall switch +# For Power-on behavior +SELECTS["fskg"] = SELECTS["kg"] + +# Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SELECTS["cz"] = SELECTS["kg"] + +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SELECTS["pc"] = SELECTS["kg"] + +SELECTS["tdq"] = SELECTS["kg"] + +# Heater +SELECTS["rs"] = SELECTS["kt"] diff --git a/custom_components/localtuya/core/ha_entities/sensors.py b/custom_components/localtuya/core/ha_entities/sensors.py new file mode 100644 index 00000000..26800d09 --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/sensors.py @@ -0,0 +1,1608 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from homeassistant.components.sensor import SensorStateClass, SensorDeviceClass +from homeassistant.const import ( + PERCENTAGE, + UnitOfTime, + UnitOfPower, + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfTime, + CONF_UNIT_OF_MEASUREMENT, + UnitOfTemperature, + UnitOfEnergy, + UnitOfVolume, + UnitOfElectricPotential, + UnitOfMass, + DEGREE, + LIGHT_LUX, + UnitOfLength, +) + +from .base import ( + DPCode, + LocalTuyaEntity, + EntityCategory, + CLOUD_VALUE, +) +from ...const import CONF_SCALING as SCALE_FACTOR + + +def localtuya_sensor(unit_of_measurement=None, scale_factor: float = 1) -> dict: + """Define LocalTuya Configs for Sensor.""" + data = {CONF_UNIT_OF_MEASUREMENT: unit_of_measurement} + data.update({SCALE_FACTOR: CLOUD_VALUE(scale_factor, "id", "scale")}) + + return data + + +# Commonly used battery sensors, that are re-used in the sensors down below. +BATTERY_SENSORS: dict[str, tuple[LocalTuyaEntity, ...]] = ( + LocalTuyaEntity( + id=DPCode.BATTERY_PERCENTAGE, + name="Battery", + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(PERCENTAGE), + ), + LocalTuyaEntity( + id=(DPCode.BATTERY_STATE, DPCode.BATTERYSTATUS), + name="Battery Level", + # name="battery_state", + icon="mdi:battery", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.BATTERY_VALUE, + name="Battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(PERCENTAGE), + ), + LocalTuyaEntity( + id=DPCode.VA_BATTERY, + name="Battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(PERCENTAGE), + ), + LocalTuyaEntity( + id=DPCode.BATTERY, + name="Battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(PERCENTAGE), + ), +) + +# All descriptions can be found here. Mostly the Integer data types in the +# default status set of each category (that don't have a set instruction) +# end up being a sensor. +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +SENSORS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Wireless Switch # also can come as knob switch. + # https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5 + "wxkg": ( + LocalTuyaEntity( + id=DPCode.MODE_1, + name="Switch 1 Mode", + icon="mdi:information-slab-circle-outline", + ), + LocalTuyaEntity( + id=DPCode.MODE_2, + name="Switch 2 Mode", + icon="mdi:information-slab-circle-outline", + ), + LocalTuyaEntity( + id=DPCode.KNOB_SWITCH_MODE_1, + name="Knob Mode", + icon="mdi:knob", + entity_category=EntityCategory.DIAGNOSTIC, + ), + *BATTERY_SENSORS, + ), + # Smart panel with switches and zigbee hub ? + # Not documented + "dgnzk": ( + LocalTuyaEntity( + id=DPCode.PLAY_INFO, + name="Playing", + icon="mdi:playlist-play", + ), + ), + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + LocalTuyaEntity( + id=DPCode.GAS_SENSOR_VALUE, + # name="gas", + icon="mdi:gas-cylinder", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CH4_SENSOR_VALUE, + # name="gas", + name="Methane", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VOC_VALUE, + # name="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.PM25_VALUE, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CO_VALUE, + # name="carbon_monoxide", + icon="mdi:molecule-co", + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + icon="mdi:molecule-co2", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CH2O_VALUE, + # name="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.BRIGHT_STATE, + # name="luminosity", + icon="mdi:brightness-6", + ), + LocalTuyaEntity( + id=DPCode.BRIGHT_VALUE, + # name="illuminance", + icon="mdi:brightness-6", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.SMOKE_SENSOR_VALUE, + # name="smoke_amount", + icon="mdi:smoke-detector", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Smart Kettle + # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 + "bh": ( + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT_F, + # name="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.STATUS, + # name="status", + ), + ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Two-way temperature and humidity switch + # "MOES Temperature and Humidity Smart Switch Module MS-103" + # Documentation not found + "wkcz": ( + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # CO Detector + # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v + "cobj": ( + LocalTuyaEntity( + id=DPCode.CO_VALUE, + # name="carbon_monoxide", + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + LocalTuyaEntity( + id=DPCode.FEED_REPORT, + # name="last_amount", + icon="mdi:counter", + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Air Quality Monitor + # No specification on Tuya portal + "hjjcy": ( + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CH2O_VALUE, + # name="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VOC_VALUE, + # name="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.PM25_VALUE, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Formaldehyde Detector + # Note: Not documented + "jqbj": ( + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VOC_VALUE, + # name="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.PM25_VALUE, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VA_HUMIDITY, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VA_TEMPERATURE, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CH2O_VALUE, + # name="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Methane Detector + # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm + "jwbj": ( + LocalTuyaEntity( + id=DPCode.CH4_SENSOR_VALUE, + # name="methane", + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + LocalTuyaEntity( + id=DPCode.CUR_CURRENT, + name="Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.MILLIAMPERE), + ), + LocalTuyaEntity( + id=DPCode.CUR_POWER, + name="Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.CUR_VOLTAGE, + name="Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.ADD_ELE, + name="Electricity", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + # CZ - Energy monitor? + LocalTuyaEntity( + id=DPCode.CUR_CURRENT1, + name="Current 1", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.MILLIAMPERE), + ), + LocalTuyaEntity( + id=DPCode.CUR_CURRENT2, + name="Current 2", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.MILLIAMPERE), + ), + LocalTuyaEntity( + id=DPCode.CUR_POWER1, + name="Power 1", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.CUR_POWER2, + name="Power 2", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.CUR_VOLTAGE1, + name="Voltage 1", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.CUR_VOLTAGE2, + name="Voltage 2", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.ADD_ELE1, + name="Electricity 1", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.ADD_ELE2, + name="Electricity 2", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TOTAL_ENERGY, + name="Total Energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TOTAL_ENERGY1, + name="Total Energy 1", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TOTAL_ENERGY2, + name="Total Energy 2", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TODAY_ACC_ENERGY, + name="Today Energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TODAY_ACC_ENERGY1, + name="Today Energy 1", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TODAY_ACC_ENERGY2, + name="Today Energy 2", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TODAY_ENERGY_ADD, + name="Today Energy Increase", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TODAY_ENERGY_ADD1, + name="Today Energy 1 Increase", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TODAY_ENERGY_ADD2, + name="Today Energy 2 Increase", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.SYNC_REQUEST, + name="Sync Request", + ), + LocalTuyaEntity( + id=DPCode.DEVICE_STATE1, + name="Device 1 State", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.DEVICE_STATE2, + name="Device 2 State", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.NET_STATE, + name="Connection state", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:network", + ), + ), + # IoT Switch + # Note: Undocumented + "tdq": ( + LocalTuyaEntity( + id=DPCode.CUR_CURRENT, + name="Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.MILLIAMPERE), + # entity_registry_enabled_default=False, + ), + LocalTuyaEntity( + id=DPCode.CUR_POWER, + name="Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + # entity_registry_enabled_default=False, + ), + LocalTuyaEntity( + id=DPCode.CUR_VOLTAGE, + name="Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + # entity_registry_enabled_default=False, + ), + LocalTuyaEntity( + id=DPCode.ADD_ELE, + name="Electricity", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + ), + # Luminance Sensor + # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 + "ldcg": ( + LocalTuyaEntity( + id=DPCode.BRIGHT_STATE, + # name="luminosity", + icon="mdi:brightness-6", + ), + LocalTuyaEntity( + id=DPCode.BRIGHT_VALUE, + # name="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Door and Window Controller + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 + "mc": BATTERY_SENSORS, + # Door Window Sensor + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m + "mcs": BATTERY_SENSORS, + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.STATUS, + # name="sous_vide_status", + ), + LocalTuyaEntity( + id=DPCode.REMAIN_TIME, + name="Timer Remaining", + custom_configs=localtuya_sensor(UnitOfTime.MINUTES), + icon="mdi:timer", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + # PIR Detector + # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + "pir": ( + LocalTuyaEntity( + id=DPCode.PM25_VALUE, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.MOD_ON_TMR_CD, + icon="mdi:timer-edit-outline", + name="Timer left", + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor("s"), + ), + LocalTuyaEntity( + id=DPCode.ILLUMINANCE_VALUE, + name="Illuminance", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(LIGHT_LUX), + ), + *BATTERY_SENSORS, + ), + # PM2.5 Sensor + # https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu + "pm2.5": ( + LocalTuyaEntity( + id=DPCode.PM25_VALUE, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CH2O_VALUE, + # name="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VOC_VALUE, + # name="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.PM1, + # name="pm1", + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.PM10, + # name="pm10", + device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qn": ( + LocalTuyaEntity( + id=DPCode.WORK_POWER, + name="Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + ), + ), + # Gas Detector + # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw + "rqbj": ( + LocalTuyaEntity( + id=DPCode.GAS_SENSOR_VALUE, + icon="mdi:gas-cylinder", + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Water Detector + # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli + "sj": ( + LocalTuyaEntity( + id=DPCode.WATERSENSOR_STATE, + icon="mdi:water", + ), + LocalTuyaEntity( + id=DPCode.TEMP_STATUS, + name="Temperature Status", + icon="mdi:thermometer-check", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.HUMI_STATUS, + name="Humidity Status", + icon="mdi:water-percent-alert", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.POWER, + icon="mdi:power", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(PERCENTAGE, 0.01), + ), + LocalTuyaEntity( + id=(DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F), + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Emergency Button + # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy + "sos": BATTERY_SENSORS, + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + LocalTuyaEntity( + id=DPCode.SENSOR_TEMPERATURE, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.SENSOR_HUMIDITY, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.WIRELESS_ELECTRICITY, + name="Battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Water Valve + "sfkzq": ( + LocalTuyaEntity( + id=DPCode.WORK_STATE, + name="State", + icon="mdi:state-machine", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.USE_TIME_ONE, + name="Single Usage Time", + icon="mdi:chart-arc", + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(unit_of_measurement=UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=(DPCode.TIME_USE, DPCode.USE_TIME), + name="Usage Time", + icon="mdi:chart-arc", + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(unit_of_measurement=UnitOfTime.SECONDS), + ), + *BATTERY_SENSORS, + ), + # Fingerbot + "szjqr": BATTERY_SENSORS, + # Solar Light + # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 + "tyndj": BATTERY_SENSORS, + # Volatile Organic Compound Sensor + # Note: Undocumented in cloud API docs, based on test device + "voc": ( + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.PM25_VALUE, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CH2O_VALUE, + # name="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VOC_VALUE, + # name="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": { + LocalTuyaEntity( + id=(DPCode.TEMP_CURRENT, DPCode.TEMPFLOOR), + name="External temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + }, + # Thermostatic Radiator Valve + # Not documented + "wkf": BATTERY_SENSORS, + # Temperature and Humidity Sensor + # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 + "wsdcg": ( + LocalTuyaEntity( + id=DPCode.VA_TEMPERATURE, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=(DPCode.TEMP_CURRENT, DPCode.PRM_CONTENT), + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS, 0.01), + ), + LocalTuyaEntity( + id=(DPCode.HUMIDITY_VALUE, DPCode.PRM_CONTENT, DPCode.VA_HUMIDITY), + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(PERCENTAGE, 0.01), + ), + LocalTuyaEntity( + id=DPCode.BRIGHT_VALUE, + name="Illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(LIGHT_LUX), + ), + *BATTERY_SENSORS, + ), + # Pressure Sensor + # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm + "ylcg": ( + LocalTuyaEntity( + id=DPCode.PRESSURE_VALUE, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Smoke Detector + # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 + "ywbj": ( + LocalTuyaEntity( + id=DPCode.SMOKE_SENSOR_VALUE, + # name="smoke_amount", + icon="mdi:smoke-detector", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Vibration Sensor + # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno + "zd": BATTERY_SENSORS, + # Smart Electricity Meter + # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 + "zndb": ( + LocalTuyaEntity( + id=DPCode.FORWARD_ENERGY_TOTAL, + # name="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.01), + ), + LocalTuyaEntity( + id=DPCode.REVERSE_ENERGY_TOTAL, + name="Total Reverse Energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.01), + ), + ## PHASE X Are probably encrypted values. since it duplicated it probably raw dict data. + LocalTuyaEntity( + id=DPCode.PHASE_A, + name="Phase C Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.MILLIAMPERE), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.PHASE_A, + name="Phase C Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.PHASE_A, + name="Phase A Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.PHASE_B, + name="Phase B", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.MILLIAMPERE), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.PHASE_B, + name="Phase B Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.PHASE_B, + name="Phase B Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.PHASE_C, + name="Phase C Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.MILLIAMPERE), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.PHASE_C, + name="Phase C Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.PHASE_C, + name="Phase C Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + ## PHASE X Are probably encrypted values. since it duplicated it probably raw dict data. + LocalTuyaEntity( + id=DPCode.POWER_A, + name="Power A", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.POWER_B, + name="Power B", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.POWER_C, + name="Power C", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_FORWORD_A, + name="Energy A", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_FORWORD_B, + name="Energy B", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_FORWORD_C, + name="Energy C", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=(DPCode.ENERGY_REVERSE_A, DPCode.ENERGY_RESERSE_A), + name="Reverse Energy A", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=(DPCode.ENERGY_REVERSE_B, DPCode.ENERGY_RESERSE_B), + name="Reverse Energy B", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=(DPCode.ENERGY_REVERSE_C, DPCode.ENERGY_RESERSE_C), + name="Reverse Energy C", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=(DPCode.POWER_FACTOR, DPCode.POWER_FACTOR_A), + name="Power Factor A", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.POWER_FACTOR_B, + name="Power Factor B", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.POWER_FACTOR_C, + name="Power Factor C", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.DIRECTION_A, + name="Direction A", + icon="mdi:arrow-up-down", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.DIRECTION_B, + name="Direction B", + icon="mdi:arrow-up-down", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.DIRECTION_C, + name="Direction C", + icon="mdi:arrow-up-down", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + LocalTuyaEntity( + id=DPCode.CLEAN_AREA, + # name="cleaning_area", + icon="mdi:texture-box", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CLEAN_TIME, + # name="cleaning_time", + icon="mdi:progress-clock", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TOTAL_CLEAN_AREA, + # name="total_cleaning_area", + icon="mdi:texture-box", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + LocalTuyaEntity( + id=DPCode.TOTAL_CLEAN_TIME, + # name="total_cleaning_time", + icon="mdi:history", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + LocalTuyaEntity( + id=DPCode.TOTAL_CLEAN_COUNT, + # name="total_cleaning_times", + icon="mdi:counter", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + LocalTuyaEntity( + id=DPCode.DUSTER_CLOTH, + # name="duster_cloth_life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.EDGE_BRUSH, + # name="side_brush_life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.FILTER_LIFE, + # name="filter_life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.ROLL_BRUSH, + # name="rolling_brush_life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Curtain + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qy7wkre + "cl": ( + LocalTuyaEntity( + id=DPCode.TIME_TOTAL, + # name="last_operation_duration", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:progress-clock", + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qwjz0i3 + "jsq": ( + LocalTuyaEntity( + id=DPCode.HUMIDITY_CURRENT, + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT_F, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.LEVEL_CURRENT, + name="Water Level", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:waves-arrow-up", + ), + ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81 + "kj": ( + LocalTuyaEntity( + id=DPCode.FILTER, + # name="filter_utilization", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:ticket-percent-outline", + ), + LocalTuyaEntity( + id=DPCode.PM25, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:molecule", + ), + LocalTuyaEntity( + id=DPCode.TEMP, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TVOC, + # name="total_volatile_organic_compound", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.ECO2, + # name="concentration_carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TOTAL_TIME, + # name="total_operating_time", + icon="mdi:history", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.TOTAL_PM, + # name="total_absorption_particles", + icon="mdi:texture-box", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.AIR_QUALITY, + # name="air_quality", + icon="mdi:air-filter", + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48quojr54 + "fs": ( + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # eMylo Smart WiFi IR Remote + # Air Conditioner Mate (Smart IR Socket) + "wnykq": ( + LocalTuyaEntity( + id=(DPCode.VA_TEMPERATURE, DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F), + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=(DPCode.VA_HUMIDITY, DPCode.HUMIDITY_VALUE), + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CUR_CURRENT, + name="Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.MILLIAMPERE), + # entity_registry_enabled_default=False, + ), + LocalTuyaEntity( + id=DPCode.CUR_POWER, + name="Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + # entity_registry_enabled_default=False, + ), + LocalTuyaEntity( + id=DPCode.CUR_VOLTAGE, + name="Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + # entity_registry_enabled_default=False, + ), + LocalTuyaEntity( + id=DPCode.ADD_ELE, + name="Electricity", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e + "cs": ( + LocalTuyaEntity( + id=DPCode.TEMP_INDOOR, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_INDOOR, + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_LEFT, + name="Timer Remaining", + custom_configs=localtuya_sensor(UnitOfTime.MINUTES), + icon="mdi:timer", + entity_category=EntityCategory.DIAGNOSTIC, + ), + # Sensors 'Micro Inverter' ? + LocalTuyaEntity( + id=DPCode.PV_POWER, + name="PV Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT), + ), + LocalTuyaEntity( + id=DPCode.EMISSION, + name="Emission", + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfMass.KILOGRAMS), + ), + LocalTuyaEntity( + id=DPCode.PV_VOLT, + name="PV Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT), + ), + LocalTuyaEntity( + id=DPCode.TEMPERATURE, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=DPCode.AC_CURRENT, + name="AC Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.AMPERE), + ), + LocalTuyaEntity( + id=DPCode.PV_CURRENT, + name="PV Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.AMPERE), + ), + LocalTuyaEntity( + id=DPCode.AC_VOLT, + name="AC Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT), + ), + LocalTuyaEntity( + id=DPCode.DAY_ENERGY, + name="Daily Consumption", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR), + ), + LocalTuyaEntity( + id=DPCode.ENERGY, + name="Energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR), + ), + LocalTuyaEntity( + id=DPCode.OUT_POWER, + name="Out Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT), + ), + LocalTuyaEntity( + id=DPCode.PLANT, + name="Plant", + custom_configs=localtuya_sensor("pcs"), + ), + ), + # Soil sensor (Plant monitor) + "zwjcy": ( + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf + "mal": ( + LocalTuyaEntity( + id=DPCode.SUB_STATE, + name="Sub-Device State", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.POWEREVENT, + name="Power Event", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.ZONE_NUMBER, + name="Zone Number", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.OTHEREVENT, + name="Other Event", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + # Lock + "ms": ( + LocalTuyaEntity( + id=DPCode.LOCK_MOTOR_STATE, + name="Motor State", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + # Smart Water Meter + # https://developer.tuya.com/en/docs/iot/f?id=Ka8n052xu7w4c + "znsb": ( + LocalTuyaEntity( + id=DPCode.WATER_USE_DATA, + name="Total Water Consumption", + icon="mdi:water-outline", + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfVolume.LITERS, 1), + ), + LocalTuyaEntity( + id=DPCode.WATER_TEMP, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS, 0.01), + ), + LocalTuyaEntity( + id=DPCode.VOLTAGE_CURRENT, + name="Battery", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.01), + ), + ), + # Air conditioner + # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + "kt": ( + LocalTuyaEntity( + id=DPCode.AIR_RETURN, + name="AIR Return", + icon="mdi:air-filter", + custom_configs=localtuya_sensor(DEGREE, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.COIL_OUT, + name="Coil Out", + icon="mdi:heating-coil", + custom_configs=localtuya_sensor(DEGREE, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.DEFROST, + name="Defrosting", + icon="mdi:snowflake-melt", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + name="Timer State", + icon="mdi:timer-sand", + custom_configs=localtuya_sensor(UnitOfTime.MINUTES, 1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.COMPRESSOR_COMMAND, + name="Compressor", + ), + LocalTuyaEntity( + id=DPCode.FOUT_WAY_VALVE, + name="Fout Way Valve", + ), + LocalTuyaEntity( + id=DPCode.ODU_FAN_SPEED, + name="ODU Fan Speed", + icon="mdi:fan", + ), + ), + # Ultrasonic level sensor + "ywcgq": ( + LocalTuyaEntity( + id=DPCode.LIQUID_STATE, + name="State", + ), + LocalTuyaEntity( + id=DPCode.LIQUID_DEPTH, + name="Depth", + icon="mdi:altimeter", + custom_configs=localtuya_sensor(UnitOfLength.METERS, 1), + ), + LocalTuyaEntity( + id=DPCode.LIQUID_LEVEL_PERCENT, + name="Level", + icon="mdi:altimeter", + custom_configs=localtuya_sensor(PERCENTAGE, 1), + ), + ), +} + + +# Circuit Breaker +# https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 +SENSORS["dlq"] = SENSORS["zndb"] + +# Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SENSORS["cz"] = SENSORS["kg"] + +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SENSORS["pc"] = SENSORS["kg"] diff --git a/custom_components/localtuya/core/ha_entities/sirens.py b/custom_components/localtuya/core/ha_entities/sirens.py new file mode 100644 index 00000000..42b65a3a --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/sirens.py @@ -0,0 +1,34 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory + +# All descriptions can be found here: +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +SIRENS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + LocalTuyaEntity( + id=(DPCode.ALARM_SWITCH, DPCode.ALARMSWITCH), + ), + ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": ( + LocalTuyaEntity( + id=(DPCode.ALARM_SWITCH, DPCode.ALARMSWITCH), + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + LocalTuyaEntity( + id=DPCode.SIREN_SWITCH, + ), + ), +} diff --git a/custom_components/localtuya/core/ha_entities/switches.py b/custom_components/localtuya/core/ha_entities/switches.py new file mode 100644 index 00000000..e53aaec1 --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/switches.py @@ -0,0 +1,966 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory +from homeassistant.components.switch import SwitchDeviceClass + +CHILD_LOCK = ( + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), +) +SWITCHES: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Smart Kettle + # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 + "bh": ( + LocalTuyaEntity( + id=DPCode.START, + name="Start", + icon="mdi:kettle-steam", + ), + LocalTuyaEntity( + id=DPCode.WARM, + name="Warm", + entity_category=EntityCategory.CONFIG, + ), + ), + # EasyBaby + # Undocumented, might have a wider use + "cn": ( + LocalTuyaEntity( + id=DPCode.DISINFECTION, + name="Disinfection", + icon="mdi:bacteria", + ), + LocalTuyaEntity( + id=DPCode.WATER, + name="Water", + icon="mdi:water", + ), + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + LocalTuyaEntity( + id=DPCode.SLOW_FEED, + name="Slow Feed", + icon="mdi:speedometer-slow", + entity_category=EntityCategory.CONFIG, + ), + ), + # Pet Water Feeder + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46aewxem5 + "cwysj": ( + LocalTuyaEntity( + id=DPCode.FILTER_RESET, + name="Reset Filter", + icon="mdi:filter", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.PUMP_RESET, + name="Reset Water Pump", + icon="mdi:pump", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Power", + ), + LocalTuyaEntity( + id=DPCode.WATER_RESET, + name="Reset Water", + icon="mdi:water-sync", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.UV, + name="UV Sterilization", + icon="mdi:lightbulb", + entity_category=EntityCategory.CONFIG, + ), + ), + # Light + # https://developer.tuya.com/en/docs/iot/f?id=K9i5ql3v98hn3 + "dj": ( + # There are sockets available with an RGB light + # that advertise as `dj`, but provide an additional + # switch to control the plug. + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Plug", + ), + ), + # Circuit Breaker + "dlq": ( + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + ), + ), + # Wake Up Light II + # Not documented + "hxd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Radio", + icon="mdi:radio", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_2, + name="Alarm 2", + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_3, + name="Alarm 3", + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_4, + name="Alarm 4", + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_5, + name="Alarm 5", + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_6, + name="Alarm 6", + icon="mdi:power-sleep", + ), + ), + # Two-way temperature and humidity switch + # "MOES Temperature and Humidity Smart Switch Module MS-103" + # Documentation not found + "wkcz": ( + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Switch 1", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_2, + name="Switch 2", + device_class=SwitchDeviceClass.OUTLET, + ), + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child lock", + icon="mdi:account-lock", + ), + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Switch 1", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_2, + name="Switch 2", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_3, + name="Switch 3", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_4, + name="Switch 4", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_5, + name="Switch 5", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_6, + name="Switch 6", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_7, + name="Switch 7", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_8, + name="Switch 8", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB1, + name="USB", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB2, + name="USB 2", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB3, + name="USB 3", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB4, + name="USB 4", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB5, + name="USB 5", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB6, + name="USB 6", + device_class=SwitchDeviceClass.OUTLET, + ), + ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + LocalTuyaEntity( + id=DPCode.ANION, + name="Ionizer", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.FILTER_RESET, + name="Reset Filter Cartridge_", + icon="mdi:filter", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Power", + ), + LocalTuyaEntity( + id=DPCode.WET, + name="Humidification", + icon="mdi:water-percent", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.UV, + name="UV Sterilization", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Air conditioner + # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + "kt": ( + LocalTuyaEntity( + id=DPCode.ANION, + name="Ionizer", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SLEEP, + name="Sleep", + icon="mdi:sleep", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SHAKE, + name="Shake", + # icon="mdi:vibrate", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.INNERDRY, + name="Inner Dry", + icon="mdi:water-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + icon="mdi:power", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.START, + name="Start", + icon="mdi:pot-steam", + entity_category=EntityCategory.CONFIG, + ), + ), + # Power Socket + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "pc": ( + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.OVERCHARGE_SWITCH, + name="Overcharge", + icon="mdi:flash-alert", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Switch 1", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_2, + name="Switch 2", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_3, + name="Switch 3", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_4, + name="Switch 4", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_5, + name="Switch 5", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_6, + name="Switch 6", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB1, + name="USB 1", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB2, + name="USB 2", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB3, + name="USB 3", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB4, + name="USB 4", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB5, + name="USB 5", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB6, + name="USB 6", + ), + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Socket", + device_class=SwitchDeviceClass.OUTLET, + ), + ), + # Smart panel with switches and zigbee hub ? + # Not documented + "dgnzk": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + ), + LocalTuyaEntity( + id=(DPCode.SWITCH_1, DPCode.SWITCH1), + name="Switch 1", + ), + LocalTuyaEntity( + id=(DPCode.SWITCH_2, DPCode.SWITCH2), + name="Switch 2", + ), + LocalTuyaEntity( + id=(DPCode.SWITCH_3, DPCode.SWITCH3), + name="Switch 3", + ), + LocalTuyaEntity( + id=(DPCode.SWITCH_4, DPCode.SWITCH4), + name="Switch 4", + ), + LocalTuyaEntity( + id=(DPCode.SWITCH_5, DPCode.SWITCH5), + name="Switch 5", + ), + LocalTuyaEntity( + id=(DPCode.SWITCH_6, DPCode.SWITCH6), + name="Switch 6", + ), + LocalTuyaEntity( + id=DPCode.VOICE_PLAY, + name="Voice", + icon="mdi:play", + ), + LocalTuyaEntity( + id=DPCode.VOICE_BT_PLAY, + name="BT Voice", + icon="mdi:play", + ), + LocalTuyaEntity( + id=DPCode.MUTE, + name="Mute", + icon="mdi:volume-off", + ), + LocalTuyaEntity( + id=DPCode.VOICE_MIC, + name="Microphone", + icon="mdi:microphone-off", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_WELCOME, + name="Welcome", + icon="mdi:human-greeting", + ), + ), + # Unknown product with switch capabilities + # Fond in some diffusers, plugs and PIR flood lights + # Not documented + "qjdcz": ( + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Switch", + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qn": ( + LocalTuyaEntity( + id=DPCode.ANION, + name="Ionizer", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_DISTURB, + name="Do Not Disturb", + icon="mdi:minus-circle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.VOICE_SWITCH, + name="Mute Voice", + icon="mdi:account-voice", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.RESET_MAP, + name="Map Resetting", + icon="mdi:backup-restore", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.BREAK_CLEAN, + name="Resumable Cleaning", + icon="mdi:cog-play-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.Y_MOP, + name="Mop Y", + icon="mdi:dots-vertical", + entity_category=EntityCategory.CONFIG, + ), + ), + # Water Valve + "sfkzq": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + icon="mdi:valve", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_WEATHER, + name="Smart Weather", + icon="mdi:auto-mode", + entity_category=EntityCategory.CONFIG, + ), + ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": ( + LocalTuyaEntity( + id=DPCode.MUFFLING, + name="Mute", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + LocalTuyaEntity( + id=DPCode.WIRELESS_BATTERYLOCK, + name="Battery Lock", + icon="mdi:battery-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.CRY_DETECTION_SWITCH, + name="Cry Detection", + icon="mdi:emoticon-cry", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.DECIBEL_SWITCH, + name="Sound Detection", + icon="mdi:microphone-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.RECORD_SWITCH, + name="Video Recording", + icon="mdi:record-rec", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.MOTION_RECORD, + name="Motion Recording", + icon="mdi:record-rec", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.BASIC_PRIVATE, + name="Privacy Mode", + icon="mdi:eye-off", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.BASIC_FLIP, + name="Flip", + icon="mdi:flip-horizontal", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.BASIC_OSD, + name="Time Watermark", + icon="mdi:watermark", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.BASIC_WDR, + name="Wide Dynamic Range", + icon="mdi:watermark", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.MOTION_TRACKING, + name="Motion Tracking", + icon="mdi:motion-sensor", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.MOTION_SWITCH, + name="Motion Alarm", + icon="mdi:motion-sensor", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.PTZ_STOP, + name="PTZ Stop", + icon="mdi:stop-circle", + entity_category=EntityCategory.CONFIG, + ), + ), + # Fingerbot + "szjqr": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + icon="mdi:cursor-pointer", + ), + ), + # IoT Switch? + # Note: Undocumented + "tdq": ( + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Switch 1", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_2, + name="Switch 2", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_3, + name="Switch 3", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_4, + name="Switch 4", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + ), + # Solar Light + # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 + "tyndj": ( + LocalTuyaEntity( + id=DPCode.SWITCH_SAVE_ENERGY, + name="Energy Saving", + icon="mdi:leaf", + entity_category=EntityCategory.CONFIG, + ), + ), + # PIR Detector + # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + "pir": ( + LocalTuyaEntity( + id=DPCode.MOD_ON_TMR, + icon="mdi:timer-play", + entity_category=EntityCategory.CONFIG, + name="Timer", + ), + ), + # Thermostatic Radiator Valve + # Not documented + "wkf": ( + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=(DPCode.WINDOW_CHECK, DPCode.WINDOW_STATE), + name="Open Window Detection", + icon="mdi:window-open", + entity_category=EntityCategory.CONFIG, + ), + ), + # Air Conditioner Mate (Smart IR Socket) + "wnykq": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + ), + ), + # Zigbee Gateway (dunno if it's useful) + # "wg2": ( + # LocalTuyaEntity( + # id=DPCode.SWITCH_ALARM_SOUND, + # name="Switch", + # ), + # ), + # SIREN: Siren (switch) with Temperature and humidity sensor + # https://developer.tuya.com/en/docs/iot/f?id=Kavck4sr3o5ek + "wsdcg": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + device_class=SwitchDeviceClass.OUTLET, + ), + ), + # Ceiling Light + # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r + "xdd": ( + LocalTuyaEntity( + id=DPCode.DO_NOT_DISTURB, + name="Do Not Disturb", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Diffuser + # https://developer.tuya.com/en/docs/iot/categoryxxj?id=Kaiuz1f9mo6bl + "xxj": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Power", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_SPRAY, + name="Spray", + icon="mdi:spray", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_VOICE, + name="Voice", + icon="mdi:account-voice", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Electricity Meter + # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 + "zndb": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + LocalTuyaEntity( + id=DPCode.ANION, + name="Anion", + icon="mdi:atom", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.HUMIDIFIER, + name="Humidification", + icon="mdi:air-humidifier", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.OXYGEN, + name="Oxygen Bar", + icon="mdi:molecule", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.FAN_COOL, + name="Natural Wind", + icon="mdi:weather-windy", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.FAN_BEEP, + name="Sound", + icon="mdi:minus-circle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + ), + # Fan switch + "fskg": ( + LocalTuyaEntity( + id=DPCode.BACKLIGHT_SWITCH, + name="LED Siwtch", + icon="mdi:led-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Curtain + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc + "cl": ( + LocalTuyaEntity( + id=DPCode.CONTROL_BACK, + name="Reverse", + icon="mdi:swap-horizontal", + entity_category=EntityCategory.CONFIG, + condition_contains_any=["true", "false"], + ), + LocalTuyaEntity( + id=DPCode.OPPOSITE, + name="Reverse", + icon="mdi:swap-horizontal", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.UP_CONFIRM, + name="Set Upper Limit", + icon="mdi:arrow-collapse-up", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.MIDDLE_CONFIRM, + name="Set Middle Limit", + icon="mdi:format-vertical-align-center", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.DOWN_CONFIRM, + name="Set Down Limit", + icon="mdi:arrow-collapse-down", + entity_category=EntityCategory.CONFIG, + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + LocalTuyaEntity( + id=DPCode.SWITCH_SOUND, + name="Voice", + icon="mdi:account-voice", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SLEEP, + name="Sleep", + icon="mdi:power-sleep", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.STERILIZATION, + name="Sterilization", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_SPRAY, + name="Spray", + icon="mdi:spray", + entity_category=EntityCategory.CONFIG, + ), + ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf + "mal": ( + LocalTuyaEntity( + id=DPCode.SWITCH_ALARM_SOUND, + name="Sound", + icon="mdi:volume-source", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_ALARM_LIGHT, + name="Light", + icon="mdi:alarm-light-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_KB_SOUND, + name="Key Tone Sound", + icon="mdi:volume-source", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_KB_LIGHT, + name="Keypad Light", + icon="mdi:alarm-light-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_ALARM_CALL, + name="Call", + icon="mdi:phone", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_ALARM_SMS, + name="SMS", + icon="mdi:message", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_ALARM_PROPEL, + name="Push Notification", + icon="mdi:bell-badge-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.MUFFLING, + name="Mute", + icon="mdi:volume-mute", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Water Meter + # https://developer.tuya.com/en/docs/iot/f?id=Ka8n052xu7w4c + "znsb": ( + LocalTuyaEntity( + id=DPCode.SWITCH_COLD, + name="Valve", + icon="mdi:Valve", + ), + LocalTuyaEntity( + id=DPCode.AUTO_CLEAN, + name="Auto Clean", + icon="mdi:auto-fix", + entity_category=EntityCategory.CONFIG, + ), + ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": ( + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ECO, + name="ECO", + icon="mdi:sprout", + entity_category=EntityCategory.CONFIG, + ), + ), +} + +# Scene Switch +# https://developer.tuya.com/en/docs/iot/f?id=K9gf7nx6jelo8 +SWITCHES["cjkg"] = SWITCHES["kg"] + +# Wireless Switch # also can come as knob switch. +# https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5 +SWITCHES["wxkg"] = SWITCHES["kg"] + +# Socket (duplicate of `pc`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SWITCHES["cz"] = SWITCHES["pc"] + +# Climates / heaters +SWITCHES["wkf"] = SWITCHES["wk"] +SWITCHES["rs"] = SWITCHES["wk"] +SWITCHES["qn"] = SWITCHES["wk"] +SWITCHES["kt"] = SWITCHES["wk"] + +# Dehumidifier +# https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha +SWITCHES["cs"] = SWITCHES["jsq"] diff --git a/custom_components/localtuya/core/ha_entities/vacuums.py b/custom_components/localtuya/core/ha_entities/vacuums.py new file mode 100644 index 00000000..c3c96a1e --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/vacuums.py @@ -0,0 +1,100 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE + +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" + +DEFAULT_IDLE_STATUS = "standby,sleep" +DEFAULT_RETURNING_STATUS = "docking,to_charge,goto_charge" +DEFAULT_DOCKED_STATUS = "charging,chargecompleted,charge_done" +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 localtuya_vaccuums( + modes: str = None, + returning_status_value: str = None, + return_mode: str = None, + fan_speeds: str = None, + paused_state: str = None, + stop_status: str = None, + idle_status_value: str = None, + docked_status_value: str = None, +) -> dict: + """Will return dict with the vacuum localtuya entity configs""" + data = { + CONF_MODES: CLOUD_VALUE(modes, CONF_MODE_DP, "range", str), + CONF_IDLE_STATUS_VALUE: idle_status_value or DEFAULT_IDLE_STATUS, + CONF_STOP_STATUS: stop_status or DEFAULT_STOP_STATUS, + CONF_PAUSED_STATE: paused_state or DEFAULT_PAUSED_STATE, + CONF_FAN_SPEEDS: CLOUD_VALUE(fan_speeds, CONF_FAN_SPEED_DP, "range", str), + CONF_RETURN_MODE: return_mode or DEFAULT_RETURN_MODE, + CONF_RETURNING_STATUS_VALUE: returning_status_value or DEFAULT_RETURNING_STATUS, + CONF_DOCKED_STATUS_VALUE: docked_status_value or CONF_DOCKED_STATUS_VALUE, + } + + return data + + +VACUUMS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + LocalTuyaEntity( + id=DPCode.STATUS, + icon="mdi:robot-vacuum", + powergo_dp=(DPCode.POWER_GO, DPCode.POWER, DPCode.SWITCH), + battery_dp=( + DPCode.BATTERY_PERCENTAGE, + DPCode.ELECTRICITY_LEFT, + DPCode.RESIDUAL_ELECTRICITY, + ), + mode_dp=DPCode.MODE, + fan_speed_dp=DPCode.SUCTION, + pause_dp=DPCode.PAUSE, + locate_dp=DPCode.SEEK, + clean_time_dp=( + DPCode.CLEAN_TIME, + DPCode.TOTAL_CLEAN_AREA, + DPCode.TOTAL_CLEAN_TIME, + ), + clean_area_dp=DPCode.CLEAN_AREA, + clean_record_dp=DPCode.CLEAN_RECORD, + fault_dp=DPCode.FAULT, + custom_configs=localtuya_vaccuums( + modes=DEFAULT_MODES, + returning_status_value=DEFAULT_RETURNING_STATUS, + return_mode=DEFAULT_RETURN_MODE, + fan_speeds=DEFAULT_FAN_SPEEDS, + paused_state=DEFAULT_PAUSED_STATE, + stop_status=DEFAULT_STOP_STATUS, + idle_status_value=DEFAULT_IDLE_STATUS, + docked_status_value=DEFAULT_DOCKED_STATUS, + ), + ), + ), +} diff --git a/custom_components/localtuya/core/ha_entities/water_heaters.py b/custom_components/localtuya/core/ha_entities/water_heaters.py new file mode 100644 index 00000000..941a749b --- /dev/null +++ b/custom_components/localtuya/core/ha_entities/water_heaters.py @@ -0,0 +1,99 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from homeassistant.components.water_heater import ( + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, +) +from homeassistant.const import CONF_TEMPERATURE_UNIT + +from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE +from ...const import ( + CONF_TARGET_TEMPERATURE_LOW_DP, + CONF_TARGET_TEMPERATURE_HIGH_DP, + CONF_PRECISION, + CONF_TARGET_PRECISION, + CONF_CURRENT_TEMPERATURE_DP, + CONF_MAX_TEMP, + CONF_MIN_TEMP, + CONF_TARGET_TEMPERATURE_DP, + CONF_MODES, + CONF_MODE_DP, +) + + +UNIT_C = "celsius" +UNIT_F = "fahrenheit" + + +def localtuya_water_heater( + modes={}, + unit=None, + min_temperature=DEFAULT_MIN_TEMP, + max_temperature=DEFAULT_MAX_TEMP, + current_precsion=0.1, + target_precision=1, +) -> dict: + """Create localtuya climate configs""" + data = {} + for key, conf in { + CONF_MODES: CLOUD_VALUE(modes, CONF_MODE_DP, "range", dict), + CONF_MIN_TEMP: CLOUD_VALUE( + min_temperature, CONF_TARGET_TEMPERATURE_DP, "min", scale=True + ), + CONF_MAX_TEMP: CLOUD_VALUE( + max_temperature, CONF_TARGET_TEMPERATURE_DP, "max", scale=True + ), + CONF_TEMPERATURE_UNIT: unit, + CONF_PRECISION: CLOUD_VALUE( + str(current_precsion), CONF_CURRENT_TEMPERATURE_DP, "scale", str + ), + CONF_TARGET_PRECISION: CLOUD_VALUE( + str(target_precision), CONF_TARGET_TEMPERATURE_DP, "scale", str + ), + }.items(): + if conf is not None: + data.update({key: conf}) + + return data + + +WATER_HEATERS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Heater + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46epy4j82 + "qn": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + current_temperature_dp=(DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F), + target_temperature_low_dp=(DPCode.TEMP_LOW, DPCode.LOWER_TEMP), + target_temperature_high_dp=(DPCode.TEMP_UP, DPCode.UPPER_TEMP), + mode_dp=DPCode.MODE, + fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + custom_configs=localtuya_water_heater( + current_precsion=0.1, target_precision=0.1 + ), + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx + "rs": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + current_temperature_dp=(DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F), + target_temperature_low_dp=(DPCode.TEMP_LOW, DPCode.LOWER_TEMP), + target_temperature_high_dp=(DPCode.TEMP_UP, DPCode.UPPER_TEMP), + mode_dp=DPCode.MODE, + fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + custom_configs=localtuya_water_heater( + current_precsion=0.1, target_precision=0.1 + ), + ), + ), +} diff --git a/custom_components/localtuya/core/helpers.py b/custom_components/localtuya/core/helpers.py new file mode 100644 index 00000000..254e6888 --- /dev/null +++ b/custom_components/localtuya/core/helpers.py @@ -0,0 +1,116 @@ +""" +Helpers functions for HASS-LocalTuya. +""" + +import asyncio +import logging +import os.path +from enum import Enum +from fnmatch import fnmatch +from typing import NamedTuple + +from homeassistant.util.yaml import load_yaml, dump +from homeassistant.const import CONF_PLATFORM, CONF_ENTITIES + + +import custom_components.localtuya.templates as templates_dir + +JSON_TYPE = list | dict | str + +_LOGGER = logging.getLogger(__name__) + + +############################### +# Templates # +############################### +class templates: + + def yaml_dump(config, fname: str | None = None) -> JSON_TYPE: + """Save yaml config.""" + try: + with open(fname, "w", encoding="utf-8") as conf_file: + return conf_file.write(dump(config)) + except UnicodeDecodeError as exc: + _LOGGER.error("Unable to save file %s: %s", fname, exc) + + def list_templates(): + """Return the available templates files.""" + dir = os.path.dirname(templates_dir.__file__) + files = {} + for e in sorted(os.scandir(dir), key=lambda e: e.name): + file: str = e.name.lower() + if e.is_file() and (fnmatch(file, "*yaml") or fnmatch(file, "*yml")): + # fn = str(file).replace(".yaml", "").replace("_", " ") + files[e.name] = e.name + return files + + def import_config(filename): + """Create a data that can be used as config in localtuya.""" + template_dir = os.path.dirname(templates_dir.__file__) + template_file = os.path.join(template_dir, filename) + _config = load_yaml(template_file) + entities = [] + for cfg in _config: + ent = {} + for plat, values in cfg.items(): + for key, value in values.items(): + ent[str(key)] = ( + str(value) + if not isinstance(value, (bool, float, dict, list)) + else value + ) + ent[CONF_PLATFORM] = plat + entities.append(ent) + if not entities: + raise ValueError("No entities found the can be used for localtuya") + return entities + + @classmethod + def export_config(cls, config: dict, config_name: str): + """Create a yaml config file for localtuya.""" + export_config = [] + for cfg in config[CONF_ENTITIES]: + # Special case device_classes + for k, v in cfg.items(): + if not type(v) is str and isinstance(v, Enum): + cfg[k] = v.value + + ents = {cfg[CONF_PLATFORM]: cfg} + export_config.append(ents) + fname = ( + config_name + ".yaml" if not config_name.endswith(".yaml") else config_name + ) + fname = fname.replace(" ", "_") + template_dir = os.path.dirname(templates_dir.__file__) + template_file = os.path.join(template_dir, fname) + + cls.yaml_dump(export_config, template_file) + + +################################ +## config flows ## +################################ + +from ..const import CONF_LOCAL_KEY, CONF_NODE_ID + +GATEWAY = NamedTuple("Gateway", [("id", str), ("data", dict)]) + + +def get_gateway_by_deviceid(device_id: str, cloud_data: dict) -> GATEWAY: + """Return the gateway (id, data) of the sub-deviceID if existed in cloud_data.""" + + if sub_device := cloud_data.get(device_id): + for dev_id, dev_data in cloud_data.items(): + # Get gateway Assuming the LocalKey is the same gateway LocalKey! + if ( + dev_id != device_id + and not dev_data.get(CONF_NODE_ID) + and dev_data.get(CONF_LOCAL_KEY) == sub_device.get(CONF_LOCAL_KEY) + ): + return GATEWAY(dev_id, dev_data) + + +############################### +# Auto configure device # +############################### +from .ha_entities import gen_localtuya_entities diff --git a/custom_components/localtuya/core/pytuya/__init__.py b/custom_components/localtuya/core/pytuya/__init__.py new file mode 100644 index 00000000..8797fb5e --- /dev/null +++ b/custom_components/localtuya/core/pytuya/__init__.py @@ -0,0 +1,1662 @@ +# PyTuya Module +# -*- coding: utf-8 -*- +""" +Python module to interface with Tuya WiFi smart devices. + +Author: clach04, postlund +Maintained by: rospogrigio, xZetsubou + +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], 3.2, 3.3, 3.4 or 3.5 + 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) + * Tuya Protocol 3.4 and 3.5 Support by uzlonewolf + Enhancement to TuyaMessage logic for multi-payload messages and Tuya Protocol 3.4 support + * TinyTuya https://github.com/jasonacox/tinytuya by jasonacox, uzlonewolf + Several CLI tools and code for Tuya devices +""" + +import os +import asyncio +import errno +import base64 +import binascii +import hmac +import json +import logging +import struct +import time +import weakref +from enum import Enum +from abc import ABC, abstractmethod +from typing import Self +from collections import namedtuple +from hashlib import md5, sha256 + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +version_tuple = (2024, 6, 0) +version = version_string = __version__ = "%d.%d.%d" % version_tuple +__author__ = "rospogrigio, xZetsubou" + +_LOGGER = logging.getLogger(__name__) + +# Tuya Packet Format +TuyaHeader = namedtuple("TuyaHeader", "prefix seqno cmd length total_length") +MessagePayload = namedtuple("MessagePayload", "cmd payload") +try: + TuyaMessage = namedtuple( + "TuyaMessage", + "seqno cmd retcode payload crc crc_good prefix iv", + defaults=(True, 0x55AA, None), + ) +except: + TuyaMessage = namedtuple( + "TuyaMessage", "seqno cmd retcode payload crc crc_good prefix iv" + ) + +# TinyTuya Error Response Codes +ERR_JSON = 900 +ERR_CONNECT = 901 +ERR_TIMEOUT = 902 +ERR_RANGE = 903 +ERR_PAYLOAD = 904 +ERR_OFFLINE = 905 +ERR_STATE = 906 +ERR_FUNCTION = 907 +ERR_DEVTYPE = 908 +ERR_CLOUDKEY = 909 +ERR_CLOUDRESP = 910 +ERR_CLOUDTOKEN = 911 +ERR_PARAMS = 912 +ERR_CLOUD = 913 + +error_codes = { + ERR_JSON: "Invalid JSON Response from Device", + ERR_CONNECT: "Network Error: Unable to Connect", + ERR_TIMEOUT: "Timeout Waiting for Device", + ERR_RANGE: "Specified Value Out of Range", + ERR_PAYLOAD: "Unexpected Payload from Device", + ERR_OFFLINE: "Network Error: Device Unreachable", + ERR_STATE: "Device in Unknown State", + ERR_FUNCTION: "Function Not Supported by Device", + ERR_DEVTYPE: "Device22 Detected: Retry Command", + ERR_CLOUDKEY: "Missing Tuya Cloud Key and Secret", + ERR_CLOUDRESP: "Invalid JSON Response from Cloud", + ERR_CLOUDTOKEN: "Unable to Get Cloud Token", + ERR_PARAMS: "Missing Function Parameters", + ERR_CLOUD: "Error Response from Tuya Cloud", + None: "Unknown Error", +} + + +class DecodeError(Exception): + """Specific Exception caused by decoding error.""" + + pass + + +class SubdeviceState(Enum): + ONLINE = 1 + OFFLINE = 2 + ABSENT = 3 + + +# Tuya Command Types +# Reference: +# https://github.com/tuya/tuya-iotos-embeded-sdk-wifi-ble-bk7231n/blob/master/sdk/include/lan_protocol.h +AP_CONFIG = 0x01 # FRM_TP_CFG_WF # only used for ap 3.0 network config +ACTIVE = 0x02 # FRM_TP_ACTV (discard) # WORK_MODE_CMD +SESS_KEY_NEG_START = 0x03 # FRM_SECURITY_TYPE3 # negotiate session key +SESS_KEY_NEG_RESP = 0x04 # FRM_SECURITY_TYPE4 # negotiate session key response +SESS_KEY_NEG_FINISH = 0x05 # FRM_SECURITY_TYPE5 # finalize session key negotiation +UNBIND = 0x06 # FRM_TP_UNBIND_DEV # DATA_QUERT_CMD - issue command +CONTROL = 0x07 # FRM_TP_CMD # STATE_UPLOAD_CMD +STATUS = 0x08 # FRM_TP_STAT_REPORT # STATE_QUERY_CMD +HEART_BEAT = 0x09 # FRM_TP_HB +DP_QUERY = 0x0A # 10 # FRM_QUERY_STAT # UPDATE_START_CMD - get data points +QUERY_WIFI = 0x0B # 11 # FRM_SSID_QUERY (discard) # UPDATE_TRANS_CMD +TOKEN_BIND = 0x0C # 12 # FRM_USER_BIND_REQ # GET_ONLINE_TIME_CMD - system time (GMT) +CONTROL_NEW = 0x0D # 13 # FRM_TP_NEW_CMD # FACTORY_MODE_CMD +ENABLE_WIFI = 0x0E # 14 # FRM_ADD_SUB_DEV_CMD # WIFI_TEST_CMD +WIFI_INFO = 0x0F # 15 # FRM_CFG_WIFI_INFO +DP_QUERY_NEW = 0x10 # 16 # FRM_QUERY_STAT_NEW +SCENE_EXECUTE = 0x11 # 17 # FRM_SCENE_EXEC +UPDATEDPS = 0x12 # 18 # FRM_LAN_QUERY_DP # Request refresh of DPS +UDP_NEW = 0x13 # 19 # FR_TYPE_ENCRYPTION +AP_CONFIG_NEW = 0x14 # 20 # FRM_AP_CFG_WF_V40 +BOARDCAST_LPV34 = 0x23 # 35 # FR_TYPE_BOARDCAST_LPV34 +LAN_EXT_STREAM = 0x40 # 64 # FRM_LAN_EXT_STREAM + +UPDATE_DPS_LIST = [3.2, 3.3, 3.4, 3.5] # 3.2 behaves like 3.3 with type_0d + +PROTOCOL_VERSION_BYTES_31 = b"3.1" +PROTOCOL_VERSION_BYTES_33 = b"3.3" +PROTOCOL_VERSION_BYTES_34 = b"3.4" +PROTOCOL_VERSION_BYTES_35 = b"3.5" + +PROTOCOL_3x_HEADER = 12 * b"\x00" +PROTOCOL_33_HEADER = PROTOCOL_VERSION_BYTES_33 + PROTOCOL_3x_HEADER +PROTOCOL_34_HEADER = PROTOCOL_VERSION_BYTES_34 + PROTOCOL_3x_HEADER +PROTOCOL_35_HEADER = PROTOCOL_VERSION_BYTES_35 + PROTOCOL_3x_HEADER +MESSAGE_RECV_HEADER_FMT = ">5I" # 4*uint32: prefix, seqno, cmd, length, retcode +MESSAGE_HEADER_FMT = MESSAGE_HEADER_FMT_55AA = ( + ">4I" # 4*uint32: prefix, seqno, cmd, length [, retcode] +) +MESSAGE_HEADER_FMT_6699 = ">IHIII" # 4*uint32: prefix, unknown, seqno, cmd, length +MESSAGE_RETCODE_FMT = ">I" # retcode for received messages +MESSAGE_END_FMT = MESSAGE_END_FMT_55AA = ">2I" # 2*uint32: crc, suffix +MESSAGE_END_FMT_HMAC = ">32sI" # 32s:hmac, uint32:suffix +MESSAGE_END_FMT_6699 = ">16sI" # 16s:tag, suffix +PREFIX_VALUE = PREFIX_55AA_VALUE = 0x000055AA +PREFIX_BIN = PREFIX_55AA_BIN = b"\x00\x00U\xaa" +SUFFIX_VALUE = SUFFIX_55AA_VALUE = 0x0000AA55 +SUFFIX_BIN = SUFFIX_55AA_BIN = b"\x00\x00\xaaU" +PREFIX_6699_VALUE = 0x00006699 +PREFIX_6699_BIN = b"\x00\x00\x66\x99" +SUFFIX_6699_VALUE = 0x00009966 +SUFFIX_6699_BIN = b"\x00\x00\x99\x66" + +NO_PROTOCOL_HEADER_CMDS = [ + DP_QUERY, + DP_QUERY_NEW, + UPDATEDPS, + HEART_BEAT, + SESS_KEY_NEG_START, + SESS_KEY_NEG_RESP, + SESS_KEY_NEG_FINISH, + LAN_EXT_STREAM, +] + +HEARTBEAT_INTERVAL = 9 + +# DPS that are known to be safe to use with update_dps (0x12) command +UPDATE_DPS_WHITELIST = [18, 19, 20] # Socket (Wi-Fi) + +# Tuya Device Dictionary - Command and Payload Overrides +# This is intended to match requests.json payload at +# https://github.com/codetheweb/tuyapi : +# 'type_0a' devices require the 0a command for the DP_QUERY request +# 'type_0d' devices require the 0d command for the DP_QUERY request and a list of +# dps used set to Null in the request payload +# 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) + +# Any command not defined in payload_dict will be sent as-is with a +# payload of {"gwId": "", "devId": "", "uid": "", "t": ""} + +payload_dict = { + # Default Device + "type_0a": { + AP_CONFIG: { # [BETA] Set Control Values on Device + "command": {"gwId": "", "devId": "", "uid": "", "t": "", "cid": ""}, + }, + CONTROL: { # Set Control Values on Device + "command": {"devId": "", "uid": "", "t": "", "cid": ""}, + }, + STATUS: { # Get Status from Device + "command": {"gwId": "", "devId": "", "cid": ""}, + }, + HEART_BEAT: {"command": {"gwId": "", "devId": ""}}, + DP_QUERY: { # Get Data Points from Device + "command": {"gwId": "", "devId": "", "uid": "", "t": "", "cid": ""}, + }, + CONTROL_NEW: {"command": {"devId": "", "uid": "", "t": "", "cid": ""}}, + DP_QUERY_NEW: {"command": {"devId": "", "uid": "", "t": "", "cid": ""}}, + UPDATEDPS: {"command": {"dpId": [18, 19, 20], "cid": ""}}, + LAN_EXT_STREAM: {"command": {"reqType": "", "data": {}}}, + }, + # Special Case Device "0d" - Some of these devices + # Require the 0d command as the DP_QUERY status request and the list of + # dps requested payload + "type_0d": { + DP_QUERY: { # Get Data Points from Device + "command_override": CONTROL_NEW, # Uses CONTROL_NEW command for some reason + "command": {"devId": "", "uid": "", "t": "", "cid": ""}, + }, + }, + "v3.4": { + CONTROL: { + "command_override": CONTROL_NEW, # Uses CONTROL_NEW command + "command": {"protocol": 5, "t": "int", "data": {"cid": ""}}, + }, + DP_QUERY: {"command_override": DP_QUERY_NEW}, + }, + "v3.5": { + CONTROL: { + "command_override": CONTROL_NEW, # Uses CONTROL_NEW command + "command": {"protocol": 5, "t": "int", "data": {"cid": ""}}, + }, + DP_QUERY: {"command_override": DP_QUERY_NEW}, + }, +} + + +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"] + name = self.extra.get("name") + prefix = f"{dev_id[0:3]}...{dev_id[-3:]}" + if name: + return f"[{prefix} - {name}] {msg}", kwargs + + return f"[{prefix}] {msg}", kwargs + + +class ContextualLogger: + """Contextual logger adding device id to log points.""" + + def __init__(self): + """Initialize a new ContextualLogger.""" + self._logger = None + self._enable_debug = False + + self._reset_warning = int(time.time()) + self._last_warning = "" + + def set_logger(self, logger, device_id, enable_debug=False, name=None): + """Set base logger to use.""" + self._enable_debug = enable_debug + self._logger = TuyaLoggingAdapter( + logger, {"device_id": device_id, "name": name} + ) + return self + + def debug(self, msg, *args, force=False): + """Debug level log for device. force will ignore device debug check.""" + if not self._enable_debug and not force: + return + return self._logger.log(logging.DEBUG, msg, *args) + + def info(self, msg, *args, clear_warning=False): + """Info level log. clear_warning to re-enable warings msgs if duplicated""" + if clear_warning: + self._last_warning = "" + + return self._logger.log(logging.INFO, msg, *args) + + def warning(self, msg, *args): + """Warning method log.""" + if msg != self._last_warning: + self._last_warning = msg + return self._logger.log(logging.WARNING, msg, *args) + # else: + # self.info(msg) + + 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, hmac_key=None): + """Pack a TuyaMessage into bytes.""" + if msg.prefix == PREFIX_55AA_VALUE: + header_fmt = MESSAGE_HEADER_FMT_55AA + end_fmt = MESSAGE_END_FMT_HMAC if hmac_key else MESSAGE_END_FMT_55AA + msg_len = len(msg.payload) + struct.calcsize(end_fmt) + header_data = (msg.prefix, msg.seqno, msg.cmd, msg_len) + elif msg.prefix == PREFIX_6699_VALUE: + if not hmac_key: + raise TypeError("key must be provided to pack 6699-format messages") + header_fmt = MESSAGE_HEADER_FMT_6699 + end_fmt = MESSAGE_END_FMT_6699 + msg_len = len(msg.payload) + (struct.calcsize(end_fmt) - 4) + 12 + if type(msg.retcode) == int: + msg_len += struct.calcsize(MESSAGE_RETCODE_FMT) + header_data = (msg.prefix, 0, msg.seqno, msg.cmd, msg_len) + else: + raise ValueError( + "pack_message() cannot handle message format %08X" % msg.prefix + ) + + # Create full message excluding CRC and suffix + data = struct.pack(header_fmt, *header_data) + + if msg.prefix == PREFIX_6699_VALUE: + cipher = AESCipher(hmac_key) + if type(msg.retcode) == int: + raw = struct.pack(MESSAGE_RETCODE_FMT, msg.retcode) + msg.payload + else: + raw = msg.payload + data2 = cipher.encrypt( + raw, + use_base64=False, + pad=False, + iv=True if not msg.iv else msg.iv, + header=data[4:], + ) + data += data2 + SUFFIX_6699_BIN + else: + data += msg.payload + if hmac_key: + crc = hmac.new(hmac_key, data, sha256).digest() + else: + crc = binascii.crc32(data) & 0xFFFFFFFF + # Calculate CRC, add it together with suffix + data += struct.pack(end_fmt, crc, SUFFIX_VALUE) + + return data + + +def unpack_message(data, hmac_key=None, header=None, no_retcode=False, logger=_LOGGER): + """Unpack bytes into a TuyaMessage.""" + if header is None: + header = parse_header(data) + + if header.prefix == PREFIX_55AA_VALUE: + # 4-word header plus return code + header_len = struct.calcsize(MESSAGE_HEADER_FMT_55AA) + end_fmt = MESSAGE_END_FMT_HMAC if hmac_key else MESSAGE_END_FMT_55AA + retcode_len = 0 if no_retcode else struct.calcsize(MESSAGE_RETCODE_FMT) + msg_len = header_len + header.length + elif header.prefix == PREFIX_6699_VALUE: + if not hmac_key: + raise TypeError("key must be provided to unpack 6699-format messages") + header_len = struct.calcsize(MESSAGE_HEADER_FMT_6699) + end_fmt = MESSAGE_END_FMT_6699 + retcode_len = 0 + msg_len = header_len + header.length + 4 + else: + raise ValueError( + "unpack_message() cannot handle message format %08X" % header.prefix + ) + + if len(data) < msg_len: + logger.debug( + "unpack_message(): not enough data to unpack payload! need %d but only have %d", + header_len + header.length, + len(data), + ) + raise DecodeError(f"Not enough data to unpack payload: {data}") + + end_len = struct.calcsize(end_fmt) + # the retcode is technically part of the payload, but strip it as we do not want it here + retcode = ( + 0 + if not retcode_len + else struct.unpack( + MESSAGE_RETCODE_FMT, data[header_len : header_len + retcode_len] + )[0] + ) + payload = data[header_len + retcode_len : msg_len] + crc, suffix = struct.unpack(end_fmt, payload[-end_len:]) + payload = payload[:-end_len] + + if header.prefix == PREFIX_55AA_VALUE: + if hmac_key: + have_crc = hmac.new( + hmac_key, data[: (header_len + header.length) - end_len], sha256 + ).digest() + else: + have_crc = ( + binascii.crc32(data[: (header_len + header.length) - end_len]) + & 0xFFFFFFFF + ) + + if suffix != SUFFIX_VALUE: + logger.debug("Suffix prefix wrong! %08X != %08X", suffix, SUFFIX_VALUE) + + if crc != have_crc: + if hmac_key: + logger.debug( + "HMAC checksum wrong! %r != %r", + binascii.hexlify(have_crc), + binascii.hexlify(crc), + ) + else: + logger.debug("CRC wrong! %08X != %08X", have_crc, crc) + crc_good = crc == have_crc + iv = None + elif header.prefix == PREFIX_6699_VALUE: + iv = payload[:12] + payload = payload[12:] + try: + cipher = AESCipher(hmac_key) + payload = cipher.decrypt( + payload, + use_base64=False, + decode_text=False, + iv=iv, + header=data[4:header_len], + tag=crc, + ) + crc_good = True + except: + crc_good = False + + retcode_len = struct.calcsize(MESSAGE_RETCODE_FMT) + if no_retcode is False: + pass + elif ( + no_retcode is None + and payload[0:1] != b"{" + and payload[retcode_len : retcode_len + 1] == b"{" + ): + retcode_len = struct.calcsize(MESSAGE_RETCODE_FMT) + else: + retcode_len = 0 + if retcode_len: + retcode = struct.unpack(MESSAGE_RETCODE_FMT, payload[:retcode_len])[0] + payload = payload[retcode_len:] + + return TuyaMessage( + header.seqno, header.cmd, retcode, payload, crc, crc_good, header.prefix, iv + ) + + +def parse_header(data, logger=_LOGGER): + """Unpack bytes into a TuyaHeader.""" + if data[:4] == PREFIX_6699_BIN: + fmt = MESSAGE_HEADER_FMT_6699 + else: + fmt = MESSAGE_HEADER_FMT_55AA + + header_len = struct.calcsize(fmt) + + if len(data) < header_len: + err = "Not enough data to unpack header" + logger.error(err) + raise DecodeError(err) + + unpacked = struct.unpack(fmt, data[:header_len]) + prefix = unpacked[0] + + if prefix == PREFIX_55AA_VALUE: + prefix, seqno, cmd, payload_len = unpacked + total_length = payload_len + header_len + elif prefix == PREFIX_6699_VALUE: + prefix, unknown, seqno, cmd, payload_len = unpacked + # seqno |= unknown << 32 + total_length = payload_len + header_len + len(SUFFIX_6699_BIN) + else: + err = f"Header prefix wrong! {prefix} is not {PREFIX_55AA_VALUE} or {PREFIX_6699_VALUE}" + logger.error(err) + raise DecodeError(err) + + # sanity check. currently the max payload length is somewhere around 300 bytes + if payload_len > 2000: + err = f"Header claims the packet size is over 2000 bytes! It is most likely corrupt. Claimed size: {payload_len} bytes. fmt: {fmt} unpacked: {unpacked}" + logger.error(err) + raise DecodeError(err) + + return TuyaHeader(prefix, seqno, cmd, payload_len, total_length) + + +class AESCipher: + """Cipher module for Tuya communication.""" + + def __init__(self, key): + """Initialize a new AESCipher.""" + self.block_size = 16 + self.key = key + self.cipher = Cipher(algorithms.AES(key), modes.ECB(), default_backend()) + + def encrypt(self, raw, use_base64=True, pad=True, iv=False, header=None): + """Encrypt data to be sent to device.""" + if iv: + if iv is True: + if _LOGGER.isEnabledFor(logging.DEBUG): + iv = b"0123456789ab" + else: + iv = str(time.time() * 10)[:12].encode("utf8") + encryptor = Cipher(algorithms.AES(self.key), modes.GCM(iv)).encryptor() + if header: + encryptor.authenticate_additional_data(header) + crypted_text = encryptor.update(raw) + encryptor.finalize() + crypted_text = iv + crypted_text + encryptor.tag + else: + encryptor = self.cipher.encryptor() + if pad: + raw = self._pad(raw) + crypted_text = encryptor.update(raw) + encryptor.finalize() + return base64.b64encode(crypted_text) if use_base64 else crypted_text + + def decrypt( + self, enc, use_base64=True, decode_text=True, iv=False, header=None, tag=None + ): + """Decrypt data from device.""" + if not iv: + if use_base64: + enc = base64.b64decode(enc) + + if iv: + if iv is True: + iv = enc[:12] + enc = enc[12:] + if tag is None: + decryptor = Cipher( + algorithms.AES(self.key), modes.CTR(iv + b"\x00\x00\x00\x02") + ).decryptor() + else: + decryptor = Cipher( + algorithms.AES(self.key), modes.GCM(iv, tag) + ).decryptor() + if header and (tag is not None): + decryptor.authenticate_additional_data(header) + raw = decryptor.update(enc) + decryptor.finalize() + else: + decryptor = self.cipher.decryptor() + raw = decryptor.update(enc) + decryptor.finalize() + raw = self._unpad(raw) + + return raw.decode("utf-8") if decode_text else raw + + 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 on protocols < 3.3 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 + RESET_SEQNO = -101 + SESS_KEY_SEQNO = -102 + SUB_DEVICE_QUERY_SEQNO = -103 + + def __init__(self, dev_id, callback_status_update, protocol_version, local_key): + """Initialize a new MessageBuffer.""" + super().__init__() + self.buffer = b"" + self.listeners: dict[str, asyncio.Semaphore] = {} + self.callback_status_update = callback_status_update + self.version = protocol_version + self.local_key = local_key + + 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, cmd, timeout=5): + """Wait for response to a sequence number to be received and return it.""" + if seqno in self.listeners: + self.debug(f"listener exists for {seqno}") + if seqno == self.HEARTBEAT_SEQNO: + raise Exception(f"listener exists for {seqno}") + + self.debug("Command %d waiting for seq. number %d", cmd, seqno) + self.listeners[seqno] = asyncio.Semaphore(0) + try: + await asyncio.wait_for(self.listeners[seqno].acquire(), timeout=timeout) + except asyncio.TimeoutError: + self.debug( + "Command %d timed out waiting for sequence number %d", cmd, seqno + ) + del self.listeners[seqno] + raise TimeoutError( + f"Command {cmd} timed out waiting for sequence number {seqno}" + ) + 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 + + prefix_offset_55AA = self.buffer.find(PREFIX_55AA_BIN) + prefix_offset_6699 = self.buffer.find(PREFIX_6699_BIN) + prefixes = (prefix_offset_55AA, prefix_offset_6699) + + # If somehow we got unexpected message, we will ignore it and reset the buffer. + if prefix_offset_55AA < 0 and prefix_offset_6699 < 0: + self.debug(f"Got unexpected Message prefix: {self.buffer}", force=True) + self.buffer = b"" + break + + # If the prefix is not at the start of the message. + if prefix_offset_55AA != 0 and prefix_offset_6699 != 0: + self.debug(f"Message prefix offset not at the start {self.buffer}") + prefix_offset = min(prefix for prefix in prefixes if not prefix < 0) + self.buffer = self.buffer[prefix_offset:] + + header = parse_header(self.buffer, logger=self) + # Check if the all data for the message has been received. + if len(self.buffer) < header.total_length: + break + + hmac_key = self.local_key if self.version >= 3.4 else None + no_retcode = False + msg = unpack_message( + self.buffer, + header=header, + hmac_key=hmac_key, + no_retcode=no_retcode, + logger=self, + ) + self.buffer = self.buffer[header.total_length :] + self._dispatch(msg) + + def _dispatch(self, msg): + """Dispatch a message to someone that is listening.""" + + self.debug("Dispatching message CMD %r %s", msg.cmd, msg) + + if msg.seqno in self.listeners: + self.debug("Dispatching sequence number %d", msg.seqno) + self._release_listener(msg.seqno, msg) + + if msg.cmd == HEART_BEAT: + self.debug("Got heartbeat response") + self._release_listener(self.HEARTBEAT_SEQNO, msg) + elif msg.cmd == UPDATEDPS: + self.debug("Got normal updatedps response") + self._release_listener(self.RESET_SEQNO, msg) + elif msg.cmd == SESS_KEY_NEG_RESP: + self.debug("Got key negotiation response") + self._release_listener(self.SESS_KEY_SEQNO, msg) + elif msg.cmd == STATUS: + if self.RESET_SEQNO in self.listeners: + self.debug("Got reset status update") + self._release_listener(self.RESET_SEQNO, msg) + else: + self.debug("Got status update") + self.callback_status_update(msg) + elif msg.cmd == LAN_EXT_STREAM: + self._release_listener(self.SUB_DEVICE_QUERY_SEQNO, msg) + if msg.payload: + self.debug(f"Got Sub-devices status update") + self.callback_status_update(msg) + else: + if msg.cmd == CONTROL_NEW or not msg.payload: + self.debug( + "Got ACK message for command %d: ignoring it %s", msg.cmd, msg.seqno + ) + self.callback_status_update(msg, ack=True) + elif msg.seqno not in self.listeners: + self.debug( + "Got message type %d for unknown listener %d: %s", + msg.cmd, + msg.seqno, + msg, + ) + + def _release_listener(self, seqno, msg): + if seqno not in self.listeners: + return + + sem = self.listeners[seqno] + if isinstance(sem, asyncio.Semaphore): + self.listeners[seqno] = msg + sem.release() + else: + self.debug(f"{seqno} - Got additional message without request: skip {sem}") + + +class TuyaListener(ABC): + """Listener interface for Tuya device changes.""" + + sub_devices: dict[str, Self] + + @abstractmethod + def status_updated(self, status): + """Device updated status.""" + + @abstractmethod + def disconnected(self, exc=""): + """Device disconnected.""" + + @abstractmethod + def subdevice_state_updated(self, state: SubdeviceState): + """Device is offline or online.""" + + +class EmptyListener(TuyaListener): + """Listener doing nothing.""" + + def status_updated(self, status): + """Device updated status.""" + + def disconnected(self, exc=""): + """Device disconnected.""" + + def subdevice_state_updated(self, state: SubdeviceState): + """Device is offline or online.""" + + +class TuyaProtocol(asyncio.Protocol, ContextualLogger): + """Implementation of the Tuya protocol.""" + + def __init__( + self, + dev_id: str, + local_key: str, + protocol_version: float, + enable_debug: bool, + on_connected: asyncio.Future, + listener: TuyaListener, + ): + """ + 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.id = dev_id + self.local_key = local_key.encode("latin1") + self.real_local_key = self.local_key + self.dev_type = "type_0a" + self.dps_to_request = {} + + if protocol_version: + self.set_version(float(protocol_version)) + else: + # make sure we call our set_version() and not a subclass since some of + # them (such as BulbDevice) make connections when called + TuyaProtocol.set_version(self, 3.1) + + self.cipher = AESCipher(self.local_key) + self.seqno = 1 + self.transport = None + self.listener = weakref.ref(listener) + self.dispatcher = self._setup_dispatcher() + self.on_connected = on_connected + self.heartbeater: asyncio.Task | None = None + self._sub_devs_query_task: asyncio.Task | None = None + self.dps_cache = {} + self.sub_devices_states = {"online": [], "offline": []} + self.local_nonce = b"0123456789abcdef" # not-so-random random key + self.remote_nonce = b"" + self.dps_whitelist = UPDATE_DPS_WHITELIST + self.dispatched_dps = {} # Store payload so we can trigger an event in HA. + self._last_command_sent = 1 # The time last command was sent + self._write_lock = asyncio.Lock() # To serialize writes + self.enable_debug(enable_debug) + + def set_version(self, protocol_version): + """Set the device version and eventually start available DPs detection.""" + self.version = protocol_version + self.version_bytes = str(protocol_version).encode("latin1") + self.version_header = self.version_bytes + PROTOCOL_3x_HEADER + if protocol_version == 3.2: # 3.2 behaves like 3.3 with type_0d + # self.version = 3.3 + self.dev_type = "type_0d" + elif protocol_version == 3.4: + self.dev_type = "v3.4" + elif protocol_version == 3.5: + self.dev_type = "v3.5" + + def error_json(self, number=None, payload=None): + """Return error details in JSON.""" + try: + spayload = json.dumps(payload) + # spayload = payload.replace('\"','').replace('\'','') + except Exception: # pylint: disable=broad-except + spayload = '""' + + vals = (error_codes[number], str(number), spayload) + self.debug("ERROR %s - %s - payload: %s", *vals) + + return json.loads('{ "Error":"%s", "Err":"%s", "Payload":%s }' % vals) + + def _msg_subdevs_query(self, decoded_message): + """ + Handle the sub-devices query message. + Message: {"online": [cids, ...], "offline": [cids, ...], "nearby": [cids, ...]} + """ + + async def _action(): + try: + await asyncio.sleep(2) + + self.debug(f"Sub-Devices States Update: {self.sub_devices_states}") + on_devs = self.sub_devices_states.get("online") + off_devs = self.sub_devices_states.get("offline") + listener = self.listener and self.listener() + if listener is None or (on_devs is None and off_devs is None): + return + for cid, device in listener.sub_devices.items(): + if cid in on_devs: + device.subdevice_state_updated(SubdeviceState.ONLINE) + elif cid in off_devs: + device.subdevice_state_updated(SubdeviceState.OFFLINE) + else: + device.subdevice_state_updated(SubdeviceState.ABSENT) + except asyncio.CancelledError: + pass + + if (data := decoded_message.get("data")) and isinstance(data, dict): + devs_states = self.sub_devices_states + updated_states = {} + + cached_on_devs = devs_states.get("online", []) + cached_off_devs = devs_states.get("offline", []) + + on_devs, off_devs = data.get("online", []), data.get("offline", []) + + updated_states["online"] = list(set(cached_on_devs + on_devs)) + updated_states["offline"] = list(set(cached_off_devs + off_devs)) + + if self._sub_devs_query_task is not None: + self._sub_devs_query_task.cancel() + + self.sub_devices_states = updated_states + self._sub_devs_query_task = self.loop.create_task(_action()) + + def _setup_dispatcher(self) -> MessageDispatcher: + def _status_update(msg, ack=False): + if msg.seqno > 0: + if msg.seqno >= self.seqno: + self.seqno = msg.seqno + 1 + if ack: + self.debug( + f"Got update ack message update seqno only. msg.seqno={msg.seqno} self.seqno={self.seqno}" + ) + return + + decoded_message: dict = self._decode_payload(msg.payload) + cid = None + + # Sub-devices query message. + if msg.cmd == LAN_EXT_STREAM: + return self._msg_subdevs_query(decoded_message) + + if "dps" not in decoded_message: + return + + if dps_payload := decoded_message.get("dps"): + if cid := decoded_message.get("cid"): + self.dps_cache.setdefault(cid, {}) + self.dps_cache[cid].update(dps_payload) + else: + self.dps_cache.setdefault("parent", {}) + self.dps_cache["parent"].update(dps_payload) + + listener = self.listener and self.listener() + if listener is not None: + if cid: + # Don't pass sub-device's payload to the (fake)gateway! + if not (listener := listener.sub_devices.get(cid, None)): + return self.debug( + f'Payload for missing sub-device discarded: "{decoded_message}"' + ) + status = self.dps_cache.get(cid, {}) + else: + status = self.dps_cache.get("parent", {}) + + listener.status_updated(status) + + return MessageDispatcher(self.id, _status_update, self.version, self.local_key) + + def connection_made(self, transport): + """Did connect to the device.""" + self.transport = transport + self.on_connected.set_result(True) + + def keep_alive(self, is_gateway: bool = False): + """ + Start the heartbeat transmissions with the device. + is_gateway: will use subdevices_query as heartbeat. + """ + + async def keep_alive_loop(action): + """Continuously send heart beat updates.""" + self.debug("Started keep alive loop.") + fail_attempt = 0 + while True: + try: + await asyncio.sleep(HEARTBEAT_INTERVAL) + await action() + fail_attempt = 0 + except asyncio.CancelledError: + self.debug("Stopped heartbeat loop") + break + except asyncio.TimeoutError: + fail_attempt += 1 + if fail_attempt >= 2: + 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 + + self.heartbeater = None + if self.transport is not None: + self.clean_up_session() + + self.debug("Stopped heartbeat loop") + + if self.heartbeater is None: + # Prevent duplicates heartbeat task + self.heartbeater = self.loop.create_task( + keep_alive_loop( + # Ver. 3.3 gateways don't respond to subdevice query + self.subdevices_query + if is_gateway and self.version >= 3.4 + else self.heartbeat + ) + ) + + def data_received(self, data): + """Received data from device.""" + # self.debug("received data=%r", binascii.hexlify(data), force=True) + self.dispatcher.add_data(data) + + def connection_lost(self, exc): + """Disconnected from device.""" + self.debug("Connection lost: %s", exc, force=True) + + listener = self.listener and self.listener() + self.clean_up_session() + + try: + if listener is not None: + listener.disconnected(exc or "Connection lost") + except Exception: # pylint: disable=broad-except + self.exception("Failed to call disconnected callback") + + async def transport_write(self, data): + """Write data on transport, ensure that no massive requests happen all at once.""" + async with self._write_lock: + while self.last_command_sent < 0.050: + await asyncio.sleep(0.010) + + self._last_command_sent = time.time() + self.transport.write(data) + + async def close(self): + """Close connection and abort all outstanding listeners.""" + self.debug("Closing connection") + self.clean_up_session() + + if self.heartbeater: + await self.heartbeater + + if self._sub_devs_query_task: + await self._sub_devs_query_task + + def clean_up_session(self): + """Clean up session.""" + self.debug(f"Cleaning up session.") + self.real_local_key = self.local_key + + if self.heartbeater: + self.heartbeater.cancel() + + if self._sub_devs_query_task: + self._sub_devs_query_task.cancel() + + if self.is_connected: + self.transport.close() + + if self.dispatcher: + self.dispatcher.abort() + + async def exchange_quick(self, payload, recv_retries): + """Similar to exchange() but never retries sending and does not decode the response.""" + if not self.is_connected: + self.debug("send quick failed, could not get socket: %s", payload) + return None + enc_payload = ( + self._encode_message(payload) + if isinstance(payload, MessagePayload) + else payload + ) + # self.debug("Quick-dispatching message %s, seqno %s", binascii.hexlify(enc_payload), self.seqno) + + try: + await self.transport_write(enc_payload) + except Exception: # pylint: disable=broad-except + return self.clean_up_session() + + while recv_retries: + try: + seqno = MessageDispatcher.SESS_KEY_SEQNO + msg = await self.dispatcher.wait_for(seqno, payload.cmd) + # for 3.4 devices, we get the starting seqno with the SESS_KEY_NEG_RESP message + self.seqno = msg.seqno + except Exception: # pylint: disable=broad-except + msg = None + if msg and len(msg.payload) != 0: + return msg + recv_retries -= 1 + if recv_retries == 0: + self.debug( + "received null payload (%r) but out of recv retries, giving up", msg + ) + else: + self.debug( + "received null payload (%r), fetch new one - %s retries remaining", + msg, + recv_retries, + ) + return None + + async def exchange(self, command, dps=None, nodeID=None, payload=None): + """Send and receive a message, returning response from device.""" + if not self.is_connected: + return None + + if self.version >= 3.4 and self.real_local_key == self.local_key: + self.debug("3.4 or 3.5 device: negotiating a new session key") + if not await self._negotiate_session_key(): + return self.clean_up_session() + + self.debug( + "Sending command %s (device type: %s) DPS: %s", command, self.dev_type, dps + ) + payload = payload or self._generate_payload(command, dps, nodeId=nodeID) + real_cmd = payload.cmd + dev_type = self.dev_type + + # Wait for special sequence number + seqno = self.seqno + + if payload.cmd == HEART_BEAT: + seqno = MessageDispatcher.HEARTBEAT_SEQNO + elif payload.cmd == UPDATEDPS: + seqno = MessageDispatcher.RESET_SEQNO + elif payload.cmd == LAN_EXT_STREAM: + seqno = MessageDispatcher.SUB_DEVICE_QUERY_SEQNO + + enc_payload = self._encode_message(payload) + + try: + await self.transport_write(enc_payload) + except Exception: # pylint: disable=broad-except + return self.clean_up_session() + msg = await self.dispatcher.wait_for(seqno, payload.cmd) + if msg is None: + self.debug("Wait was aborted for seqno %d", seqno) + return None + + # TODO: Verify stuff, e.g. CRC sequence number? + if real_cmd in [HEART_BEAT, CONTROL, CONTROL_NEW] and len(msg.payload) == 0: + # device may send messages with empty payload in response + # to a HEART_BEAT or CONTROL or CONTROL_NEW command: consider them an ACK + self.debug(f"ACK received for command {real_cmd}: ignoring: {msg.seqno}") + return None + 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, nodeID=nodeID) + return payload + + async def status(self, cid=None): + """Return device status.""" + status: dict = await self.exchange(command=DP_QUERY, nodeID=cid) + + self.dps_cache.setdefault("parent", {}) + if status and "dps" in status: + if "cid" in status: + self.dps_cache.update({status["cid"]: status["dps"]}) + else: + self.dps_cache["parent"].update(status["dps"]) + + return self.dps_cache.get(cid or "parent", {}) + + async def heartbeat(self): + """Send a heartbeat message.""" + return await self.exchange(HEART_BEAT) + + async def reset(self, dpIds=None, cid=None): + """Send a reset message (3.3 only).""" + if self.version == 3.3: + self.dev_type = "type_0a" + self.debug("reset switching to dev_type %s", self.dev_type) + return await self.exchange(UPDATEDPS, dpIds, nodeID=cid) + + return True + + def set_updatedps_list(self, update_list): + """Set the DPS to be requested with the update command.""" + self.dps_whitelist = update_list + + async def update_dps(self, dps=None, cid=None): + """ + Request device to update index. + + Args: + dps([int]): list of dps to update, default=detected&whitelisted + """ + if self.version in UPDATE_DPS_LIST and self.is_connected: + if dps is None: + if not self.dps_cache: + await self.detect_available_dps(cid=cid) + if self.dps_cache: + if cid and cid in self.dps_cache: + dps = [int(dp) for dp in self.dps_cache[cid]] + else: + dps = [int(dp) for dp in self.dps_cache["parent"]] + # filter non whitelisted dps + dps = list(set(dps).intersection(set(self.dps_whitelist))) + payload = self._generate_payload(UPDATEDPS, dps, nodeId=cid) + enc_payload = self._encode_message(payload) + await self.transport_write(enc_payload) + return True + + async def set_dp(self, value, dp_index, cid=None): + """ + 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(CONTROL, {str(dp_index): value}, nodeID=cid) + + async def set_dps(self, dps, cid=None): + """Set values for a set of datapoints.""" + return await self.exchange(CONTROL, dps, nodeID=cid) + + async def subdevices_query(self): + """Request a list of sub-devices and their status.""" + # Return payload: {"online": [cid1, ...], "offline": [cid2, ...]} + # "nearby": [cids, ...] can come in payload. + self.sub_devices_states = {"online": [], "offline": []} + payload = self._generate_payload( + LAN_EXT_STREAM, rawData={"cids": []}, reqType="subdev_online_stat_query" + ) + + return await self.exchange(command=LAN_EXT_STREAM, payload=payload) + + async def detect_available_dps(self, cid=None): + """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) + + 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)) + data = await self.status(cid=cid) + if cid and cid in data: + self.dps_cache.update({cid: data[cid]}) + elif not cid and "parent" in data: + self.dps_cache.update({"parent": data["parent"]}) + + if self.dev_type == "type_0a" and not cid: + return self.dps_cache.get("parent", {}) + + return self.dps_cache.get(cid or "parent", {}) + + 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): + cipher = AESCipher(self.local_key) + + if self.version == 3.4: + # 3.4 devices encrypt the version header in addition to the payload + try: + # self.debug("decrypting=%r", payload) + payload = cipher.decrypt(payload, False, decode_text=False) + except Exception as ex: + self.debug( + "incomplete payload=%r with len:%d (%s)", payload, len(payload), ex + ) + return self.error_json(ERR_PAYLOAD) + + # self.debug("decrypted 3.x payload=%r", payload) + + if payload.startswith(PROTOCOL_VERSION_BYTES_31): + # Received an encrypted payload + # Remove version header + payload = payload[len(PROTOCOL_VERSION_BYTES_31) :] + # Decrypt payload + # Remove 16-bytes of MD5 hexdigest of payload + payload = cipher.decrypt(payload[16:]) + elif self.version >= 3.2: # 3.2 or 3.3 or 3.4 + # Trim header for non-default device type + if payload.startswith(self.version_bytes): + payload = payload[len(self.version_header) :] + # self.debug("removing 3.x=%r", payload) + elif self.dev_type == "type_0d" and (len(payload) & 0x0F) != 0: + payload = payload[len(self.version_header) :] + # self.debug("removing type_0d 3.x header=%r", payload) + + if self.version < 3.4: + try: + # self.debug("decrypting=%r", payload) + payload = cipher.decrypt(payload, False) + except Exception as ex: + self.debug( + "incomplete payload=%r with len:%d (%s)", + payload, + len(payload), + ex, + ) + return self.error_json(ERR_PAYLOAD) + + # self.debug("decrypted 3.x payload=%r", payload) + # Try to detect if type_0d found + + if not isinstance(payload, str): + try: + payload = payload.decode() + except Exception as ex: + self.debug("payload was not string type and decoding failed") + return self.error_json(ERR_JSON, payload) + + if "data unvalid" in payload: + if self.version <= 3.3: + self.dev_type = "type_0d" + self.debug( + "'data unvalid' error detected: switching to dev_type %r", + self.dev_type, + ) + return None + elif not payload.startswith(b"{"): + self.debug("Unexpected payload=%r", payload) + return self.error_json(ERR_PAYLOAD, payload) + + if not isinstance(payload, str): + payload = payload.decode() + self.debug("Deciphered data = %r", payload) + try: + json_payload = json.loads(payload) + except Exception as ex: + json_payload = self.error_json(ERR_JSON, payload) + + if "devid not" in payload: # DeviceID Not found. + raise ValueError(f"DeviceID [{self.id}] Not found") + # else: + # raise DecodeError( + # f"[{self.id}]: could not decrypt data: wrong local_key? (exception: {ex}, payload: {payload})" + # ) + # json_payload = self.error_json(ERR_JSON, payload) + + # v3.4 stuffs it into {"data":{"dps":{"1":true}}, ...} + if ( + "dps" not in json_payload + and "data" in json_payload + and "dps" in json_payload["data"] + ): + json_payload["dps"] = json_payload["data"]["dps"] + + if "cid" in json_payload["data"]: + json_payload["cid"] = json_payload["data"]["cid"] + + # We will store the payload to trigger an event in HA. + if "dps" in json_payload: + self.dispatched_dps = json_payload["dps"] + return json_payload + + async def _negotiate_session_key(self): + self.remote_nonce = b"" + self.local_key = self.real_local_key + + rkey = await self.exchange_quick( + MessagePayload(SESS_KEY_NEG_START, self.local_nonce), 2 + ) + if not rkey or not isinstance(rkey, TuyaMessage) or len(rkey.payload) < 48: + # error + self.debug("session key negotiation failed on step 1") + return False + + if rkey.cmd != SESS_KEY_NEG_RESP: + self.debug( + "session key negotiation step 2 returned wrong command: %d", rkey.cmd + ) + return False + + payload = rkey.payload + if self.version == 3.4: + try: + # self.debug("decrypting %r using %r", payload, self.real_local_key) + cipher = AESCipher(self.real_local_key) + payload = cipher.decrypt(payload, False, decode_text=False) + except Exception as ex: + self.debug( + "session key step 2 decrypt failed, payload=%r with len:%d (%s)", + payload, + len(payload), + ex, + ) + return False + + self.debug("decrypted session key negotiation step 2: payload=%r", payload) + + if len(payload) < 48: + self.debug("session key negotiation step 2 failed, too short response") + return False + + self.remote_nonce = payload[:16] + hmac_check = hmac.new(self.local_key, self.local_nonce, sha256).digest() + + if hmac_check != payload[16:48]: + self.debug( + "session key negotiation step 2 failed HMAC check! wanted=%r but got=%r", + binascii.hexlify(hmac_check), + binascii.hexlify(payload[16:48]), + ) + + # self.debug("session local nonce: %r remote nonce: %r", self.local_nonce, self.remote_nonce) + rkey_hmac = hmac.new(self.local_key, self.remote_nonce, sha256).digest() + await self.exchange_quick(MessagePayload(SESS_KEY_NEG_FINISH, rkey_hmac), None) + + self.local_key = bytes( + [a ^ b for (a, b) in zip(self.local_nonce, self.remote_nonce)] + ) + # self.debug("Session nonce XOR'd: %r" % self.local_key) + + cipher = AESCipher(self.real_local_key) + if self.version == 3.4: + self.local_key = self.dispatcher.local_key = cipher.encrypt( + self.local_key, False, pad=False + ) + else: + iv = self.local_nonce[:12] + self.debug("Session IV: %r", iv) + self.local_key = self.dispatcher.local_key = cipher.encrypt( + self.local_key, use_base64=False, pad=False, iv=iv + )[12:28] + + self.debug("Session key negotiate success! session key: %r", self.local_key) + return True + + # adds protocol header (if needed) and encrypts + def _encode_message(self, msg): + hmac_key = None + iv = None + payload = msg.payload + self.cipher = AESCipher(self.local_key) + + if self.version >= 3.4: + hmac_key = self.local_key + if msg.cmd not in NO_PROTOCOL_HEADER_CMDS: + # add the 3.x header + payload = self.version_header + payload + self.debug("final payload for cmd %r: %r", msg.cmd, payload) + + if self.version >= 3.5: + iv = True + # seqno cmd retcode payload crc crc_good, prefix, iv + msg = TuyaMessage( + self.seqno, msg.cmd, None, payload, 0, True, PREFIX_6699_VALUE, True + ) + self.seqno += 1 # increase message sequence number + data = pack_message(msg, hmac_key=self.local_key) + self.debug("payload encrypted=%r", binascii.hexlify(data)) + return data + + payload = self.cipher.encrypt(payload, False) + elif self.version >= 3.2: + # expect to connect and then disconnect to set new + payload = self.cipher.encrypt(payload, False) + if msg.cmd not in NO_PROTOCOL_HEADER_CMDS: + # add the 3.x header + payload = self.version_header + payload + elif msg.cmd == CONTROL: + # need to encrypt + payload = self.cipher.encrypt(payload) + preMd5String = ( + b"data=" + + payload + + b"||lpv=" + + PROTOCOL_VERSION_BYTES_31 + + b"||" + + self.local_key + ) + m = md5() + m.update(preMd5String) + hexdigest = m.hexdigest() + # some tuya libraries strip 8: to :24 + payload = ( + PROTOCOL_VERSION_BYTES_31 + + hexdigest[8:][:16].encode("latin1") + + payload + ) + + self.cipher = None + msg = TuyaMessage( + self.seqno, msg.cmd, 0, payload, 0, True, PREFIX_55AA_VALUE, False + ) + self.seqno += 1 # increase message sequence number + buffer = pack_message(msg, hmac_key=hmac_key) + # self.debug("payload encrypted with key %r => %r", self.local_key, binascii.hexlify(buffer)) + return buffer + + def _generate_payload( + self, + command, + data=None, + gwId=None, + devId=None, + uid=None, + nodeId=None, + rawData=None, + reqType=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 + gwId(str, optional): Will be used for gwId + devId(str, optional): Will be used for devId + uid(str, optional): Will be used for uid + """ + json_data = command_override = None + + # Create a deep copy of payload_dict. otherwise, the original references will be overwritten + def deepcopy_dict(_dict: dict): + output = _dict.copy() + for key, value in output.items(): + output[key] = deepcopy_dict(value) if isinstance(value, dict) else value + return output + + payloads = deepcopy_dict(payload_dict) + + if command in payloads[self.dev_type]: + if "command" in payloads[self.dev_type][command]: + json_data = payloads[self.dev_type][command]["command"].copy() + if "command_override" in payloads[self.dev_type][command]: + command_override = payloads[self.dev_type][command]["command_override"] + + if self.dev_type != "type_0a": + if ( + json_data is None + and command in payloads["type_0a"] + and "command" in payloads["type_0a"][command] + ): + json_data = payloads["type_0a"][command]["command"].copy() + if ( + command_override is None + and command in payloads["type_0a"] + and "command_override" in payloads["type_0a"][command] + ): + command_override = payloads["type_0a"][command]["command_override"] + + if command_override is None: + command_override = command + if json_data is None: + # I have yet to see a device complain about included but unneeded attribs, but they *will* + # complain about missing attribs, so just include them all unless otherwise specified + json_data = {"gwId": "", "devId": "", "uid": "", "t": "", "cid": ""} + + if "gwId" in json_data: + if gwId is not None: + json_data["gwId"] = gwId + else: + json_data["gwId"] = self.id + if "devId" in json_data: + if devId is not None: + json_data["devId"] = devId + else: + json_data["devId"] = self.id + if "uid" in json_data: + if uid is not None: + json_data["uid"] = uid + else: + json_data["uid"] = self.id + if "cid" in json_data: + if cid := nodeId: + json_data["cid"] = cid + # for <= 3.3 we don't need `gwID`, `devID` and `uid` in payload. + if command in (CONTROL, DP_QUERY): + for k in ("gwId", "devId", "uid"): + if k in json_data: + json_data.pop(k) + else: + del json_data["cid"] + if "data" in json_data and "cid" in json_data["data"]: + # "cid" is inside "data" For 3.4 and 3.5 versions. + if cid := nodeId: + json_data["data"]["cid"] = cid + else: + del json_data["data"]["cid"] + if "t" in json_data: + if json_data["t"] == "int": + json_data["t"] = int(time.time()) + else: + json_data["t"] = str(int(time.time())) + if rawData is not None and "data" in json_data: + json_data["data"] = rawData + elif data is not None: + if "dpId" in json_data: + json_data["dpId"] = data + elif "data" in json_data: + json_data["data"]["dps"] = data # We don't want to remove CID + else: + json_data["dps"] = data + elif self.dev_type == "type_0d" and command == DP_QUERY: + json_data["dps"] = self.dps_to_request + if reqType and "reqType" in json_data: + json_data["reqType"] = reqType + + if json_data == "": + payload = "" + else: + payload = json.dumps(json_data) + # if spaces are not removed device does not respond! + payload = payload.replace(" ", "").encode("utf-8") + self.debug("Sending payload: %s", payload) + + return MessagePayload(command_override, payload) + + def enable_debug(self, enable=False, friendly_name=None): + """Enable the debug logs for the device.""" + self.set_logger(_LOGGER, self.id, enable, friendly_name) + self.dispatcher.set_logger(_LOGGER, self.id, enable, friendly_name) + + @property + def is_connected(self): + return self.transport and not self.transport.is_closing() + + @property + def last_command_sent(self): + """Return last command sent by seconds""" + return time.time() - self._last_command_sent + + def __repr__(self): + """Return internal string representation of object.""" + return self.id + + +async def connect( + address, + device_id, + local_key, + protocol_version, + enable_debug, + listener=None, + port=6668, + timeout=5, +): + """Connect to a device.""" + loop = asyncio.get_running_loop() + on_connected = loop.create_future() + try: + _, protocol = await asyncio.wait_for( + loop.create_connection( + lambda: TuyaProtocol( + device_id, + local_key, + protocol_version, + enable_debug, + on_connected, + listener or EmptyListener(), + ), + address, + port, + ), + timeout=3, + ) + # Assuming the connect timed out then then the host isn't reachable. + except (OSError, TimeoutError) as ex: + if ex.errno == errno.EHOSTUNREACH or isinstance(ex, TimeoutError): + raise OSError( + errno.EHOSTUNREACH, + os.strerror(errno.EHOSTUNREACH) + f" ('{address}', '{port}')", + ) + + raise ex + except (Exception, asyncio.CancelledError) as ex: + raise ex + except: + raise Exception(f"The host refused to connect") + + await asyncio.wait_for(on_connected, timeout=timeout) + return protocol diff --git a/custom_components/localtuya/core/pytuya/__pycache__/__init__.cpython-313.pyc b/custom_components/localtuya/core/pytuya/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..3ebb4728 Binary files /dev/null and b/custom_components/localtuya/core/pytuya/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/localtuya/cover.py b/custom_components/localtuya/cover.py index 700dc3f1..c21076f0 100644 --- a/custom_components/localtuya/cover.py +++ b/custom_components/localtuya/cover.py @@ -1,4 +1,5 @@ """Platform to locally control Tuya-based cover devices.""" + import asyncio import logging import time @@ -8,10 +9,13 @@ from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN, - CoverEntity, CoverEntityFeature, + CoverEntityFeature, + CoverEntity, + DEVICE_CLASSES_SCHEMA, ) - -from .common import LocalTuyaEntity, async_setup_entry +from homeassistant.const import CONF_DEVICE_CLASS +from .config_flow import col_to_select +from .entity import LocalTuyaEntity, async_setup_entry from .const import ( CONF_COMMANDS_SET, CONF_CURRENT_POSITION_DP, @@ -19,49 +23,74 @@ CONF_POSITIONING_MODE, CONF_SET_POSITION_DP, CONF_SPAN_TIME, + CONF_STOP_SWITCH_DP, ) + +# cover states. +STATE_OPENING = "opening" +STATE_CLOSING = "closing" +STATE_STOPPED = "stopped" +STATE_SET_CMD = "moving" +STATE_SET_OPENING = "set_opeing" +STATE_SET_CLOSING = "set_closing" + _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_COMMANDS = { + "Open, Close and Stop": "open_close_stop", + "Open, Close and Continue": "open_close_continue", + "ON, OFF and Stop": "on_off_stop", + "fz, zz and Stop": "fz_zz_stop", + "zz, fz and Stop": "zz_fz_stop", + "1, 2 and 3": "1_2_3", + "0, 1 and 2": "0_1_2", +} + +MODE_NONE = "none" +MODE_SET_POSITION = "position" +MODE_TIME_BASED = "timed" +COVER_MODES = { + "Neither": MODE_NONE, + "Set Position": MODE_SET_POSITION, + "Time Based": MODE_TIME_BASED, +} + COVER_TIMEOUT_TOLERANCE = 3.0 -DEFAULT_COMMANDS_SET = COVER_ONOFF_CMDS -DEFAULT_POSITIONING_MODE = COVER_MODE_NONE +DEF_CMD_SET = list(COVER_COMMANDS.values())[0] +DEF_POS_MODE = list(COVER_MODES.values())[0] 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_COMMANDS_SET, default=DEF_CMD_SET): col_to_select( + COVER_COMMANDS ), - vol.Optional(CONF_POSITIONING_MODE, default=DEFAULT_POSITIONING_MODE): vol.In( - [COVER_MODE_NONE, COVER_MODE_POSITION, COVER_MODE_TIMED] + vol.Optional(CONF_POSITIONING_MODE, default=DEF_POS_MODE): col_to_select( + COVER_MODES ), - vol.Optional(CONF_CURRENT_POSITION_DP): vol.In(dps), - vol.Optional(CONF_SET_POSITION_DP): vol.In(dps), + vol.Optional(CONF_CURRENT_POSITION_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_SET_POSITION_DP): col_to_select(dps, is_dps=True), 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) ), + vol.Optional(CONF_STOP_SWITCH_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, } -class LocaltuyaCover(LocalTuyaEntity, CoverEntity): +class LocalTuyaCover(LocalTuyaEntity, CoverEntity): """Tuya cover device.""" def __init__(self, device, config_entry, switchid, **kwargs): - """Initialize a new LocaltuyaCover.""" + """Initialize a new LocalTuyaCover.""" super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) - commands_set = DEFAULT_COMMANDS_SET + commands_set = DEF_CMD_SET if self.has_config(CONF_COMMANDS_SET): commands_set = self._config[CONF_COMMANDS_SET] self._open_cmd = commands_set.split("_")[0] @@ -71,51 +100,77 @@ def __init__(self, device, config_entry, switchid, **kwargs): self._state = self._stop_cmd self._previous_state = self._state self._current_cover_position = 0 - _LOGGER.debug("Initialized cover [%s]", self.name) + self._current_state_action = STATE_STOPPED # Default. + self._set_new_position = int | None + self._stop_switch = self._config.get(CONF_STOP_SWITCH_DP, None) + self._position_inverted = self._config.get(CONF_POSITION_INVERTED) + self._current_task = None @property def supported_features(self): """Flag supported features.""" - supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP - if self._config[CONF_POSITIONING_MODE] != COVER_MODE_NONE: + supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + if self._config[CONF_POSITIONING_MODE] != MODE_NONE: supported_features = supported_features | CoverEntityFeature.SET_POSITION return supported_features + @property + def _current_state(self) -> str: + """Return the current state of the cover.""" + state = self._current_state_action + curr_pos = self.current_cover_position + # Reset STATE when cover is fully closed or fully opened. + if (state == STATE_CLOSING and curr_pos == 0) or ( + state == STATE_OPENING and curr_pos == 100 + ): + self._current_state_action = STATE_STOPPED + # in case cover moving by set position cmd. + if ( + self._current_state_action == STATE_SET_CLOSING + or self._current_state_action == STATE_SET_OPENING + ): + set_pos = self._set_new_position + # Reset state whenn cover reached the position. + if curr_pos - set_pos < 5 and curr_pos - set_pos >= -5: + self._current_state_action = STATE_STOPPED + return self._current_state_action + @property def current_cover_position(self): """Return current cover position in percent.""" - if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE: + if self._config[CONF_POSITIONING_MODE] == 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 + state = self._current_state + return state == STATE_SET_OPENING or state == STATE_OPENING @property def is_closing(self): """Return if cover is closing.""" - state = self._state - return state == self._close_cmd + state = self._current_state + return state == STATE_SET_CLOSING or state == STATE_CLOSING @property def is_closed(self): """Return if the cover is closed or not.""" - if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE: - return False - - if self._current_cover_position == 0: - return True - if self._current_cover_position == 100: - return False - return False + if self._config[CONF_POSITIONING_MODE] == MODE_NONE: + return None + return self.current_cover_position == 0 and self._current_state == STATE_STOPPED async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" + # Update device values IF the device is moving at the moment. + if self._current_state != STATE_STOPPED: + await self.async_stop_cover() + self.debug("Setting cover position: %r", kwargs[ATTR_POSITION]) - if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: + if self._config[CONF_POSITIONING_MODE] == MODE_TIME_BASED: newpos = float(kwargs[ATTR_POSITION]) currpos = self.current_cover_position @@ -123,62 +178,85 @@ async def async_set_cover_position(self, **kwargs): 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() + await self.async_open_cover(delay=mydelay) + self.update_state(STATE_OPENING) 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)) + await self.async_close_cover(delay=mydelay) + self.update_state(STATE_CLOSING) self.debug("Done") - elif self._config[CONF_POSITIONING_MODE] == COVER_MODE_POSITION: + elif self._config[CONF_POSITIONING_MODE] == MODE_SET_POSITION: converted_position = int(kwargs[ATTR_POSITION]) - if self._config[CONF_POSITION_INVERTED]: + if self._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] ) + # Give it a moment, to make sure hass updated current pos. + await asyncio.sleep(0.1) + self.update_state(STATE_SET_CMD, int(kwargs[ATTR_POSITION])) 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() + try: + await asyncio.sleep(delay_sec) + self._current_task = None + await self.async_stop_cover() + except asyncio.CancelledError: + self._current_task = None 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: + if self._config[CONF_POSITIONING_MODE] == MODE_TIME_BASED: + if self._current_task is not None: + self._current_task.cancel() # for timed positioning, stop the cover after a full opening timespan # instead of waiting the internal timeout - self.hass.async_create_task( + self._current_task = self.hass.async_create_task( self.async_stop_after_timeout( - self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE + kwargs.get( + "delay", self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE + ) ) ) + self.update_state(STATE_OPENING) 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: + if self._config[CONF_POSITIONING_MODE] == MODE_TIME_BASED: + if self._current_task is not None: + self._current_task.cancel() # for timed positioning, stop the cover after a full opening timespan # instead of waiting the internal timeout - self.hass.async_create_task( + self._current_task = self.hass.async_create_task( self.async_stop_after_timeout( - self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE + kwargs.get( + "delay", self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE + ) ) ) + self.update_state(STATE_CLOSING) async def async_stop_cover(self, **kwargs): """Stop the cover.""" + if self._current_task is not None: + self._current_task.cancel() self.debug("Launching command %s to cover ", self._stop_cmd) - await self._device.set_dp(self._stop_cmd, self._dp_id) + command = {self._dp_id: self._stop_cmd} + if self._stop_switch is not None: + command[self._stop_switch] = True + await self._device.set_dps(command) + self.update_state(STATE_STOPPED) def status_restored(self, stored_state): """Restore the last stored cover status.""" - if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: + if self._config[CONF_POSITIONING_MODE] == MODE_TIME_BASED: stored_pos = stored_state.attributes.get("current_position") if stored_pos is not None: self._current_cover_position = stored_pos @@ -187,20 +265,20 @@ def status_restored(self, stored_state): 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._state = self.dp_value(self._dp_id) + if self._state and 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]: + curr_pos = self.dp_value(CONF_CURRENT_POSITION_DP) + if self._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 + self._config[CONF_POSITIONING_MODE] == MODE_TIME_BASED and self._state != self._previous_state ): if self._previous_state != self._stop_cmd: @@ -229,5 +307,27 @@ def status_updated(self): if (self._state is not None) and (not self._device.is_connecting): self._last_state = self._state + def update_state(self, action, position=None): + """Update cover current states.""" + state = self._current_state_action + # using Commands. + if position is None: + self._current_state_action = action + # Set position cmd, check if target position weither close or open + if action == STATE_SET_CMD and position is not None: + curr_pos = self.current_cover_position + self._set_new_position = position + pos_diff = position - curr_pos + # Prevent stuck state when interrupted on middle of cmd + if state == STATE_STOPPED: + if pos_diff > 0: + self._current_state_action = STATE_SET_OPENING + elif pos_diff < 0: + self._current_state_action = STATE_SET_CLOSING + else: + self._current_state_action = STATE_STOPPED + # Write state data. + self.async_write_ha_state() + -async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaCover, flow_schema) +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaCover, flow_schema) diff --git a/custom_components/localtuya/diagnostics.py b/custom_components/localtuya/diagnostics.py index 9c84a931..4db57e8a 100644 --- a/custom_components/localtuya/diagnostics.py +++ b/custom_components/localtuya/diagnostics.py @@ -1,4 +1,5 @@ """Diagnostics support for LocalTuya.""" + from __future__ import annotations import copy @@ -10,7 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from .const import CONF_LOCAL_KEY, CONF_USER_ID, DATA_CLOUD, DOMAIN +from . import HassLocalTuyaData +from .const import CONF_LOCAL_KEY, CONF_USER_ID, DOMAIN CLOUD_DEVICES = "cloud_devices" DEVICE_CONFIG = "device_config" @@ -18,6 +20,8 @@ _LOGGER = logging.getLogger(__name__) +DATA_OBFUSCATE = {"ip": 1, "uid": 3, CONF_LOCAL_KEY: 3, "lat": 0, "lon": 0} + async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry @@ -25,20 +29,21 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" data = {} data = dict(entry.data) - tuya_api = hass.data[DOMAIN][DATA_CLOUD] + hass_localtuya: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id] + tuya_api = hass_localtuya.cloud_data # censoring private information on integration diagnostic data for field in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]: - data[field] = f"{data[field][0:3]}...{data[field][-3:]}" + data[field] = obfuscate(data[field]) data[CONF_DEVICES] = copy.deepcopy(entry.data[CONF_DEVICES]) for dev_id, dev in data[CONF_DEVICES].items(): local_key = dev[CONF_LOCAL_KEY] - local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}" + local_key_obfuscated = obfuscate(local_key) dev[CONF_LOCAL_KEY] = local_key_obfuscated - data[CLOUD_DEVICES] = tuya_api.device_list + data[CLOUD_DEVICES] = copy.deepcopy(tuya_api.device_list) for dev_id, dev in data[CLOUD_DEVICES].items(): - local_key = data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY] - local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}" - data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY] = local_key_obfuscated + for obf, obf_len in DATA_OBFUSCATE.items(): + if ob := data[CLOUD_DEVICES][dev_id].get(obf): + data[CLOUD_DEVICES][dev_id][obf] = obfuscate(ob, obf_len, obf_len) return data @@ -53,9 +58,13 @@ async def async_get_device_diagnostics( # local_key = data[DEVICE_CONFIG][CONF_LOCAL_KEY] # data[DEVICE_CONFIG][CONF_LOCAL_KEY] = f"{local_key[0:3]}...{local_key[-3:]}" - tuya_api = hass.data[DOMAIN][DATA_CLOUD] + hass_localtuya: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id] + tuya_api = hass_localtuya.cloud_data if dev_id in tuya_api.device_list: - data[DEVICE_CLOUD_INFO] = tuya_api.device_list[dev_id] + data[DEVICE_CLOUD_INFO] = copy.deepcopy(tuya_api.device_list[dev_id]) + for obf, obf_len in DATA_OBFUSCATE.items(): + if ob := data[DEVICE_CLOUD_INFO].get(obf): + data[DEVICE_CLOUD_INFO][obf] = obfuscate(ob, obf_len, obf_len) # NOT censoring private information on device diagnostic data # local_key = data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY] # local_key_obfuscated = "{local_key[0:3]}...{local_key[-3:]}" @@ -63,3 +72,11 @@ async def async_get_device_diagnostics( # data["log"] = hass.data[DOMAIN][CONF_DEVICES][dev_id].logger.retrieve_log() return data + + +def obfuscate(key, start_characters=3, end_characters=3) -> str: + """Return obfuscated text by removing characters between [start_characters and end_characters]""" + if start_characters <= 0 and end_characters <= 0: + return "" + + return f"{key[0:start_characters]}...{key[-end_characters:]}" diff --git a/custom_components/localtuya/discovery.py b/custom_components/localtuya/discovery.py index 0c93ab79..e4dc3399 100644 --- a/custom_components/localtuya/discovery.py +++ b/custom_components/localtuya/discovery.py @@ -1,33 +1,58 @@ """Discovery module for Tuya devices. -Entirely based on tuya-convert.py from tuya-convert: +based on tuya-convert.py from tuya-convert: + https://github.com/ct-Open-Source/tuya-convert/blob/master/scripts/tuya-discovery.py -https://github.com/ct-Open-Source/tuya-convert/blob/master/scripts/tuya-discovery.py +Maintained by @xZetsubou """ + +import os import asyncio import json import logging from hashlib import md5 +from socket import inet_aton from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from .entity import pytuya + _LOGGER = logging.getLogger(__name__) UDP_KEY = md5(b"yGAdlopoPVldABfn").digest() +PREFIX_55AA_BIN = b"\x00\x00U\xaa" +PREFIX_6699_BIN = b"\x00\x00\x66\x99" +UDP_COMMAND = b"\x00\x00\x00\x00" + DEFAULT_TIMEOUT = 6.0 -def decrypt_udp(message): - """Decrypt encrypted UDP broadcasts.""" - +def decrypt(msg, key): def _unpad(data): return data[: -ord(data[len(data) - 1 :])] - cipher = Cipher(algorithms.AES(UDP_KEY), modes.ECB(), default_backend()) + cipher = Cipher(algorithms.AES(key), modes.ECB(), default_backend()) decryptor = cipher.decryptor() - return _unpad(decryptor.update(message) + decryptor.finalize()).decode() + return _unpad(decryptor.update(msg) + decryptor.finalize()).decode() + + +def decrypt_udp(message): + """Decrypt encrypted UDP broadcasts.""" + if message[:4] == PREFIX_55AA_BIN: + payload = message[20:-8] + if message[8:12] == UDP_COMMAND: + return payload + return decrypt(payload, UDP_KEY) + if message[:4] == PREFIX_6699_BIN: + unpacked = pytuya.unpack_message(message, hmac_key=UDP_KEY, no_retcode=None) + payload = unpacked.payload.decode() + # app sometimes has extra bytes at the end + while payload[-1] == chr(0): + payload = payload[:-1] + return payload + return decrypt(message, UDP_KEY) class TuyaDiscovery(asyncio.DatagramProtocol): @@ -42,15 +67,18 @@ def __init__(self, callback=None): async def start(self): """Start discovery by listening to broadcasts.""" loop = asyncio.get_running_loop() + op_reuse_port = {"reuse_port": True} if os.name != "nt" else {} listener = loop.create_datagram_endpoint( - lambda: self, local_addr=("0.0.0.0", 6666), reuse_port=True + lambda: self, local_addr=("0.0.0.0", 6666), **op_reuse_port ) encrypted_listener = loop.create_datagram_endpoint( - lambda: self, local_addr=("0.0.0.0", 6667), reuse_port=True + lambda: self, local_addr=("0.0.0.0", 6667), **op_reuse_port ) - + # tuyaApp_encrypted_listener = loop.create_datagram_endpoint( + # lambda: self, local_addr=("0.0.0.0", 7000), **op_reuse_port + # ) self._listeners = await asyncio.gather(listener, encrypted_listener) - _LOGGER.debug("Listening to broadcasts on UDP port 6666 and 6667") + _LOGGER.debug("Listening to broadcasts on UDP port 6666, 6667") def close(self): """Stop discovery.""" @@ -60,21 +88,33 @@ def close(self): 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) + try: + data = decrypt_udp(data) + except Exception: # pylint: disable=broad-except + data = data.decode() + decoded = json.loads(data) + self.device_found(decoded) + except: + # _LOGGER.debug("Bordcast from app from ip: %s", addr[0]) + _LOGGER.debug("Failed to decode broadcast from %r: %r", addr[0], data) def device_found(self, device): """Discover a new device.""" - if device.get("gwId") not in self.devices: - self.devices[device.get("gwId")] = device - _LOGGER.debug("Discovered device: %s", device) + gwid, ip = device.get("gwId"), device.get("ip") + # If device found but the ip changed. + if gwid in self.devices and (self.devices[gwid].get("ip") != ip): + self.devices.pop(gwid) + + if gwid not in self.devices: + self.devices[gwid] = device + # Sort devices by ip. + sort_devices = sorted( + self.devices.items(), key=lambda i: inet_aton(i[1].get("ip", "0")) + ) + self.devices = dict(sort_devices) + _LOGGER.debug("Discovered device: %s", device) if self._callback: self._callback(device) diff --git a/custom_components/localtuya/entity.py b/custom_components/localtuya/entity.py new file mode 100644 index 00000000..b3bc9b74 --- /dev/null +++ b/custom_components/localtuya/entity.py @@ -0,0 +1,379 @@ +"""Code shared between all platforms.""" + +import logging +from typing import Any + +from homeassistant.core import HomeAssistant, State +from homeassistant.config_entries import ConfigEntry + +from homeassistant.const import ( + CONF_DEVICES, + CONF_DEVICE_CLASS, + CONF_ENTITIES, + CONF_ENTITY_CATEGORY, + CONF_FRIENDLY_NAME, + CONF_HOST, + CONF_ICON, + CONF_ID, + CONF_PLATFORM, + EntityCategory, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ATTR_VIA_DEVICE, +) +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) + +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .core import pytuya +from .coordinator import HassLocalTuyaData, TuyaDevice +from .const import ( + ATTR_STATE, + CONF_DEFAULT_VALUE, + CONF_ID, + CONF_NODE_ID, + CONF_PASSIVE_ENTITY, + CONF_RESTORE_ON_RECONNECT, + CONF_SCALING, + DOMAIN, + RESTORE_STATES, + DeviceConfig, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + domain, + entity_class, + flow_schema, + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +): + """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. + """ + entities = [] + hass_entry_data: HassLocalTuyaData = hass.data[DOMAIN][config_entry.entry_id] + + for dev_id in config_entry.data[CONF_DEVICES]: + dev_entry: dict = config_entry.data[CONF_DEVICES][dev_id] + + host = dev_entry.get(CONF_HOST) + node_id = dev_entry.get(CONF_NODE_ID) + device_key = f"{host}_{node_id}" if node_id else host + + if device_key not in hass_entry_data.devices: + continue + + entities_to_setup = [ + entity + for entity in dev_entry[CONF_ENTITIES] + if entity[CONF_PLATFORM] == domain + ] + + if entities_to_setup: + device: TuyaDevice = hass_entry_data.devices[device_key] + dps_config_fields = list(get_dps_for_platform(flow_schema)) + + for entity_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 entity_config: + device.dps_to_request[entity_config[dp_conf]] = None + + entities.append( + entity_class( + device, + dev_entry, + entity_config[CONF_ID], + ) + ) + # Once the entities have been created, add to the TuyaDevice instance + if entities: + device.add_entities(entities) + 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) -> dict: + """Return entity config for a given DPS id.""" + for entity in config_entry[CONF_ENTITIES]: + if entity[CONF_ID] == dp_id: + return entity + raise Exception(f"missing entity config for id {dp_id}") + + +class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): + """Representation of a Tuya entity.""" + + _attr_device_class = None + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, device: TuyaDevice, device_config: dict, dp_id: str, logger, **kwargs + ): + """Initialize the Tuya entity.""" + super().__init__() + self._device = device + self._device_config = DeviceConfig(device_config) + self._config = get_entity_config(device_config, dp_id) + self._dp_id = dp_id + self._status = {} + self._state = None + self._last_state = None + self._stored_states: State | None = None + self._hass = device._hass + + # Default value is available to be provided by Platform entities if required + self._default_value = self._config.get(CONF_DEFAULT_VALUE) + + """ Restore on connect setting is available to be provided by Platform entities + if required""" + dev = self._device_config + self.set_logger(logger, dev.id, dev.enable_debug, dev.name) + self.debug(f"Initialized {self._config.get(CONF_PLATFORM)} [{self.name}]") + + async def async_added_to_hass(self): + """Subscribe localtuya events.""" + await super().async_added_to_hass() + + self.debug(f"Adding {self.entity_id} with configuration: {self._config}") + + stored_data = await self.async_get_last_state() + if stored_data: + self._stored_states = stored_data + self.status_restored(stored_data) + + def _update_handler(new_status: dict | None): + """Update entity state when status was updated.""" + status = self._status.clear() if new_status is None else new_status.copy() + + if status == RESTORE_STATES and stored_data and not self._status: + if stored_data.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN): + self.debug(f"{self.name}: Restore state: {stored_data.state}") + status[self._dp_id] = stored_data.state + + if self._status != status: + if status: + # Pop the special DPs + status.pop("0", None) + self._status.update(status) + self.status_updated() + + # Update HA + self.schedule_update_ha_state() + + signal = f"localtuya_{self._device_config.id}" + + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, _update_handler) + ) + + signal = f"localtuya_entity_{self._device_config.id}" + async_dispatcher_send(self.hass, signal, self.entity_id) + + @property + def extra_state_attributes(self): + """Return entity specific state attributes to be saved. + + These attributes are then available for restore when the + entity is restored at startup. + """ + attributes = {} + if self._state is not None: + attributes[ATTR_STATE] = self._state + elif self._last_state is not None: + attributes[ATTR_STATE] = self._last_state + + self.debug(f"Entity {self.name} - Additional attributes: {attributes}") + return attributes + + @property + def device_info(self) -> DeviceInfo: + """Return device information for the device registry.""" + model = self._device_config.model + device_info = DeviceInfo( + # Serial numbers are unique identifiers within a specific domain + identifiers={(DOMAIN, f"local_{self._device_config.id}")}, + name=self._device_config.name, + manufacturer="Tuya", + model=f"{model} ({self._device_config.id})", + sw_version=self._device_config.protocol_version, + ) + if self._device.is_subdevice: + device_info[ATTR_VIA_DEVICE] = (DOMAIN, f"local_{self._device.gateway.id}") + return device_info + + @property + def name(self) -> str: + """Get name of Tuya entity.""" + return self._config.get(CONF_FRIENDLY_NAME) + + @property + def icon(self) -> str | None: + """Icon of the entity.""" + return self._config.get(CONF_ICON, None) + + @property + def unique_id(self) -> str: + """Return unique device identifier.""" + return f"local_{self._device_config.id}_{self._dp_id}" + + @property + def available(self) -> bool: + """Return if device is available or not.""" + return (len(self._status) > 0) or self._device.connected + + @property + def entity_category(self) -> str: + """Return the category of the entity.""" + if category := self._config.get(CONF_ENTITY_CATEGORY): + return EntityCategory(category) if category != "None" else None + else: + # Set Default values for unconfigured devices. + if platform := self._config.get(CONF_PLATFORM): + # Call default_category from config_flow to set default values! + # This will be removed after a while, this is only made to convert who came from main integration. + # new users will be forced to choose category from config_flow. + from .config_flow import default_category + + return default_category(platform) + return None + + @property + def device_class(self): + """Return the class of this device.""" + return self._config.get(CONF_DEVICE_CLASS, self._attr_device_class) + + def has_config(self, attr) -> bool: + """Return if a config parameter has a valid value.""" + value = self._config.get(attr, "-1") + return value is not None and value != "-1" + + def dp_value(self, key, default=None) -> Any | None: + """Return cached value for DPS index or Entity Config Key. else default None""" + requested_dp = str(key) + # If requested_dp in DP ID, get cached value. + if (value := self._status.get(requested_dp)) or value is not None: + return value + + # If requested_dp is an config key get config dp then get cached value. + if (conf_key := self._config.get(requested_dp)) or conf_key is not None: + if (value := self._status.get(conf_key)) or value is not None: + return value + + if value is None: + value = default + # self.debug(f"{self.name}: is requesting unknown DP Value {key}", force=True) + + return value + + def status_updated(self) -> None: + """Device status was updated. + + Override in subclasses and update entity specific state. + """ + state = self.dp_value(self._dp_id) + self._state = state + + # Keep record in last_state as long as not during connection/re-connection, + # as last state will be used to restore the previous state + if (state is not None) and (not self._device.is_connecting): + self._last_state = state + + def status_restored(self, stored_state) -> None: + """Device status was restored. + + Override in subclasses and update entity specific state. + """ + raw_state = stored_state.attributes.get(ATTR_STATE) + if raw_state is not None: + self._last_state = raw_state + self.debug( + f"Restoring state for entity: {self.name} - state: {str(self._last_state)}" + ) + + def default_value(self): + """Return default value of this entity. + + Override in subclasses to specify the default value for the entity. + """ + # Check if default value has been set - if not, default to the entity defaults. + if self._default_value is None: + self._default_value = self.entity_default_value() + + return self._default_value + + def entity_default_value(self): # pylint: disable=no-self-use + """Return default value of the entity type. + + Override in subclasses to specify the default value for the entity. + """ + return 0 + + def scale(self, value): + """Return the scaled factor of the value, else same value.""" + scale_factor = self._config.get(CONF_SCALING) + if scale_factor is not None and isinstance(value, (int, float)): + value = round(value * scale_factor, 2) + + return value + + async def restore_state_when_connected(self) -> None: + """Restore if restore_on_reconnect is set, or if no status has been yet found. + + Which indicates a DPS that needs to be set before it starts returning + status. + """ + restore_on_reconnect = self._config.get(CONF_RESTORE_ON_RECONNECT, False) + passive_entity = self._config.get(CONF_PASSIVE_ENTITY, False) + dp_id = str(self._dp_id) + + if not restore_on_reconnect and (dp_id in self._status or not passive_entity): + self.debug( + f"Entity {self.name} (DP {self._dp_id}) - Not restoring as restore on reconnect is " + + "disabled for this entity and the entity has an initial status " + + "or it is not a passive entity" + ) + return + + self.debug(f"Attempting to restore state for entity: {self.name}") + # Attempt to restore the current state - in case reset. + restore_state = self._state + + # If no state stored in the entity currently, go from last saved state + if (restore_state == STATE_UNKNOWN) | (restore_state is None): + self.debug("No current state for entity") + restore_state = self._last_state + + # If no current or saved state, then use the default value + if restore_state is None: + if passive_entity: + self.debug("No last restored state - using default") + restore_state = self.default_value() + else: + self.debug("Not a passive entity and no state found - aborting restore") + return + + self.debug( + f"Entity {self.name} (DP {self._dp_id}) - Restoring state: {str(restore_state)}" + ) + + # Manually initialise + await self._device.set_dp(restore_state, self._dp_id) diff --git a/custom_components/localtuya/fan.py b/custom_components/localtuya/fan.py index 5e1a3c9c..c276d53a 100644 --- a/custom_components/localtuya/fan.py +++ b/custom_components/localtuya/fan.py @@ -1,7 +1,9 @@ """Platform to locally control Tuya-based fan devices.""" + import logging import math from functools import partial +from .config_flow import col_to_select import homeassistant.helpers.config_validation as cv import voluptuous as vol @@ -9,7 +11,8 @@ DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, - FanEntity, FanEntityFeature, + FanEntityFeature, + FanEntity, ) from homeassistant.util.percentage import ( int_states_in_range, @@ -19,7 +22,7 @@ ranged_value_to_percentage, ) -from .common import LocalTuyaEntity, async_setup_entry +from .entity import LocalTuyaEntity, async_setup_entry from .const import ( CONF_FAN_DIRECTION, CONF_FAN_DIRECTION_FWD, @@ -38,19 +41,19 @@ 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_SPEED_CONTROL): col_to_select(dps, is_dps=True), + vol.Optional(CONF_FAN_OSCILLATING_CONTROL): col_to_select(dps, is_dps=True), + vol.Optional(CONF_FAN_DIRECTION): col_to_select(dps, is_dps=True), 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, - vol.Optional(CONF_FAN_DPS_TYPE, default="str"): vol.In(["str", "int"]), + # vol.Optional(CONF_FAN_DPS_TYPE, default="str"): vol.In(["str", "int"]), } -class LocaltuyaFan(LocalTuyaEntity, FanEntity): +class LocalTuyaFan(LocalTuyaEntity, FanEntity): """Representation of a Tuya fan.""" def __init__( @@ -71,19 +74,11 @@ def __init__( self._config.get(CONF_FAN_SPEED_MAX), ) self._ordered_list = self._config.get(CONF_FAN_ORDERED_LIST).split(",") - self._ordered_list_mode = None - self._dps_type = int if self._config.get(CONF_FAN_DPS_TYPE) == "int" else str 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): @@ -136,9 +131,10 @@ async def async_set_percentage(self, percentage): 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( - self._dps_type( + str( percentage_to_ordered_list_item(self._ordered_list, percentage) ), self._config.get(CONF_FAN_SPEED_CONTROL), @@ -148,10 +144,9 @@ async def async_set_percentage(self, percentage): percentage, percentage_to_ordered_list_item(self._ordered_list, percentage), ) - else: await self._device.set_dp( - self._dps_type( + int( math.ceil( percentage_to_ranged_value(self._speed_range, percentage) ) @@ -186,9 +181,9 @@ async def async_set_direction(self, direction): self.schedule_update_ha_state() @property - def supported_features(self) -> int: + def supported_features(self) -> FanEntityFeature: """Flag supported features.""" - features = 0 + features = FanEntityFeature(0) if self.has_config(CONF_FAN_OSCILLATING_CONTROL): features |= FanEntityFeature.OSCILLATE @@ -199,20 +194,25 @@ def supported_features(self) -> int: if self.has_config(CONF_FAN_DIRECTION): features |= FanEntityFeature.DIRECTION + features |= FanEntityFeature.TURN_OFF + features |= FanEntityFeature.TURN_ON + return features @property def speed_count(self) -> int: """Speed count for the fan.""" + if self._use_ordered_list: + return len(self._ordered_list) 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) + self._is_on = self.dp_value(self._dp_id) - current_speed = self.dps_conf(CONF_FAN_SPEED_CONTROL) + current_speed = self.dp_value(CONF_FAN_SPEED_CONTROL) if self._use_ordered_list: _LOGGER.debug( "Fan current_speed ordered_list_item_to_percentage: %s from %s", @@ -238,11 +238,11 @@ def status_updated(self): _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) + self._oscillating = self.dp_value(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) + value = self.dp_value(CONF_FAN_DIRECTION) if value is not None: if value == self._config.get(CONF_FAN_DIRECTION_FWD): self._direction = DIRECTION_FORWARD @@ -252,4 +252,4 @@ def status_updated(self): _LOGGER.debug("Fan current_direction : %s > %s", value, self._direction) -async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaFan, flow_schema) +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaFan, flow_schema) diff --git a/custom_components/localtuya/humidifier.py b/custom_components/localtuya/humidifier.py new file mode 100644 index 00000000..e18e31f7 --- /dev/null +++ b/custom_components/localtuya/humidifier.py @@ -0,0 +1,156 @@ +"""Platform to locally control Tuya-based button devices.""" + +import logging +from functools import partial +from .config_flow import col_to_select +from homeassistant.helpers import selector + +import voluptuous as vol +from homeassistant.const import CONF_DEVICE_CLASS +from homeassistant.components.humidifier import ( + DOMAIN, + HumidifierDeviceClass, + DEVICE_CLASSES_SCHEMA, + HumidifierEntity, + HumidifierEntityDescription, + HumidifierEntityFeature, +) +from homeassistant.components.humidifier.const import ( + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, +) + +CONF_HUMIDIFIER_SET_HUMIDITY_DP = "humidifier_set_humidity_dp" +CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP = "humidifier_current_humidity_dp" +CONF_HUMIDIFIER_MODE_DP = "humidifier_mode_dp" +CONF_HUMIDIFIER_AVAILABLE_MODES = "humidifier_available_modes" + +from .entity import LocalTuyaEntity, async_setup_entry + + +_LOGGER = logging.getLogger(__name__) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_HUMIDIFIER_SET_HUMIDITY_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP): col_to_select( + dps, is_dps=True + ), + vol.Optional(CONF_HUMIDIFIER_MODE_DP): col_to_select(dps, is_dps=True), + vol.Required(ATTR_MIN_HUMIDITY, default=DEFAULT_MIN_HUMIDITY): int, + vol.Required(ATTR_MAX_HUMIDITY, default=DEFAULT_MAX_HUMIDITY): int, + vol.Optional( + CONF_HUMIDIFIER_AVAILABLE_MODES, default={} + ): selector.ObjectSelector(), + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + } + + +class LocalTuyaHumidifier(LocalTuyaEntity, HumidifierEntity): + """Representation of a Localtuya Humidifier.""" + + _dp_mode = CONF_HUMIDIFIER_MODE_DP + _available_modes = CONF_HUMIDIFIER_AVAILABLE_MODES + _dp_current_humidity = CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP + _dp_set_humidity = CONF_HUMIDIFIER_SET_HUMIDITY_DP + _mode_name_to_value = {} + + def __init__( + self, + device, + config_entry, + humidifierID, + **kwargs, + ): + """Initialize the Tuya button.""" + super().__init__(device, config_entry, humidifierID, _LOGGER, **kwargs) + self._state = None + self._current_mode = None + + if self._config.get(self._dp_mode) and self._config.get(self._available_modes): + self._attr_supported_features |= HumidifierEntityFeature.MODES + self._mode_name_to_value = { + v: k if k else v.replace("_", " ").capitalize() + for k, v in self._config.get(self._available_modes, {}).items() + } + + self._attr_min_humidity = self._config.get( + ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY + ) + self._attr_max_humidity = self._config.get( + ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY + ) + + @property + def is_on(self) -> bool: + """Return the device is on or off.""" + return self._state + + @property + def mode(self) -> str | None: + """Return the current mode.""" + return self._current_mode + + @property + def target_humidity(self) -> int | None: + """Return the humidity we try to reach.""" + target_dp = self._config.get(self._dp_set_humidity, None) + + return self.dp_value(target_dp) if target_dp else None + + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + curr_humidity = self._config.get(self._dp_current_humidity) + + return self.dp_value(self._dp_current_humidity) if curr_humidity else None + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._device.set_dp(True, self._dp_id) + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._device.set_dp(False, self._dp_id) + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + set_humidity_dp = self._config.get(self._dp_set_humidity, None) + if set_humidity_dp is None: + return None + + await self._device.set_dp(humidity, set_humidity_dp) + + @property + def available_modes(self): + """Return the list of presets that this device supports.""" + if modes := self._config.get(self._available_modes, {}).values(): + modes = list(modes) + return modes + + async def async_set_mode(self, mode): + """Set new target preset mode.""" + set_mode_dp = self._config.get(self._dp_mode, None) + if set_mode_dp is None: + return None + + set_mode = self._mode_name_to_value.get(mode) + await self._device.set_dp(set_mode, set_mode_dp) + + def status_updated(self): + """Device status was updated.""" + super().status_updated() + current_mode = self.dp_value(self._dp_mode) + for mode, mode_name in self._config.get(self._available_modes, {}).items(): + if mode == current_mode: + self._current_mode = mode_name + break + else: + self._current_mode = "unknown" + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaHumidifier, flow_schema) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index 7c74e49f..8818060b 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -1,34 +1,38 @@ """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 dataclasses import dataclass +from functools import partial +from homeassistant.helpers import selector 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, + LightEntityFeature, + ColorMode, LightEntity, ) from homeassistant.const import CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_SCENE -from .common import LocalTuyaEntity, async_setup_entry +from .config_flow import col_to_select +from .entity import LocalTuyaEntity, async_setup_entry from .const import ( CONF_BRIGHTNESS_LOWER, CONF_BRIGHTNESS_UPPER, CONF_COLOR, CONF_COLOR_MODE, + CONF_COLOR_MODE_SET, CONF_COLOR_TEMP_MAX_KELVIN, CONF_COLOR_TEMP_MIN_KELVIN, CONF_COLOR_TEMP_REVERSE, CONF_MUSIC_MODE, + CONF_SCENE_VALUES, ) _LOGGER = logging.getLogger(__name__) @@ -41,6 +45,7 @@ DEFAULT_LOWER_BRIGHTNESS = 29 DEFAULT_UPPER_BRIGHTNESS = 1000 +MODE_MANUAL = "manual" MODE_COLOR = "colour" MODE_MUSIC = "music" MODE_SCENE = "scene" @@ -49,6 +54,18 @@ SCENE_CUSTOM = "Custom" SCENE_MUSIC = "Music" +MODES_SET = {"Colour, Music, Scene and White": 0, "Manual, Music, Scene and White": 1} + +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_RGBW_1000 = { "Night": "000e0d0000000000000000c80000", "Read": "010e0d0000000000000003e801f4", @@ -63,18 +80,6 @@ + "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", @@ -86,52 +91,66 @@ + "e800000000", "Dazzling": "06464601000003e803e800000000464601007803e803e80000000046460100f003e80" + "3e800000000", - "Music": "07464602000003e803e800000000464602007803e803e80000000046460200f003e803e8" + "Gorgeous": "07464602000003e803e800000000464602007803e803e80000000046460200f003e803e8" + "00000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e803e80" + "0000000", } -def map_range(value, from_lower, from_upper, to_lower, to_upper): +@dataclass(frozen=True) +class Mode: + color: str = MODE_COLOR + music: str = MODE_MUSIC + scene: str = MODE_SCENE + white: str = MODE_WHITE + + def as_list(self) -> list: + return [self.color, self.music, self.scene, self.white] + + def as_dict(self) -> dict[str, str]: + default = {"Default": self.white} + return {**default, "Mode Color": self.color, "Mode Scene": self.scene} + + +MAP_MODE_SET = {0: Mode(), 1: Mode(color=MODE_MANUAL)} + + +def map_range(value, from_lower, from_upper, to_lower=0, to_upper=255, reverse=False): """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)) + if reverse: + value = from_upper - value + from_lower + mapped = value * to_upper / from_upper + return min(max(round(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): col_to_select(dps, is_dps=True), + vol.Optional(CONF_COLOR_TEMP): col_to_select(dps, is_dps=True), 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_MODE): col_to_select(dps, is_dps=True), + vol.Required(CONF_COLOR_MODE_SET, default="0"): col_to_select(MODES_SET), + vol.Optional(CONF_COLOR): col_to_select(dps, is_dps=True), 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, + vol.Optional(CONF_COLOR_TEMP_REVERSE, default=DEFAULT_COLOR_TEMP_REVERSE): bool, + vol.Optional(CONF_SCENE): col_to_select(dps, is_dps=True), + vol.Optional(CONF_SCENE_VALUES, default={}): selector.ObjectSelector(), + vol.Optional(CONF_MUSIC_MODE, default=False): selector.BooleanSelector(), } -class LocaltuyaLight(LocalTuyaEntity, LightEntity): +class LocalTuyaLight(LocalTuyaEntity, LightEntity): """Representation of a Tuya light.""" def __init__( @@ -146,34 +165,47 @@ def __init__( self._state = False self._brightness = None self._color_temp = None - self._lower_brightness = self._config.get( - CONF_BRIGHTNESS_LOWER, DEFAULT_LOWER_BRIGHTNESS + self._lower_brightness = int( + self._config.get(CONF_BRIGHTNESS_LOWER, DEFAULT_LOWER_BRIGHTNESS) ) - self._upper_brightness = self._config.get( - CONF_BRIGHTNESS_UPPER, DEFAULT_UPPER_BRIGHTNESS + self._upper_brightness = int( + self._config.get(CONF_BRIGHTNESS_UPPER, DEFAULT_UPPER_BRIGHTNESS) ) self._upper_color_temp = self._upper_brightness - self._max_mired = color_util.color_temperature_kelvin_to_mired( + self._min_kelvin = int( self._config.get(CONF_COLOR_TEMP_MIN_KELVIN, DEFAULT_MIN_KELVIN) ) - self._min_mired = color_util.color_temperature_kelvin_to_mired( + self._max_kelvin = int( 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._modes = MAP_MODE_SET[int(self._config.get(CONF_COLOR_MODE_SET, 0))] self._hs = None self._effect = None self._effect_list = [] - self._scenes = None + self._scenes = {} + + custom_scenes = False if self.has_config(CONF_SCENE): - if self._config.get(CONF_SCENE) < 20: + if self.has_config(CONF_SCENE_VALUES): + custom_scenes = True + values_list = list(self._config.get(CONF_SCENE_VALUES)) + values_name = list(self._config.get(CONF_SCENE_VALUES).values()) + self._scenes = dict(zip(values_name, values_list)) + elif int(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 + + if not custom_scenes: + self._scenes = {**self._modes.as_dict(), **self._scenes} + self._effect_list = list(self._scenes.keys()) + if self._config.get(CONF_MUSIC_MODE): self._effect_list.append(SCENE_MUSIC) @@ -185,9 +217,19 @@ def is_on(self): @property def brightness(self): """Return the brightness of the light.""" - if self.is_color_mode or self.is_white_mode: + brightness = self._brightness + if brightness is not None and (self.is_color_mode or self.is_white_mode): + if self._upper_brightness >= 1000: + # Round to the nearest 10th, since Tuya does that. + # If the value is less than 5, it will round down to 0. + # So instead, we take _lower_brightness, which is < 5 in this case. + brightness = ( + (brightness + 5) // 10 * 10 + if brightness >= 5 + else self._lower_brightness + ) return map_range( - self._brightness, self._lower_brightness, self._upper_brightness, 0, 255 + brightness, self._lower_brightness, self._upper_brightness, 0, 255 ) return None @@ -197,8 +239,8 @@ def hs_color(self): if self.is_color_mode: return self._hs if ( - self.supported_features & SUPPORT_COLOR - and not self.supported_features & SUPPORT_COLOR_TEMP + ColorMode.HS in self.supported_color_modes + and not ColorMode.COLOR_TEMP in self.supported_color_modes ): return [0, 0] return None @@ -206,17 +248,19 @@ def hs_color(self): @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 = ( + if self._color_temp is None: + return + if self.has_config(CONF_COLOR_TEMP): + color_temp = ( self._upper_color_temp - self._color_temp if self._color_temp_reverse else self._color_temp ) return int( - self._max_mired + self.max_mireds - ( - ((self._max_mired - self._min_mired) / self._upper_color_temp) - * color_temp_value + ((self.max_mireds - self.min_mireds) / self._upper_color_temp) + * color_temp ) ) return None @@ -224,65 +268,98 @@ def color_temp(self): @property def min_mireds(self): """Return color temperature min mireds.""" - return self._min_mired + return color_util.color_temperature_kelvin_to_mired(self._max_kelvin) @property def max_mireds(self): """Return color temperature max mireds.""" - return self._max_mired + return color_util.color_temperature_kelvin_to_mired(self._min_kelvin) @property def effect(self): """Return the current effect for this light.""" if self.is_scene_mode or self.is_music_mode: return self._effect + elif (color_mode := self.__get_color_mode()) in self._scenes.values(): + return self.__find_scene_by_scene_data(color_mode) return None @property def effect_list(self): """Return the list of supported effects for this light.""" - return self._effect_list + if len(self._effect_list) > 0: + return self._effect_list + return None @property - def supported_features(self): - """Flag supported features.""" - supports = 0 - if self.has_config(CONF_BRIGHTNESS): - supports |= SUPPORT_BRIGHTNESS + def supported_color_modes(self) -> set[ColorMode] | set[str] | None: + """Flag supported color modes.""" + color_modes: set[ColorMode] = set() + if self.has_config(CONF_COLOR_TEMP): - supports |= SUPPORT_COLOR_TEMP + color_modes.add(ColorMode.COLOR_TEMP) if self.has_config(CONF_COLOR): - supports |= SUPPORT_COLOR | SUPPORT_BRIGHTNESS + color_modes.add(ColorMode.HS) + + if not color_modes and self.has_config(CONF_BRIGHTNESS): + return {ColorMode.BRIGHTNESS} + + if not color_modes: + return {ColorMode.ONOFF} + + return color_modes + + @property + def supported_features(self) -> LightEntityFeature: + """Flag supported features.""" + supports = LightEntityFeature(0) if self.has_config(CONF_SCENE) or self.has_config(CONF_MUSIC_MODE): - supports |= SUPPORT_EFFECT + supports |= LightEntityFeature.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 + return color_mode is None or color_mode == self._modes.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 + return color_mode is not None and color_mode == self._modes.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) + return color_mode is not None and color_mode.startswith(self._modes.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 + return color_mode is not None and color_mode == self._modes.music + + @property + def color_mode(self) -> ColorMode: + """Return the color_mode of the light.""" + if len(self.supported_color_modes) == 1: + return next(iter(self.supported_color_modes)) + + if self.is_color_mode: + return ColorMode.HS + if self.is_white_mode: + return ColorMode.COLOR_TEMP + if self._brightness: + return ColorMode.BRIGHTNESS + + return ColorMode.ONOFF def __is_color_rgb_encoded(self): - return len(self.dps_conf(CONF_COLOR)) > 12 + # for now we will prefer non encoded if color is none "added by manual or cloud pull dp" + color = self.dp_value(CONF_COLOR) + return False if color is None else len(color) > 12 def __find_scene_by_scene_data(self, data): return next( @@ -292,9 +369,9 @@ def __find_scene_by_scene_data(self, data): def __get_color_mode(self): return ( - self.dps_conf(CONF_COLOR_MODE) + self.dp_value(CONF_COLOR_MODE) if self.has_config(CONF_COLOR_MODE) - else MODE_WHITE + else self._modes.white ) async def async_turn_on(self, **kwargs): @@ -303,19 +380,30 @@ async def async_turn_on(self, **kwargs): if not self.is_on: states[self._dp_id] = True features = self.supported_features + color_modes = self.supported_color_modes brightness = None - if ATTR_EFFECT in kwargs and (features & SUPPORT_EFFECT): - scene = self._scenes.get(kwargs[ATTR_EFFECT]) + if ATTR_EFFECT in kwargs and (features & LightEntityFeature.EFFECT): + effect = kwargs[ATTR_EFFECT] + scene = self._scenes.get(effect) if scene is not None: - if scene.startswith(MODE_SCENE): + if scene.startswith(self._modes.scene) or scene in ( + self._modes.white, + self._modes.color, + ): states[self._config.get(CONF_COLOR_MODE)] = scene else: - states[self._config.get(CONF_COLOR_MODE)] = MODE_SCENE + states[self._config.get(CONF_COLOR_MODE)] = self._modes.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): + elif effect in self._modes.as_list(): + states[self._config.get(CONF_COLOR_MODE)] = effect + elif effect == self._modes.music: + states[self._config.get(CONF_COLOR_MODE)] = self._modes.music + + if ATTR_BRIGHTNESS in kwargs and ( + ColorMode.BRIGHTNESS in color_modes + or self.has_config(CONF_BRIGHTNESS) + or self.has_config(CONF_COLOR) + ): brightness = map_range( int(kwargs[ATTR_BRIGHTNESS]), 0, @@ -323,7 +411,7 @@ async def async_turn_on(self, **kwargs): self._lower_brightness, self._upper_brightness, ) - if self.is_white_mode: + if self.is_white_mode or self.dp_value(CONF_COLOR) is None: states[self._config.get(CONF_BRIGHTNESS)] = brightness else: if self.__is_color_rgb_encoded(): @@ -345,15 +433,15 @@ async def async_turn_on(self, **kwargs): 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 + states[self._config.get(CONF_COLOR_MODE)] = self._modes.color - if ATTR_HS_COLOR in kwargs and (features & SUPPORT_COLOR): + if ATTR_HS_COLOR in kwargs and ColorMode.HS in color_modes: 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 + states[self._config.get(CONF_COLOR_MODE)] = self._modes.white else: if self.__is_color_rgb_encoded(): rgb = color_util.color_hsv_to_RGB( @@ -372,26 +460,27 @@ async def async_turn_on(self, **kwargs): 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 + states[self._config.get(CONF_COLOR_MODE)] = self._modes.color - if ATTR_COLOR_TEMP in kwargs and (features & SUPPORT_COLOR_TEMP): + if ATTR_COLOR_TEMP in kwargs and ColorMode.COLOR_TEMP in color_modes: if brightness is None: brightness = self._brightness mired = int(kwargs[ATTR_COLOR_TEMP]) if self._color_temp_reverse: - mired = self._max_mired - (mired - self._min_mired) - if mired < self._min_mired: - mired = self._min_mired - elif mired > self._max_mired: - mired = self._max_mired + mired = self.max_mireds - (mired - self.min_mireds) + if mired < self.min_mireds: + mired = self.min_mireds + elif mired > self.max_mireds: + mired = self.max_mireds color_temp = int( self._upper_color_temp - - (self._upper_color_temp / (self._max_mired - self._min_mired)) - * (mired - self._min_mired) + - (self._upper_color_temp / (self.max_mireds - self.min_mireds)) + * (mired - self.min_mireds) ) - states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE + states[self._config.get(CONF_COLOR_MODE)] = self._modes.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): @@ -400,14 +489,15 @@ async def async_turn_off(self, **kwargs): def status_updated(self): """Device status was updated.""" - self._state = self.dps(self._dp_id) + self._state = self.dp_value(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 brightness_dp_value := self.dp_value(CONF_BRIGHTNESS, None): + self._brightness = brightness_dp_value + + if ColorMode.HS in self.supported_color_modes: + color = self.dp_value(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) @@ -421,18 +511,20 @@ def status_updated(self): ] self._hs = [hue, sat / 10.0] self._brightness = value + elif self._brightness is None: + self._brightness = 20 - if supported & SUPPORT_COLOR_TEMP: - self._color_temp = self.dps_conf(CONF_COLOR_TEMP) + if ColorMode.COLOR_TEMP in self.supported_color_modes: + self._color_temp = self.dp_value(CONF_COLOR_TEMP) - if self.is_scene_mode and supported & SUPPORT_EFFECT: - if self.dps_conf(CONF_COLOR_MODE) != MODE_SCENE: + if self.is_scene_mode and supported & LightEntityFeature.EFFECT: + if self.dp_value(CONF_COLOR_MODE) != self._modes.scene: self._effect = self.__find_scene_by_scene_data( - self.dps_conf(CONF_COLOR_MODE) + self.dp_value(CONF_COLOR_MODE) ) else: self._effect = self.__find_scene_by_scene_data( - self.dps_conf(CONF_SCENE) + self.dp_value(CONF_SCENE) ) if self._effect == SCENE_CUSTOM: if SCENE_CUSTOM not in self._effect_list: @@ -440,8 +532,8 @@ def status_updated(self): elif SCENE_CUSTOM in self._effect_list: self._effect_list.remove(SCENE_CUSTOM) - if self.is_music_mode and supported & SUPPORT_EFFECT: + if self.is_music_mode and supported & LightEntityFeature.EFFECT: self._effect = SCENE_MUSIC -async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaLight, flow_schema) +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaLight, flow_schema) diff --git a/custom_components/localtuya/lock.py b/custom_components/localtuya/lock.py new file mode 100644 index 00000000..ee8272a2 --- /dev/null +++ b/custom_components/localtuya/lock.py @@ -0,0 +1,64 @@ +"""Platform to present any Tuya DP as a Lock.""" + +import logging +from functools import partial +from typing import Any +from .config_flow import col_to_select + +import voluptuous as vol +from homeassistant.components.lock import DOMAIN, LockEntity +from .entity import LocalTuyaEntity, async_setup_entry + +from .const import CONF_JAMMED_DP, CONF_LOCK_STATE_DP + +_LOGGER = logging.getLogger(__name__) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_LOCK_STATE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_JAMMED_DP): col_to_select(dps, is_dps=True), + } + + +class LocalTuyaLock(LocalTuyaEntity, LockEntity): + """Representation of a Tuya Lock.""" + + def __init__( + self, + device, + config_entry, + Lockid, + **kwargs, + ): + """Initialize the Tuya Lock.""" + super().__init__(device, config_entry, Lockid, _LOGGER, **kwargs) + self._state = None + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the lock.""" + await self._device.set_dp(True, self._dp_id) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the lock.""" + await self._device.set_dp(False, self._dp_id) + + def status_updated(self): + """Device status was updated.""" + state = self.dp_value(self._dp_id) + if (lock_state := self.dp_value(CONF_LOCK_STATE_DP)) or lock_state is not None: + state = lock_state + + self._attr_is_locked = state in (False, "closed", "close", None) + + if jammed := self.dp_value(CONF_JAMMED_DP, False): + self._attr_is_jammed = jammed + + # No need to restore state for a Lock + async def restore_state_when_connected(self): + """Do nothing for a Lock.""" + return + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaLock, flow_schema) diff --git a/custom_components/localtuya/manifest.json b/custom_components/localtuya/manifest.json index 28e36fa0..092cc596 100644 --- a/custom_components/localtuya/manifest.json +++ b/custom_components/localtuya/manifest.json @@ -1,14 +1,13 @@ { "domain": "localtuya", - "name": "LocalTuya integration", - "codeowners": [ - "@rospogrigio", "@postlund" - ], + "name": "Local Tuya", + "codeowners": [], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/rospogrigio/localtuya/", + "documentation": "https://github.com/xZetsubou/hass-localtuya/", + "integration_type": "hub", "iot_class": "local_push", - "issue_tracker": "https://github.com/rospogrigio/localtuya/issues", + "issue_tracker": "https://github.com/xZetsubou/hass-localtuya/issues", "requirements": [], - "version": "5.2.1" + "version": "2024.12.1" } diff --git a/custom_components/localtuya/number.py b/custom_components/localtuya/number.py index 917d3d00..e49768c2 100644 --- a/custom_components/localtuya/number.py +++ b/custom_components/localtuya/number.py @@ -1,19 +1,25 @@ """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 homeassistant.components.number import DOMAIN, NumberEntity, DEVICE_CLASSES_SCHEMA +from homeassistant.const import ( + CONF_DEVICE_CLASS, + STATE_UNKNOWN, + CONF_UNIT_OF_MEASUREMENT, +) -from .common import LocalTuyaEntity, async_setup_entry +from .entity import LocalTuyaEntity, async_setup_entry from .const import ( CONF_DEFAULT_VALUE, CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_PASSIVE_ENTITY, CONF_RESTORE_ON_RECONNECT, - CONF_STEPSIZE_VALUE, + CONF_SCALING, + CONF_STEPSIZE, ) _LOGGER = logging.getLogger(__name__) @@ -34,17 +40,21 @@ def flow_schema(dps): vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0), ), - vol.Required(CONF_STEPSIZE_VALUE, default=DEFAULT_STEP): vol.All( - vol.Coerce(float), - vol.Range(min=0.0, max=1000000.0), + vol.Required(CONF_STEPSIZE, default=DEFAULT_STEP): vol.All( + vol.Coerce(float), vol.Range(min=0.0, max=1000000.0) ), - vol.Required(CONF_RESTORE_ON_RECONNECT): bool, - vol.Required(CONF_PASSIVE_ENTITY): bool, + vol.Optional(CONF_RESTORE_ON_RECONNECT, default=False): bool, + vol.Optional(CONF_PASSIVE_ENTITY, default=False): bool, vol.Optional(CONF_DEFAULT_VALUE): str, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.Any(None, str), + vol.Optional(CONF_SCALING): vol.All( + vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0) + ), } -class LocaltuyaNumber(LocalTuyaEntity, NumberEntity): +class LocalTuyaNumber(LocalTuyaEntity, NumberEntity): """Representation of a Tuya Number.""" def __init__( @@ -58,17 +68,9 @@ def __init__( 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 = DEFAULT_MAX - if CONF_MAX_VALUE in self._config: - self._max_value = self._config.get(CONF_MAX_VALUE) - - self._step_size = DEFAULT_STEP - if CONF_STEPSIZE_VALUE in self._config: - self._step_size = self._config.get(CONF_STEPSIZE_VALUE) + self._min_value = self.scale(self._config.get(CONF_MIN_VALUE, DEFAULT_MIN)) + self._max_value = self.scale(self._config.get(CONF_MAX_VALUE, DEFAULT_MAX)) + self._step_size = self.scale(self._config.get(CONF_STEPSIZE, DEFAULT_STEP)) # Override standard default value handling to cast to a float default_value = self._config.get(CONF_DEFAULT_VALUE) @@ -78,6 +80,7 @@ def __init__( @property def native_value(self) -> float: """Return sensor state.""" + self._state = self.scale(self._state) return self._state @property @@ -95,6 +98,11 @@ def native_step(self) -> float: """Return the maximum value.""" return self._step_size + @property + def native_unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._config.get(CONF_UNIT_OF_MEASUREMENT) + @property def device_class(self): """Return the class of this device.""" @@ -102,7 +110,10 @@ def device_class(self): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - await self._device.set_dp(value, self._dp_id) + if scale_factor := self._config.get(CONF_SCALING): + value = value / float(scale_factor) + + await self._device.set_dp(int(value), self._dp_id) # Default value is the minimum value def entity_default_value(self): @@ -110,4 +121,4 @@ def entity_default_value(self): return self._min_value -async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaNumber, flow_schema) +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaNumber, flow_schema) diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py deleted file mode 100644 index 67aabb24..00000000 --- a/custom_components/localtuya/pytuya/__init__.py +++ /dev/null @@ -1,1196 +0,0 @@ -# PyTuya Module -# -*- coding: utf-8 -*- -""" -Python module to interface with Tuya WiFi smart devices. - -Author: clach04, postlund -Maintained by: rospogrigio - -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], 3.2, 3.3 or 3.4 - 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) - * Tuya Protocol 3.4 Support by uzlonewolf - Enhancement to TuyaMessage logic for multi-payload messages and Tuya Protocol 3.4 support - * TinyTuya https://github.com/jasonacox/tinytuya by jasonacox - Several CLI tools and code for Tuya devices -""" - -import asyncio -import base64 -import binascii -import hmac -import json -import logging -import struct -import time -import weakref -from abc import ABC, abstractmethod -from collections import namedtuple -from hashlib import md5, sha256 - -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - -version_tuple = (10, 0, 0) -version = version_string = __version__ = "%d.%d.%d" % version_tuple -__author__ = "rospogrigio" - -_LOGGER = logging.getLogger(__name__) - -# Tuya Packet Format -TuyaHeader = namedtuple("TuyaHeader", "prefix seqno cmd length") -MessagePayload = namedtuple("MessagePayload", "cmd payload") -try: - TuyaMessage = namedtuple( - "TuyaMessage", "seqno cmd retcode payload crc crc_good", defaults=(True,) - ) -except Exception: - TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc crc_good") - -# TinyTuya Error Response Codes -ERR_JSON = 900 -ERR_CONNECT = 901 -ERR_TIMEOUT = 902 -ERR_RANGE = 903 -ERR_PAYLOAD = 904 -ERR_OFFLINE = 905 -ERR_STATE = 906 -ERR_FUNCTION = 907 -ERR_DEVTYPE = 908 -ERR_CLOUDKEY = 909 -ERR_CLOUDRESP = 910 -ERR_CLOUDTOKEN = 911 -ERR_PARAMS = 912 -ERR_CLOUD = 913 - -error_codes = { - ERR_JSON: "Invalid JSON Response from Device", - ERR_CONNECT: "Network Error: Unable to Connect", - ERR_TIMEOUT: "Timeout Waiting for Device", - ERR_RANGE: "Specified Value Out of Range", - ERR_PAYLOAD: "Unexpected Payload from Device", - ERR_OFFLINE: "Network Error: Device Unreachable", - ERR_STATE: "Device in Unknown State", - ERR_FUNCTION: "Function Not Supported by Device", - ERR_DEVTYPE: "Device22 Detected: Retry Command", - ERR_CLOUDKEY: "Missing Tuya Cloud Key and Secret", - ERR_CLOUDRESP: "Invalid JSON Response from Cloud", - ERR_CLOUDTOKEN: "Unable to Get Cloud Token", - ERR_PARAMS: "Missing Function Parameters", - ERR_CLOUD: "Error Response from Tuya Cloud", - None: "Unknown Error", -} - - -class DecodeError(Exception): - """Specific Exception caused by decoding error.""" - - pass - - -# Tuya Command Types -# Reference: -# https://github.com/tuya/tuya-iotos-embeded-sdk-wifi-ble-bk7231n/blob/master/sdk/include/lan_protocol.h -AP_CONFIG = 0x01 # FRM_TP_CFG_WF # only used for ap 3.0 network config -ACTIVE = 0x02 # FRM_TP_ACTV (discard) # WORK_MODE_CMD -SESS_KEY_NEG_START = 0x03 # FRM_SECURITY_TYPE3 # negotiate session key -SESS_KEY_NEG_RESP = 0x04 # FRM_SECURITY_TYPE4 # negotiate session key response -SESS_KEY_NEG_FINISH = 0x05 # FRM_SECURITY_TYPE5 # finalize session key negotiation -UNBIND = 0x06 # FRM_TP_UNBIND_DEV # DATA_QUERT_CMD - issue command -CONTROL = 0x07 # FRM_TP_CMD # STATE_UPLOAD_CMD -STATUS = 0x08 # FRM_TP_STAT_REPORT # STATE_QUERY_CMD -HEART_BEAT = 0x09 # FRM_TP_HB -DP_QUERY = 0x0A # 10 # FRM_QUERY_STAT # UPDATE_START_CMD - get data points -QUERY_WIFI = 0x0B # 11 # FRM_SSID_QUERY (discard) # UPDATE_TRANS_CMD -TOKEN_BIND = 0x0C # 12 # FRM_USER_BIND_REQ # GET_ONLINE_TIME_CMD - system time (GMT) -CONTROL_NEW = 0x0D # 13 # FRM_TP_NEW_CMD # FACTORY_MODE_CMD -ENABLE_WIFI = 0x0E # 14 # FRM_ADD_SUB_DEV_CMD # WIFI_TEST_CMD -WIFI_INFO = 0x0F # 15 # FRM_CFG_WIFI_INFO -DP_QUERY_NEW = 0x10 # 16 # FRM_QUERY_STAT_NEW -SCENE_EXECUTE = 0x11 # 17 # FRM_SCENE_EXEC -UPDATEDPS = 0x12 # 18 # FRM_LAN_QUERY_DP # Request refresh of DPS -UDP_NEW = 0x13 # 19 # FR_TYPE_ENCRYPTION -AP_CONFIG_NEW = 0x14 # 20 # FRM_AP_CFG_WF_V40 -BOARDCAST_LPV34 = 0x23 # 35 # FR_TYPE_BOARDCAST_LPV34 -LAN_EXT_STREAM = 0x40 # 64 # FRM_LAN_EXT_STREAM - - -PROTOCOL_VERSION_BYTES_31 = b"3.1" -PROTOCOL_VERSION_BYTES_33 = b"3.3" -PROTOCOL_VERSION_BYTES_34 = b"3.4" - -PROTOCOL_3x_HEADER = 12 * b"\x00" -PROTOCOL_33_HEADER = PROTOCOL_VERSION_BYTES_33 + PROTOCOL_3x_HEADER -PROTOCOL_34_HEADER = PROTOCOL_VERSION_BYTES_34 + PROTOCOL_3x_HEADER -MESSAGE_HEADER_FMT = ">4I" # 4*uint32: prefix, seqno, cmd, length [, retcode] -MESSAGE_RECV_HEADER_FMT = ">5I" # 4*uint32: prefix, seqno, cmd, length, retcode -MESSAGE_RETCODE_FMT = ">I" # retcode for received messages -MESSAGE_END_FMT = ">2I" # 2*uint32: crc, suffix -MESSAGE_END_FMT_HMAC = ">32sI" # 32s:hmac, uint32:suffix -PREFIX_VALUE = 0x000055AA -PREFIX_BIN = b"\x00\x00U\xaa" -SUFFIX_VALUE = 0x0000AA55 -SUFFIX_BIN = b"\x00\x00\xaaU" -NO_PROTOCOL_HEADER_CMDS = [ - DP_QUERY, - DP_QUERY_NEW, - UPDATEDPS, - HEART_BEAT, - SESS_KEY_NEG_START, - SESS_KEY_NEG_RESP, - SESS_KEY_NEG_FINISH, -] - -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) - -# Tuya Device Dictionary - Command and Payload Overrides -# This is intended to match requests.json payload at -# https://github.com/codetheweb/tuyapi : -# 'type_0a' devices require the 0a command for the DP_QUERY request -# 'type_0d' devices require the 0d command for the DP_QUERY request and a list of -# dps used set to Null in the request payload -# 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) - -# Any command not defined in payload_dict will be sent as-is with a -# payload of {"gwId": "", "devId": "", "uid": "", "t": ""} - -payload_dict = { - # Default Device - "type_0a": { - AP_CONFIG: { # [BETA] Set Control Values on Device - "command": {"gwId": "", "devId": "", "uid": "", "t": ""}, - }, - CONTROL: { # Set Control Values on Device - "command": {"devId": "", "uid": "", "t": ""}, - }, - STATUS: { # Get Status from Device - "command": {"gwId": "", "devId": ""}, - }, - HEART_BEAT: {"command": {"gwId": "", "devId": ""}}, - DP_QUERY: { # Get Data Points from Device - "command": {"gwId": "", "devId": "", "uid": "", "t": ""}, - }, - CONTROL_NEW: {"command": {"devId": "", "uid": "", "t": ""}}, - DP_QUERY_NEW: {"command": {"devId": "", "uid": "", "t": ""}}, - UPDATEDPS: {"command": {"dpId": [18, 19, 20]}}, - }, - # Special Case Device "0d" - Some of these devices - # Require the 0d command as the DP_QUERY status request and the list of - # dps requested payload - "type_0d": { - DP_QUERY: { # Get Data Points from Device - "command_override": CONTROL_NEW, # Uses CONTROL_NEW command for some reason - "command": {"devId": "", "uid": "", "t": ""}, - }, - }, - "v3.4": { - CONTROL: { - "command_override": CONTROL_NEW, # Uses CONTROL_NEW command - "command": {"protocol": 5, "t": "int", "data": ""}, - }, - DP_QUERY: {"command_override": DP_QUERY_NEW}, - }, -} - - -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 - self._enable_debug = False - - def set_logger(self, logger, device_id, enable_debug=False): - """Set base logger to use.""" - self._enable_debug = enable_debug - self._logger = TuyaLoggingAdapter(logger, {"device_id": device_id}) - - def debug(self, msg, *args): - """Debug level log.""" - if not self._enable_debug: - return - 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, hmac_key=None): - """Pack a TuyaMessage into bytes.""" - end_fmt = MESSAGE_END_FMT_HMAC if hmac_key else MESSAGE_END_FMT - # Create full message excluding CRC and suffix - buffer = ( - struct.pack( - MESSAGE_HEADER_FMT, - PREFIX_VALUE, - msg.seqno, - msg.cmd, - len(msg.payload) + struct.calcsize(end_fmt), - ) - + msg.payload - ) - if hmac_key: - crc = hmac.new(hmac_key, buffer, sha256).digest() - else: - crc = binascii.crc32(buffer) & 0xFFFFFFFF - # Calculate CRC, add it together with suffix - buffer += struct.pack(end_fmt, crc, SUFFIX_VALUE) - return buffer - - -def unpack_message(data, hmac_key=None, header=None, no_retcode=False, logger=None): - """Unpack bytes into a TuyaMessage.""" - end_fmt = MESSAGE_END_FMT_HMAC if hmac_key else MESSAGE_END_FMT - # 4-word header plus return code - header_len = struct.calcsize(MESSAGE_HEADER_FMT) - retcode_len = 0 if no_retcode else struct.calcsize(MESSAGE_RETCODE_FMT) - end_len = struct.calcsize(end_fmt) - headret_len = header_len + retcode_len - - if len(data) < headret_len + end_len: - logger.debug( - "unpack_message(): not enough data to unpack header! need %d but only have %d", - headret_len + end_len, - len(data), - ) - raise DecodeError("Not enough data to unpack header") - - if header is None: - header = parse_header(data) - - if len(data) < header_len + header.length: - logger.debug( - "unpack_message(): not enough data to unpack payload! need %d but only have %d", - header_len + header.length, - len(data), - ) - raise DecodeError("Not enough data to unpack payload") - - retcode = ( - 0 - if no_retcode - else struct.unpack(MESSAGE_RETCODE_FMT, data[header_len:headret_len])[0] - ) - # the retcode is technically part of the payload, but strip it as we do not want it here - payload = data[header_len + retcode_len : header_len + header.length] - crc, suffix = struct.unpack(end_fmt, payload[-end_len:]) - - if hmac_key: - have_crc = hmac.new( - hmac_key, data[: (header_len + header.length) - end_len], sha256 - ).digest() - else: - have_crc = ( - binascii.crc32(data[: (header_len + header.length) - end_len]) & 0xFFFFFFFF - ) - - if suffix != SUFFIX_VALUE: - logger.debug("Suffix prefix wrong! %08X != %08X", suffix, SUFFIX_VALUE) - - if crc != have_crc: - if hmac_key: - logger.debug( - "HMAC checksum wrong! %r != %r", - binascii.hexlify(have_crc), - binascii.hexlify(crc), - ) - else: - logger.debug("CRC wrong! %08X != %08X", have_crc, crc) - - return TuyaMessage( - header.seqno, header.cmd, retcode, payload[:-end_len], crc, crc == have_crc - ) - - -def parse_header(data): - """Unpack bytes into a TuyaHeader.""" - header_len = struct.calcsize(MESSAGE_HEADER_FMT) - - if len(data) < header_len: - raise DecodeError("Not enough data to unpack header") - - prefix, seqno, cmd, payload_len = struct.unpack( - MESSAGE_HEADER_FMT, data[:header_len] - ) - - if prefix != PREFIX_VALUE: - # self.debug('Header prefix wrong! %08X != %08X', prefix, PREFIX_VALUE) - raise DecodeError("Header prefix wrong! %08X != %08X" % (prefix, PREFIX_VALUE)) - - # sanity check. currently the max payload length is somewhere around 300 bytes - if payload_len > 1000: - raise DecodeError( - "Header claims the packet size is over 1000 bytes! It is most likely corrupt. Claimed size: %d bytes" - % payload_len - ) - - return TuyaHeader(prefix, seqno, cmd, payload_len) - - -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, pad=True): - """Encrypt data to be sent to device.""" - encryptor = self.cipher.encryptor() - if pad: - raw = self._pad(raw) - crypted_text = encryptor.update(raw) + encryptor.finalize() - return base64.b64encode(crypted_text) if use_base64 else crypted_text - - def decrypt(self, enc, use_base64=True, decode_text=True): - """Decrypt data from device.""" - if use_base64: - enc = base64.b64decode(enc) - - decryptor = self.cipher.decryptor() - raw = self._unpad(decryptor.update(enc) + decryptor.finalize()) - return raw.decode("utf-8") if decode_text else raw - - 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 on protocols < 3.3 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 - RESET_SEQNO = -101 - SESS_KEY_SEQNO = -102 - - def __init__(self, dev_id, listener, protocol_version, local_key, enable_debug): - """Initialize a new MessageBuffer.""" - super().__init__() - self.buffer = b"" - self.listeners = {} - self.listener = listener - self.version = protocol_version - self.local_key = local_key - self.set_logger(_LOGGER, dev_id, enable_debug) - - 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, cmd, 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("Command %d waiting for seq. number %d", cmd, seqno) - self.listeners[seqno] = asyncio.Semaphore(0) - try: - await asyncio.wait_for(self.listeners[seqno].acquire(), timeout=timeout) - except asyncio.TimeoutError: - self.debug( - "Command %d timed out waiting for sequence number %d", cmd, seqno - ) - 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 - - header = parse_header(self.buffer) - hmac_key = self.local_key if self.version == 3.4 else None - msg = unpack_message( - self.buffer, header=header, hmac_key=hmac_key, logger=self - ) - self.buffer = self.buffer[header_len - 4 + header.length :] - self._dispatch(msg) - - def _dispatch(self, msg): - """Dispatch a message to someone that is listening.""" - self.debug("Dispatching message CMD %r %s", msg.cmd, msg) - if msg.seqno in self.listeners: - # self.debug("Dispatching sequence number %d", msg.seqno) - sem = self.listeners[msg.seqno] - if isinstance(sem, asyncio.Semaphore): - self.listeners[msg.seqno] = msg - sem.release() - else: - self.debug("Got additional message without request - skipping: %s", sem) - elif msg.cmd == HEART_BEAT: - 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 == UPDATEDPS: - self.debug("Got normal updatedps response") - if self.RESET_SEQNO in self.listeners: - sem = self.listeners[self.RESET_SEQNO] - self.listeners[self.RESET_SEQNO] = msg - sem.release() - elif msg.cmd == SESS_KEY_NEG_RESP: - self.debug("Got key negotiation response") - if self.SESS_KEY_SEQNO in self.listeners: - sem = self.listeners[self.SESS_KEY_SEQNO] - self.listeners[self.SESS_KEY_SEQNO] = msg - sem.release() - elif msg.cmd == STATUS: - if self.RESET_SEQNO in self.listeners: - self.debug("Got reset status update") - sem = self.listeners[self.RESET_SEQNO] - self.listeners[self.RESET_SEQNO] = msg - sem.release() - else: - self.debug("Got status update") - self.listener(msg) - else: - if msg.cmd == CONTROL_NEW: - self.debug("Got ACK message for command %d: will ignore it", msg.cmd) - 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, enable_debug, 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, enable_debug) - self.id = dev_id - self.local_key = local_key.encode("latin1") - self.real_local_key = self.local_key - self.dev_type = "type_0a" - self.dps_to_request = {} - - if protocol_version: - self.set_version(float(protocol_version)) - else: - # make sure we call our set_version() and not a subclass since some of - # them (such as BulbDevice) make connections when called - TuyaProtocol.set_version(self, 3.1) - - self.cipher = AESCipher(self.local_key) - self.seqno = 1 - self.transport = None - self.listener = weakref.ref(listener) - self.dispatcher = self._setup_dispatcher(enable_debug) - self.on_connected = on_connected - self.heartbeater = None - self.dps_cache = {} - self.local_nonce = b"0123456789abcdef" # not-so-random random key - self.remote_nonce = b"" - - def set_version(self, protocol_version): - """Set the device version and eventually start available DPs detection.""" - self.version = protocol_version - self.version_bytes = str(protocol_version).encode("latin1") - self.version_header = self.version_bytes + PROTOCOL_3x_HEADER - if protocol_version == 3.2: # 3.2 behaves like 3.3 with type_0d - # self.version = 3.3 - self.dev_type = "type_0d" - elif protocol_version == 3.4: - self.dev_type = "v3.4" - - def error_json(self, number=None, payload=None): - """Return error details in JSON.""" - try: - spayload = json.dumps(payload) - # spayload = payload.replace('\"','').replace('\'','') - except Exception: - spayload = '""' - - vals = (error_codes[number], str(number), spayload) - self.debug("ERROR %s - %s - payload: %s", *vals) - - return json.loads('{ "Error":"%s", "Err":"%s", "Payload":%s }' % vals) - - def _setup_dispatcher(self, enable_debug): - def _status_update(msg): - if msg.seqno > 0: - self.seqno = msg.seqno + 1 - 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, self.version, self.local_key, enable_debug - ) - - def connection_made(self, transport): - """Did connect to the device.""" - self.transport = transport - self.on_connected.set_result(True) - - def start_heartbeat(self): - """Start the heartbeat transmissions with 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.heartbeater = self.loop.create_task(heartbeat_loop()) - - def data_received(self, data): - """Received data from device.""" - # self.debug("received data=%r", binascii.hexlify(data)) - self.dispatcher.add_data(data) - - def connection_lost(self, exc): - """Disconnected from device.""" - self.debug("Connection lost: %s", exc) - self.real_local_key = self.local_key - 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") - self.real_local_key = self.local_key - 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_quick(self, payload, recv_retries): - """Similar to exchange() but never retries sending and does not decode the response.""" - if not self.transport: - self.debug( - "[" + self.id + "] send quick failed, could not get socket: %s", payload - ) - return None - enc_payload = ( - self._encode_message(payload) - if isinstance(payload, MessagePayload) - else payload - ) - # self.debug("Quick-dispatching message %s, seqno %s", binascii.hexlify(enc_payload), self.seqno) - - try: - self.transport.write(enc_payload) - except Exception: - # self._check_socket_close(True) - self.close() - return None - while recv_retries: - try: - seqno = MessageDispatcher.SESS_KEY_SEQNO - msg = await self.dispatcher.wait_for(seqno, payload.cmd) - # for 3.4 devices, we get the starting seqno with the SESS_KEY_NEG_RESP message - self.seqno = msg.seqno - except Exception: - msg = None - if msg and len(msg.payload) != 0: - return msg - recv_retries -= 1 - if recv_retries == 0: - self.debug( - "received null payload (%r) but out of recv retries, giving up", msg - ) - else: - self.debug( - "received null payload (%r), fetch new one - %s retries remaining", - msg, - recv_retries, - ) - return None - - async def exchange(self, command, dps=None): - """Send and receive a message, returning response from device.""" - if self.version == 3.4 and self.real_local_key == self.local_key: - self.debug("3.4 device: negotiating a new session key") - await self._negotiate_session_key() - - self.debug( - "Sending command %s (device type: %s)", - command, - self.dev_type, - ) - payload = self._generate_payload(command, dps) - real_cmd = payload.cmd - dev_type = self.dev_type - # self.debug("Exchange: payload %r %r", payload.cmd, payload.payload) - - # Wait for special sequence number if heartbeat or reset - seqno = self.seqno - - if payload.cmd == HEART_BEAT: - seqno = MessageDispatcher.HEARTBEAT_SEQNO - elif payload.cmd == UPDATEDPS: - seqno = MessageDispatcher.RESET_SEQNO - - enc_payload = self._encode_message(payload) - self.transport.write(enc_payload) - msg = await self.dispatcher.wait_for(seqno, payload.cmd) - if msg is None: - self.debug("Wait was aborted for seqno %d", seqno) - return None - - # TODO: Verify stuff, e.g. CRC sequence number? - if real_cmd in [HEART_BEAT, CONTROL, CONTROL_NEW] and len(msg.payload) == 0: - # device may send messages with empty payload in response - # to a HEART_BEAT or CONTROL or CONTROL_NEW command: consider them an ACK - self.debug("ACK received for command %d: ignoring it", real_cmd) - return None - 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(DP_QUERY) - 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(HEART_BEAT) - - async def reset(self, dpIds=None): - """Send a reset message (3.3 only).""" - if self.version == 3.3: - self.dev_type = "type_0a" - self.debug("reset switching to dev_type %s", self.dev_type) - return await self.exchange(UPDATEDPS, dpIds) - - return True - - 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 in [3.2, 3.3, 3.4]: # 3.2 behaves like 3.3 with type_0d - 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) - enc_payload = self._encode_message(payload) - self.transport.write(enc_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(CONTROL, {str(dp_index): value}) - - async def set_dps(self, dps): - """Set values for a set of datapoints.""" - return await self.exchange(CONTROL, 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): - cipher = AESCipher(self.local_key) - - if self.version == 3.4: - # 3.4 devices encrypt the version header in addition to the payload - try: - # self.debug("decrypting=%r", payload) - payload = cipher.decrypt(payload, False, decode_text=False) - except Exception as ex: - self.debug( - "incomplete payload=%r with len:%d (%s)", payload, len(payload), ex - ) - return self.error_json(ERR_PAYLOAD) - - # self.debug("decrypted 3.x payload=%r", payload) - - if payload.startswith(PROTOCOL_VERSION_BYTES_31): - # Received an encrypted payload - # Remove version header - payload = payload[len(PROTOCOL_VERSION_BYTES_31) :] - # Decrypt payload - # Remove 16-bytes of MD5 hexdigest of payload - payload = cipher.decrypt(payload[16:]) - elif self.version >= 3.2: # 3.2 or 3.3 or 3.4 - # Trim header for non-default device type - if payload.startswith(self.version_bytes): - payload = payload[len(self.version_header) :] - # self.debug("removing 3.x=%r", payload) - elif self.dev_type == "type_0d" and (len(payload) & 0x0F) != 0: - payload = payload[len(self.version_header) :] - # self.debug("removing type_0d 3.x header=%r", payload) - - if self.version != 3.4: - try: - # self.debug("decrypting=%r", payload) - payload = cipher.decrypt(payload, False) - except Exception as ex: - self.debug( - "incomplete payload=%r with len:%d (%s)", - payload, - len(payload), - ex, - ) - return self.error_json(ERR_PAYLOAD) - - # self.debug("decrypted 3.x payload=%r", payload) - # Try to detect if type_0d found - - if not isinstance(payload, str): - try: - payload = payload.decode() - except Exception as ex: - self.debug("payload was not string type and decoding failed") - raise DecodeError("payload was not a string: %s" % ex) - # return self.error_json(ERR_JSON, payload) - - if "data unvalid" in payload: - self.dev_type = "type_0d" - self.debug( - "'data unvalid' error detected: switching to dev_type %r", - self.dev_type, - ) - return None - elif not payload.startswith(b"{"): - self.debug("Unexpected payload=%r", payload) - return self.error_json(ERR_PAYLOAD, payload) - - if not isinstance(payload, str): - payload = payload.decode() - self.debug("Deciphered data = %r", payload) - try: - json_payload = json.loads(payload) - except Exception as ex: - raise DecodeError( - "could not decrypt data: wrong local_key? (exception: %s)" % ex - ) - # json_payload = self.error_json(ERR_JSON, payload) - - # v3.4 stuffs it into {"data":{"dps":{"1":true}}, ...} - if ( - "dps" not in json_payload - and "data" in json_payload - and "dps" in json_payload["data"] - ): - json_payload["dps"] = json_payload["data"]["dps"] - - return json_payload - - async def _negotiate_session_key(self): - self.local_key = self.real_local_key - - rkey = await self.exchange_quick( - MessagePayload(SESS_KEY_NEG_START, self.local_nonce), 2 - ) - if not rkey or not isinstance(rkey, TuyaMessage) or len(rkey.payload) < 48: - # error - self.debug("session key negotiation failed on step 1") - return False - - if rkey.cmd != SESS_KEY_NEG_RESP: - self.debug( - "session key negotiation step 2 returned wrong command: %d", rkey.cmd - ) - return False - - payload = rkey.payload - try: - # self.debug("decrypting %r using %r", payload, self.real_local_key) - cipher = AESCipher(self.real_local_key) - payload = cipher.decrypt(payload, False, decode_text=False) - except Exception as ex: - self.debug( - "session key step 2 decrypt failed, payload=%r with len:%d (%s)", - payload, - len(payload), - ex, - ) - return False - - self.debug("decrypted session key negotiation step 2: payload=%r", payload) - - if len(payload) < 48: - self.debug("session key negotiation step 2 failed, too short response") - return False - - self.remote_nonce = payload[:16] - hmac_check = hmac.new(self.local_key, self.local_nonce, sha256).digest() - - if hmac_check != payload[16:48]: - self.debug( - "session key negotiation step 2 failed HMAC check! wanted=%r but got=%r", - binascii.hexlify(hmac_check), - binascii.hexlify(payload[16:48]), - ) - - # self.debug("session local nonce: %r remote nonce: %r", self.local_nonce, self.remote_nonce) - rkey_hmac = hmac.new(self.local_key, self.remote_nonce, sha256).digest() - await self.exchange_quick(MessagePayload(SESS_KEY_NEG_FINISH, rkey_hmac), None) - - self.local_key = bytes( - [a ^ b for (a, b) in zip(self.local_nonce, self.remote_nonce)] - ) - # self.debug("Session nonce XOR'd: %r" % self.local_key) - - cipher = AESCipher(self.real_local_key) - self.local_key = self.dispatcher.local_key = cipher.encrypt( - self.local_key, False, pad=False - ) - self.debug("Session key negotiate success! session key: %r", self.local_key) - return True - - # adds protocol header (if needed) and encrypts - def _encode_message(self, msg): - hmac_key = None - payload = msg.payload - self.cipher = AESCipher(self.local_key) - if self.version == 3.4: - hmac_key = self.local_key - if msg.cmd not in NO_PROTOCOL_HEADER_CMDS: - # add the 3.x header - payload = self.version_header + payload - self.debug("final payload for cmd %r: %r", msg.cmd, payload) - payload = self.cipher.encrypt(payload, False) - elif self.version >= 3.2: - # expect to connect and then disconnect to set new - payload = self.cipher.encrypt(payload, False) - if msg.cmd not in NO_PROTOCOL_HEADER_CMDS: - # add the 3.x header - payload = self.version_header + payload - elif msg.cmd == CONTROL: - # need to encrypt - payload = self.cipher.encrypt(payload) - preMd5String = ( - b"data=" - + payload - + b"||lpv=" - + PROTOCOL_VERSION_BYTES_31 - + b"||" - + self.local_key - ) - m = md5() - m.update(preMd5String) - hexdigest = m.hexdigest() - # some tuya libraries strip 8: to :24 - payload = ( - PROTOCOL_VERSION_BYTES_31 - + hexdigest[8:][:16].encode("latin1") - + payload - ) - - self.cipher = None - msg = TuyaMessage(self.seqno, msg.cmd, 0, payload, 0, True) - self.seqno += 1 # increase message sequence number - buffer = pack_message(msg, hmac_key=hmac_key) - # self.debug("payload encrypted with key %r => %r", self.local_key, binascii.hexlify(buffer)) - return buffer - - def _generate_payload(self, command, data=None, gwId=None, devId=None, uid=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 - gwId(str, optional): Will be used for gwId - devId(str, optional): Will be used for devId - uid(str, optional): Will be used for uid - """ - json_data = command_override = None - - if command in payload_dict[self.dev_type]: - if "command" in payload_dict[self.dev_type][command]: - json_data = payload_dict[self.dev_type][command]["command"] - if "command_override" in payload_dict[self.dev_type][command]: - command_override = payload_dict[self.dev_type][command][ - "command_override" - ] - - if self.dev_type != "type_0a": - if ( - json_data is None - and command in payload_dict["type_0a"] - and "command" in payload_dict["type_0a"][command] - ): - json_data = payload_dict["type_0a"][command]["command"] - if ( - command_override is None - and command in payload_dict["type_0a"] - and "command_override" in payload_dict["type_0a"][command] - ): - command_override = payload_dict["type_0a"][command]["command_override"] - - if command_override is None: - command_override = command - if json_data is None: - # I have yet to see a device complain about included but unneeded attribs, but they *will* - # complain about missing attribs, so just include them all unless otherwise specified - json_data = {"gwId": "", "devId": "", "uid": "", "t": ""} - - if "gwId" in json_data: - if gwId is not None: - json_data["gwId"] = gwId - else: - json_data["gwId"] = self.id - if "devId" in json_data: - if devId is not None: - json_data["devId"] = devId - else: - json_data["devId"] = self.id - if "uid" in json_data: - if uid is not None: - json_data["uid"] = uid - else: - json_data["uid"] = self.id - if "t" in json_data: - if json_data["t"] == "int": - json_data["t"] = int(time.time()) - else: - json_data["t"] = str(int(time.time())) - - if data is not None: - if "dpId" in json_data: - json_data["dpId"] = data - elif "data" in json_data: - json_data["data"] = {"dps": data} - else: - json_data["dps"] = data - elif self.dev_type == "type_0d" and command == DP_QUERY: - json_data["dps"] = self.dps_to_request - - if json_data == "": - payload = "" - else: - payload = json.dumps(json_data) - # if spaces are not removed device does not respond! - payload = payload.replace(" ", "").encode("utf-8") - self.debug("Sending payload: %s", payload) - - return MessagePayload(command_override, payload) - - def __repr__(self): - """Return internal string representation of object.""" - return self.id - - -async def connect( - address, - device_id, - local_key, - protocol_version, - enable_debug, - 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, - enable_debug, - on_connected, - listener or EmptyListener(), - ), - address, - port, - ) - - await asyncio.wait_for(on_connected, timeout=timeout) - return protocol diff --git a/custom_components/localtuya/pytuya/__pycache__/__init__.cpython-312.pyc b/custom_components/localtuya/pytuya/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index c33bda90..00000000 Binary files a/custom_components/localtuya/pytuya/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/localtuya/pytuya/__pycache__/__init__.cpython-313.pyc b/custom_components/localtuya/pytuya/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 0ea2cf3b..00000000 Binary files a/custom_components/localtuya/pytuya/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/custom_components/localtuya/remote.py b/custom_components/localtuya/remote.py new file mode 100644 index 00000000..0aa177e0 --- /dev/null +++ b/custom_components/localtuya/remote.py @@ -0,0 +1,364 @@ +"""Platform to present any Tuya DP as a remote.""" + +import asyncio +import json +import base64 +import logging +from functools import partial +import struct +from enum import StrEnum +from typing import Any, Iterable +from .config_flow import col_to_select + +import voluptuous as vol +from homeassistant.components.remote import ( + ATTR_ACTIVITY, + ATTR_COMMAND, + ATTR_COMMAND_TYPE, + ATTR_NUM_REPEATS, + ATTR_DELAY_SECS, + ATTR_DEVICE, + ATTR_TIMEOUT, + DOMAIN, + RemoteEntity, + RemoteEntityFeature, +) +from homeassistant.components import persistent_notification +from homeassistant.const import CONF_DEVICE_ID, STATE_OFF +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.storage import Store + +from .entity import LocalTuyaEntity, async_setup_entry +from .const import CONF_RECEIVE_DP, CONF_KEY_STUDY_DP + +NSDP_CONTROL = "control" # The control commands +NSDP_TYPE = "type" # The identifier of an IR library +NSDP_HEAD = "head" # Actually used but not documented +NSDP_KEY1 = "key1" # Actually used but not documented + +_LOGGER = logging.getLogger(__name__) + + +class ControlType(StrEnum): + ENUM = "Enum" + JSON = "Json" + + +class ControlMode(StrEnum): + SEND_IR = "send_ir" + STUDY = "study" + STUDY_EXIT = "study_exit" + STUDY_KEY = "study_key" + + +class RemoteDP(StrEnum): + DP_SEND = "201" + DP_RECIEVE = "202" + + +CODE_STORAGE_VERSION = 1 +SOTRAGE_KEY = "localtuya_remotes_codes" + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_RECEIVE_DP, default=RemoteDP.DP_RECIEVE.value): col_to_select( + dps, is_dps=True + ), + vol.Optional(CONF_KEY_STUDY_DP): col_to_select(dps, is_dps=True), + } + + +class LocalTuyaRemote(LocalTuyaEntity, RemoteEntity): + """Representation of a Tuya remote.""" + + def __init__( + self, + device, + config_entry, + remoteid, + **kwargs, + ): + """Initialize the Tuya remote.""" + super().__init__(device, config_entry, remoteid, _LOGGER, **kwargs) + + self._dp_send = str(self._config.get(self._dp_id, RemoteDP.DP_SEND)) + self._dp_recieve = str(self._config.get(CONF_RECEIVE_DP, RemoteDP.DP_RECIEVE)) + self._dp_key_study = self._config.get(CONF_KEY_STUDY_DP) + + self._device_id = self._device_config.id + self._lock = asyncio.Lock() + + # self._attr_activity_list: list = [] + # self._attr_current_activity: str | None = None + + self._last_code = None + + self._codes = {} # Contains only device commands. + self._global_codes = {} # contains all devices commands. + + self._codes_storage = Store(self._hass, CODE_STORAGE_VERSION, SOTRAGE_KEY) + + self._storage_loaded = False + + self._attr_supported_features = ( + RemoteEntityFeature.LEARN_COMMAND | RemoteEntityFeature.DELETE_COMMAND + ) + + @property + def _ir_control_type(self): + if self.has_config(CONF_KEY_STUDY_DP): + return ControlType.ENUM + else: + return ControlType.JSON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the remote.""" + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the remote.""" + self._attr_is_on = False + self.async_write_ha_state() + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send commands to a device.""" + if not self._attr_is_on: + raise ServiceValidationError(f"Remote {self.entity_id} is turned off") + + commands = command + device = kwargs.get(ATTR_DEVICE) + + repeats: int = kwargs.get(ATTR_NUM_REPEATS) + repeats_delay: float = kwargs.get(ATTR_DELAY_SECS) + + for req in [device, commands]: + if not req: + raise ServiceValidationError("Missing required fields") + + if not self._storage_loaded: + await self._async_load_storage() + + # base64_code = "" + # if base64_code is None: + # option_value = "" + # _LOGGER.debug("Sending Option: -> " + option_value) + + # pulses = self.pronto_to_pulses(option_value) + # base64_code = "1" + self.pulses_to_base64(pulses) + for command in commands: + code = self._get_code(device, command) + + base64_code = "1" + code + if repeats: + current_repeat = 0 + while current_repeat < repeats: + await self.send_signal(ControlMode.SEND_IR, base64_code) + if repeats_delay: + await asyncio.sleep(repeats_delay) + current_repeat += 1 + continue + + await self.send_signal(ControlMode.SEND_IR, base64_code) + + async def async_learn_command(self, **kwargs: Any) -> None: + """Learn a command from a device.""" + if not self._attr_is_on: + raise ServiceValidationError(f"Remote {self.entity_id} is turned off") + + now, timeout = 0, kwargs.get(ATTR_TIMEOUT, 30) + sucess = False + + device = kwargs.get(ATTR_DEVICE) + commands = kwargs.get(ATTR_COMMAND) + # command_type = kwargs.get(ATTR_COMMAND_TYPE) + for req in [device, commands]: + if not req: + raise ServiceValidationError("Missing required fields") + + if not self._storage_loaded: + await self._async_load_storage() + + if self._lock.locked(): + return self.debug("The device is already in learning mode.") + + async with self._lock: + for command in commands: + last_code = self._last_code + await self.send_signal(ControlMode.STUDY) + persistent_notification.async_create( + self.hass, + f"Press the '{command}' button.", + title="Learn command", + notification_id="learn_command", + ) + + try: + self.debug(f"Waiting for code from DP: {self._dp_recieve}") + while now < timeout: + if ( + last_code != (dp_code := self.dp_value(self._dp_recieve)) + and dp_code is not None + ): + self._last_code = dp_code + sucess = True + await self.send_signal(ControlMode.STUDY_EXIT) + break + + now += 1 + await asyncio.sleep(1) + + if not sucess: + await self.send_signal(ControlMode.STUDY_EXIT) + raise ServiceValidationError(f"Failed to learn: {command}") + + finally: + persistent_notification.async_dismiss( + self.hass, notification_id="learn_command" + ) + + # code retrive sucess and it's sotred in self._last_code + # we will store the codes. + await self._save_new_command(device, command, self._last_code) + + if command != commands[-1]: + await asyncio.sleep(1) + + async def async_delete_command(self, **kwargs: Any) -> None: + """Delete commands from the database.""" + device = kwargs.get(ATTR_DEVICE) + commands = kwargs.get(ATTR_COMMAND) + + for req in [device, commands]: + if not req: + raise ServiceValidationError("Missing required fields") + + if not self._storage_loaded: + await self._async_load_storage() + + for command in commands: + await self._delete_command(device, command) + + async def send_signal(self, control, base64_code=None): + if self._ir_control_type == ControlType.ENUM: + command = {self._dp_id: control} + if control == ControlMode.SEND_IR: + command[self._dp_id] = ControlMode.STUDY_KEY.value + command[self._dp_key_study] = base64_code + command["13"] = 0 + else: + command = {NSDP_CONTROL: control} + if control == ControlMode.SEND_IR: + command[NSDP_TYPE] = 0 + command[NSDP_HEAD] = "" # also known as ir_code + command[NSDP_KEY1] = base64_code # also code: key_code + command = {self._dp_id: json.dumps(command)} + + self.debug(f"Sending IR Command: {command}") + await self._device.set_dps(command) + + async def _delete_command(self, device, command) -> None: + """Store new code into stoarge.""" + codes_data = self._codes + ir_controller = self._device_id + devices_data = self._global_codes + + if ir_controller in codes_data: + devices_data = codes_data[ir_controller] + + if device not in devices_data: + raise ServiceValidationError( + f"Couldn't find the device: {device} available devices is on this IR Remote is: {list(devices_data)}." + ) + + commands = devices_data[device] + if command not in commands: + raise ServiceValidationError( + f"Couldn't find the command {command} for in {device} device. the available commands for this device is: {list(commands)}" + ) + + # For now this only works if the command is in the list of commands of this device. + devices_data[device].pop(command) + if device in self._global_codes: + self._global_codes.pop(device) + await self._codes_storage.async_save(codes_data) + + async def _save_new_command(self, device, command, code) -> None: + """Store new code into stoarge.""" + device_unqiue_id = self._device_id + codes = self._codes + + if device_unqiue_id not in codes: + codes[device_unqiue_id] = {} + + # device_data = {command: {ATTR_COMMAND: code, ATTR_COMMAND_TYPE: command_type}} + device_data = {command: code} + + if device in codes[device_unqiue_id]: + codes[device_unqiue_id][device].update(device_data) + else: + codes[device_unqiue_id][device] = device_data + + self._global_codes[device] = device_data + await self._codes_storage.async_save(codes) + + async def _async_load_storage(self): + """Load code and flag storage from disk.""" + # Exception is intentionally not trapped to + # provide feedback if something fails. + # await self._codes_storage._async_migrate_func(1, 1, self._codes) + self._codes.update(await self._codes_storage.async_load() or {}) + + if self._codes: + for dev in self._codes.keys(): + self._global_codes.update(self._codes[dev]) + + self._storage_loaded = True + + # No need to restore state for a remote + async def restore_state_when_connected(self): + """Do nothing for a remote.""" + return + + def _get_code(self, device, command): + """Get the code of command from database.""" + codes_data = self._codes + ir_controller = self._device_id + devices_data = self._global_codes + + if ir_controller in codes_data: + devices_data = codes_data[ir_controller] + + if device not in devices_data: + raise ServiceValidationError( + f"Couldn't find the device: {device} available devices is on this IR Remote is: {list(devices_data)}." + ) + + commands = devices_data[device] + if command not in commands: + raise ServiceValidationError( + f"Couldn't find the command {command} for in {device} device. the available commands for this device is: {list(commands)}" + ) + + command = devices_data[device][command] + + return command + + async def _async_migrate_func(self, old_major_version, old_minor_version, old_data): + """Migrate to the new version.""" + raise NotImplementedError + + def status_updated(self): + """Device status was updated.""" + state = self.dp_value(self._dp_id) + + def status_restored(self, stored_state: State) -> None: + """Device status was restored..""" + state = stored_state + self._attr_is_on = state is None or state.state != STATE_OFF + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaRemote, flow_schema) diff --git a/custom_components/localtuya/select.py b/custom_components/localtuya/select.py index c9b1d1c6..44997156 100644 --- a/custom_components/localtuya/select.py +++ b/custom_components/localtuya/select.py @@ -1,16 +1,17 @@ """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 homeassistant.helpers import selector -from .common import LocalTuyaEntity, async_setup_entry +from .entity import LocalTuyaEntity, async_setup_entry from .const import ( CONF_DEFAULT_VALUE, CONF_OPTIONS, - CONF_OPTIONS_FRIENDLY, CONF_PASSIVE_ENTITY, CONF_RESTORE_ON_RECONNECT, ) @@ -19,8 +20,7 @@ def flow_schema(dps): """Return schema used in config flow.""" return { - vol.Required(CONF_OPTIONS): str, - vol.Optional(CONF_OPTIONS_FRIENDLY): str, + vol.Required(CONF_OPTIONS, default={}): selector.ObjectSelector(), vol.Required(CONF_RESTORE_ON_RECONNECT): bool, vol.Required(CONF_PASSIVE_ENTITY): bool, vol.Optional(CONF_DEFAULT_VALUE): str, @@ -30,7 +30,7 @@ def flow_schema(dps): _LOGGER = logging.getLogger(__name__) -class LocaltuyaSelect(LocalTuyaEntity, SelectEntity): +class LocalTuyaSelect(LocalTuyaEntity, SelectEntity): """Representation of a Tuya Enumeration.""" def __init__( @@ -44,38 +44,30 @@ def __init__( 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 + options_values, options_display_name = [], [] + config_options: dict = self._config.get(CONF_OPTIONS) + if not isinstance(config_options, dict): + # Warn the user in-case he used the wrong format. + self.error( + f"{self.name} DPiD: {self._dp_id}: Options configured incorrectly! It must be in the format of key-value pairs, where each line follows the structure [device_value: friendly name]" + ) + config_options = {} + for k, v in config_options.items(): + options_values.append(k) + options_display_name.append(v if v else k.replace("_", "").capitalize()) + + self._valid_options = options_values + self._display_options = options_display_name + + _LOGGER.debug("Display Options Configured: %s", options_display_name) _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: @@ -102,7 +94,7 @@ def status_updated(self): """Device status was updated.""" super().status_updated() - state = self.dps(self._dp_id) + state = self.dp_value(self._dp_id) # Check that received status update for this entity. if state is not None: @@ -120,4 +112,4 @@ def entity_default_value(self): return self._valid_options[0] -async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSelect, flow_schema) +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSelect, flow_schema) diff --git a/custom_components/localtuya/sensor.py b/custom_components/localtuya/sensor.py index 0eb0ae4e..1b650e93 100644 --- a/custom_components/localtuya/sensor.py +++ b/custom_components/localtuya/sensor.py @@ -1,17 +1,25 @@ """Platform to present any Tuya DP as a sensor.""" + import logging from functools import partial +from .config_flow import col_to_select import voluptuous as vol -from homeassistant.components.sensor import DEVICE_CLASSES, DOMAIN +from homeassistant.components.sensor import ( + DEVICE_CLASSES_SCHEMA, + DOMAIN, + STATE_CLASSES_SCHEMA, + SensorStateClass, + SensorEntity, +) 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 +from .entity import LocalTuyaEntity, async_setup_entry +from .const import CONF_SCALING, CONF_STATE_CLASS _LOGGER = logging.getLogger(__name__) @@ -22,14 +30,17 @@ 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_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): col_to_select( + [sc.value for sc in SensorStateClass] + ), vol.Optional(CONF_SCALING): vol.All( vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0) ), } -class LocaltuyaSensor(LocalTuyaEntity): +class LocalTuyaSensor(LocalTuyaEntity, SensorEntity): """Representation of a Tuya sensor.""" def __init__( @@ -41,10 +52,10 @@ def __init__( ): """Initialize the Tuya sensor.""" super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) - self._state = STATE_UNKNOWN + self._state = None @property - def state(self): + def native_value(self): """Return sensor state.""" return self._state @@ -54,17 +65,20 @@ def device_class(self): return self._config.get(CONF_DEVICE_CLASS) @property - def unit_of_measurement(self): + def state_class(self) -> str | None: + """Return state class.""" + return self._config.get(CONF_STATE_CLASS) + + @property + def native_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 + state = self.dp_value(self._dp_id) + + self._state = self.scale(state) # No need to restore state for a sensor async def restore_state_when_connected(self): @@ -72,4 +86,4 @@ async def restore_state_when_connected(self): return -async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSensor, flow_schema) +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSensor, flow_schema) diff --git a/custom_components/localtuya/services.yaml b/custom_components/localtuya/services.yaml index f10af4a6..c62353d8 100644 --- a/custom_components/localtuya/services.yaml +++ b/custom_components/localtuya/services.yaml @@ -1,15 +1,30 @@ reload: + name: "Reload" description: Reload localtuya and reconnect to all devices. set_dp: + name: "Set DP Value" description: Change the value of a datapoint (DP) fields: device_id: + name: "Device ID" description: Device ID of device to change datapoint value for + required: true example: 11100118278aab4de001 + selector: + text: dp: - description: Datapoint index + name: "DP" + description: Target DP, Datapoint index + required: false example: 1 + selector: + number: + mode: box value: - description: New value to set - example: False + name: "Value" + description: "New value to set or list of dp: value, If value is list target dp will be ignored" + required: true + example: '{ "1": True, "2": True }' + selector: + object: diff --git a/custom_components/localtuya/siren.py b/custom_components/localtuya/siren.py new file mode 100644 index 00000000..d96d0b52 --- /dev/null +++ b/custom_components/localtuya/siren.py @@ -0,0 +1,70 @@ +"""Platform to present any Tuya DP as a siren.""" + +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.siren import DOMAIN, SirenEntity, SirenEntityFeature + +from .entity import LocalTuyaEntity, async_setup_entry +from .const import CONF_STATE_ON + +_LOGGER = logging.getLogger(__name__) + +# CONF_STATE_MAP = ["True and False", "ON and 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, + } + + +class LocalTuyaSiren(LocalTuyaEntity, SirenEntity): + """Representation of a Tuya siren.""" + + _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF + + def __init__( + self, + device, + config_entry, + sirenid, + **kwargs, + ): + """Initialize the Tuya siren.""" + super().__init__(device, config_entry, sirenid, _LOGGER, **kwargs) + self._is_on = False + + @property + def is_on(self): + """Return siren state.""" + return self._is_on + + async def async_turn_on(self, **kwargs): + """Turn Tuya siren on.""" + await self._device.set_dp(True, self._dp_id) + + async def async_turn_off(self, **kwargs): + """Turn Tuya siren off.""" + await self._device.set_dp(False, self._dp_id) + + # No need to restore state for a siren + async def restore_state_when_connected(self): + """Do nothing for a siren.""" + return + + def status_updated(self): + """Device status was updated.""" + super().status_updated() + + state = str(self.dp_value(self._dp_id)).lower() + if state == self._config[CONF_STATE_ON].lower() or state == "true": + self._is_on = True + else: + self._is_on = False + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSiren, flow_schema) diff --git a/custom_components/localtuya/strings.json b/custom_components/localtuya/strings.json index 32f60400..db2e3561 100644 --- a/custom_components/localtuya/strings.json +++ b/custom_components/localtuya/strings.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Device has already been configured.", + "already_configured": "This Account ID has already been configured.", "unsupported_device_type": "Unsupported device type!" }, "error": { @@ -23,7 +23,7 @@ }, "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.", + "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", @@ -96,12 +96,14 @@ "color_temp_max_kelvin": "Maximum Color Temperature in K", "music_mode": "Music mode available", "scene": "Scene", + "scene_values": "Scene values, separate entries by a ;", + "scene_values_friendly": "User friendly scene values, separate entries by a ;", "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":"fan direction dps", "fan_direction_forward": "forward dps string", "fan_direction_reverse": "reverse dps string", "fan_dps_type": "DP value type", @@ -136,4 +138,4 @@ } }, "title": "LocalTuya" -} \ No newline at end of file +} diff --git a/custom_components/localtuya/switch.py b/custom_components/localtuya/switch.py index 3776836e..7cc7ad3e 100644 --- a/custom_components/localtuya/switch.py +++ b/custom_components/localtuya/switch.py @@ -1,11 +1,19 @@ """Platform to locally control Tuya-based switch devices.""" + import logging from functools import partial +from .config_flow import col_to_select import voluptuous as vol -from homeassistant.components.switch import DOMAIN, SwitchEntity +from homeassistant.components.switch import ( + DOMAIN, + SwitchEntity, + DEVICE_CLASSES_SCHEMA, + SwitchDeviceClass, +) +from homeassistant.const import CONF_DEVICE_CLASS -from .common import LocalTuyaEntity, async_setup_entry +from .entity import LocalTuyaEntity, async_setup_entry from .const import ( ATTR_CURRENT, ATTR_CURRENT_CONSUMPTION, @@ -25,18 +33,23 @@ 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), + vol.Optional(CONF_CURRENT): col_to_select(dps, is_dps=True), + vol.Optional(CONF_CURRENT_CONSUMPTION): col_to_select(dps, is_dps=True), + vol.Optional(CONF_VOLTAGE): col_to_select(dps, is_dps=True), vol.Required(CONF_RESTORE_ON_RECONNECT): bool, vol.Required(CONF_PASSIVE_ENTITY): bool, vol.Optional(CONF_DEFAULT_VALUE): str, + vol.Optional(CONF_DEVICE_CLASS): col_to_select( + [sc.value for sc in SwitchDeviceClass] + ), } -class LocaltuyaSwitch(LocalTuyaEntity, SwitchEntity): +class LocalTuyaSwitch(LocalTuyaEntity, SwitchEntity): """Representation of a Tuya switch.""" + _attr_device_class = SwitchDeviceClass.SWITCH + def __init__( self, device, @@ -47,7 +60,6 @@ def __init__( """Initialize the Tuya switch.""" super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) self._state = None - _LOGGER.debug("Initialized switch [%s]", self.name) @property def is_on(self): @@ -59,13 +71,13 @@ 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]) + attrs[ATTR_CURRENT] = self.dp_value(self._config[CONF_CURRENT]) if self.has_config(CONF_CURRENT_CONSUMPTION): - attrs[ATTR_CURRENT_CONSUMPTION] = ( - self.dps(self._config[CONF_CURRENT_CONSUMPTION]) / 10 - ) + val_cc = self.dp_value(self._config[CONF_CURRENT_CONSUMPTION]) + attrs[ATTR_CURRENT_CONSUMPTION] = None if val_cc is None else val_cc / 10 if self.has_config(CONF_VOLTAGE): - attrs[ATTR_VOLTAGE] = self.dps(self._config[CONF_VOLTAGE]) / 10 + val_vol = self.dp_value(self._config[CONF_VOLTAGE]) + attrs[ATTR_VOLTAGE] = None if val_vol is None else val_vol / 10 # Store the state if self._state is not None: @@ -88,4 +100,4 @@ def entity_default_value(self): return False -async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSwitch, flow_schema) +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSwitch, flow_schema) diff --git a/custom_components/localtuya/templates/__init__.py b/custom_components/localtuya/templates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/custom_components/localtuya/templates/__pycache__/__init__.cpython-313.pyc b/custom_components/localtuya/templates/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..8ba502b9 Binary files /dev/null and b/custom_components/localtuya/templates/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/localtuya/templates/sample_2g_switch.yaml b/custom_components/localtuya/templates/sample_2g_switch.yaml new file mode 100644 index 00000000..c808c5b9 --- /dev/null +++ b/custom_components/localtuya/templates/sample_2g_switch.yaml @@ -0,0 +1,43 @@ +# Simple 2 Switches config example +- switch: + id: "1" + friendly_name: "2G Local Switch 1" + entity_category: None + restore_on_reconnect: false + is_passive_entity: false + platform: "switch" + +- switch: + id: "2" + friendly_name: "2G Local Switch 2" + entity_category: None + is_passive_entity: false + platform: "switch" +#################################################### +#---# Templates Guide #---# +#################################################### +# Templates: +# The template is basically ready to go configs can be imported instead of choosing configs DPs names etc... + +# IMPORTANT: +# there is now valid check atm config so make sure you're importing correct configs. + +# the configs depends on the platform and what input does platform support read bottom. + +# THERE Is 2 ways to make template: +# - 1st is write the yaml ur self: +# --[ Keep in mind there is no valid check atm ] + +# - 2st is to export ur device file from config flow. [ Recommended ]: +# -- in HA Dashboard go to [ Devices -> localtuya -> Configure -> Edit Device * choose the device u want to export +# --- Export the device config then submit] + +# Templates DIR: +# the configs will be exported in [custom_components/localtuya/templates] + +# How to import: +# -- When u add new device when the form [ Pick Entity type selection ] +# --- Import template Form will show up showing avaliable templates in templates folder. + +# -- templates in [custom_components/localtuya/templates] +# -- Templates files will load up with HA so adding files will require restarting HA to show up. diff --git a/custom_components/localtuya/templates/sample_lights_bulb.yaml b/custom_components/localtuya/templates/sample_lights_bulb.yaml new file mode 100644 index 00000000..16f289c8 --- /dev/null +++ b/custom_components/localtuya/templates/sample_lights_bulb.yaml @@ -0,0 +1,16 @@ +- light: + brightness: '22' + brightness_lower: 29 + brightness_upper: 1000 + color: '24' + color_mode: '21' + color_temp: '23' + color_temp_max_kelvin: 6500 + color_temp_min_kelvin: 2700 + color_temp_reverse: false + entity_category: None + friendly_name: test_light_35 + id: '20' + music_mode: true + platform: light + scene: '25' diff --git a/custom_components/localtuya/translations/ar.json b/custom_components/localtuya/translations/ar.json new file mode 100644 index 00000000..cbabc511 --- /dev/null +++ b/custom_components/localtuya/translations/ar.json @@ -0,0 +1,146 @@ +{ + "config": { + "abort": { + "already_configured": "تم تكوين هذا الحساب بالفعل.", + "device_updated": "تم تحديث تكوين الجهاز." + }, + "error": { + "authentication_failed": "فشلت عملية المصادقة.\n{msg}", + "cannot_connect": "لا يمكن الاتصال بالجهاز. تحقق من صحة عنوان IP ثم حاول مرة أخرى.", + "device_list_failed": "فشل استرجاع قائمة الأجهزة.\n{msg}", + "invalid_auth": "فشلت عملية المصادقة مع الجهاز. تأكد من صحة معرّف الجهاز والمفتاح المحلي.", + "unknown": "حدث خطأ غير معروف.\n{ex}.", + "entity_already_configured": "تم تكوين هذه الكيان بالفعل.", + "address_in_use": "منفذ TCP 6668 (المستخدم للاكتشاف) قيد الاستخدام بالفعل. تحقق من عدم استخدام أي تكامل آخر له.", + "discovery_failed": "حدث خطأ عند اكتشاف الأجهزة. انظر إلى السجل للتفاصيل. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية).", + "empty_dps": "نجح الاتصال بالجهاز ولكن لم يتم العثور على نقاط البيانات. يُرجى المحاولة مرة أخرى. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية)." + }, + "step": { + "user": { + "title": "تكوين حساب Cloud API", + "description": "قم بتكوين بيانات الاعتماد المستخدمة للاتصال بـ Tuya Cloud API.", + "data": { + "region": "منطقة مركز البيانات", + "client_id": "معرف العميل (Client ID)", + "client_secret": "المعرف السري العميل (Client Secret)", + "user_id": "معرف المستخدم (UID)", + "username": "اسم المستخدم", + "no_cloud": "هل تريد تعطيل Cloud API؟" + } + } + } + }, + "options": { + "abort": { + "already_configured": "تم تكوين هذا الحساب بالفعل.", + "device_success": "تم {action} الجهاز {dev_name} بنجاح.", + "no_entities": "لا يمكن حذف كل الكيانات من الجهاز.\nإذا كنت ترغب في حذف الجهاز: انتقل إلى القائمة 'الأجهزة والخدمات'، ابحث عن جهازك في علامة التبويب 'الأجهزة'، انقر على 3 نقاط في الإطار 'معلومات الجهاز'، واضغط على زر 'حذف'." + }, + "error": { + "authentication_failed": "فشلت عملية المصادقة.\n{msg}", + "cannot_connect": "لا يمكن الاتصال بالجهاز. تحقق من صحة عنوان IP ثم حاول مرة أخرى.", + "device_list_failed": "فشل استرجاع قائمة الأجهزة.\n{msg}", + "invalid_auth": "فشلت عملية المصادقة مع الجهاز. تأكد من صحة معرّف الجهاز والمفتاح المحلي.", + "unknown": "حدث خطأ غير معروف. \n{ex}.", + "entity_already_configured": "تم تكوين هذه الكيان بالفعل.", + "address_in_use": "منفذ TCP 6668 (المستخدم للاكتشاف) قيد الاستخدام بالفعل. تحقق من عدم استخدام أي تكامل آخر له.", + "discovery_failed": "حدث خطأ عند اكتشاف الأجهزة. انظر إلى السجل للتفاصيل. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية).", + "empty_dps": "نجح الاتصال بالجهاز ولكن لم يتم العثور على نقاط البيانات. يُرجى المحاولة مرة أخرى. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية)." + }, + "step": { + "yaml_import": { + "title": "غير معتمد", + "description": "الأجهزة المكونة باستخدام `YAML` لا يمكن تكوينها في واجهة المستخدم. احذف جهازك من `YAML` وأعِد إنشاؤه في واجهة المستخدم أو قم بتعديل تكوين `YAML` الخاص بك." + }, + "init": { + "title": "التكوين", + "description": "حدد خيارًا للمتابعة.", + "menu_options": { + "add_device": "إضافة جهاز جديد", + "edit_device": "إعادة تكوين الجهاز موجود", + "configure_cloud": "إدارة حساب Cloud API" + } + }, + "add_device": { + "title": "اختيار الجهاز للتكوين", + "description": "يتم اكتشاف الأجهزة المتوافقة مع Tuya على شبكتك المحلية تلقائيًا بمجرد إعدادها في تطبيق Tuya. إذا لم تر الجهاز الذي تتوقعه، اختر `إضافة الجهاز يدويًا` من القائمة المنسدلة.", + "data": { + "selected_device": "الأجهزة المكتشفة", + "mass_configure": "ضبط جميع الأجهزة المتعرف عليها تلقائيًا" + } + }, + "edit_device": { + "title": "إعادة تكوين الجهاز الموجود", + "description": "حدد الجهاز الذي ترغب في إعادة تكوينه.", + "data": { + "selected_device": "الأجهزة المكونة" + } + }, + "configure_cloud": { + "title": "إدارة حساب Cloud API", + "description": "قم بتكوين بيانات الاعتماد المستخدمة للاتصال بـ Tuya Cloud API.", + "data": { + "region": "منطقة مركز البيانات", + "client_id": "معرف العميل (Client ID)", + "client_secret": "المعرف السري العميل (Client Secret)", + "user_id": "معرف المستخدم (UID)", + "username": "اسم للحساب", + "no_cloud": "هل تريد تعطيل Cloud API؟" + } + }, + "configure_device": { + "title": "تكوين اتصال الجهاز", + "description": "قم بتكوين أي تفاصيل جهاز {for_device} فارغة (إن وجدت) للسماح لـ LocalTuya بالاتصال بالجهاز.", + "data": { + "friendly_name": "اسم الجهاز", + "host": "عنوان IP", + "device_id": "معرف الجهاز", + "local_key": "المفتاح المحلي (Local Key)", + "node_id": "(اختياري) معرف الأجهزة الفرعية", + "protocol_version": "إصدار البروتوكول", + "enable_debug": "تمكين التصحيح (يجب تمكينه يدويًا في `configuration.yaml` أيضًا)", + "scan_interval": "(اختياري) الفاصل الزمني للمسح بالثواني، إذا كان الجهاز لا يمسح تلقائيًا", + "entities": "الكيانات التي تم تكوينها (قم بإلغاء التحديد للحذف)", + "add_entities": "إضافة كيان (كيانات) جديدة", + "manual_dps_strings": "(اختياري) دليل DPS، إذا لم يتم اكتشافه تلقائيًا (مفصولاً بفواصل)", + "reset_dpids": "(اختياري) معرفات DPID لإرسالها في أمر RESET، إذا لم يستجب الجهاز لطلبات الحالة بعد التشغيل (مفصولة بفواصل)", + "device_sleep_time": "(اختياري) وقت سبات الجهاز بالثواني: في حالة أن الجهاز يقوم بإرسال الحالة ثم يدخل في وضع السكون", + "export_config": "احفظ تكوين الكيان كقالب" + } + }, + "device_setup_method": { + "title": "تكوين كيانات الجهاز", + "description": "سيحاول LocalTuya اكتشاف بقية التكوين تلقائيًا. ", + "menu_options": { + "auto_configure_device": "اكتشف كيانات الجهاز تلقائيًا", + "pick_entity_type": "قم بتكوين كيانات الجهاز يدويًا", + "choose_template": "استخدم القالب المحفوظ" + } + }, + "auto_configure_device": { + "title": "التكوين التلقائي", + "description": "حدث خطأ: {err_msg}. ", + "menu_options": { + "device_setup_method": "العودة إلى طريقة الإعداد" + } + }, + "pick_entity_type": { + "title": "اختيار نوع الكيان", + "description": "اختر نوع الكيان الذي تريد إضافته.", + "data": { + "platform_to_add": "اختر الكيان", + "no_additional_entities": "الانتهاء من تكوين الكيانات", + "use_template": "استيراد ملف القالب" + } + }, + "choose_template": { + "title": "استيراد ملف القالب", + "description": "توجد ملفات القالب في المجلد `templates` ([لمعلومات أكثر](https://github.com/xZetsubou/hass-localtuya/discussions/13)).", + "data": { + "templates": "اختيار القالب" + } + } +} +}, +"title": "LocalTuya" +} \ No newline at end of file diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index 8fdbb609..ff80b2cc 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -1,129 +1,155 @@ { "config": { "abort": { - "already_configured": "Device has already been configured.", - "device_updated": "Device configuration has been updated!" + "already_configured": "This account has already been configured.", + "device_updated": "Device configuration has been updated." }, "error": { "authentication_failed": "Failed to authenticate.\n{msg}", - "cannot_connect": "Cannot connect to device. Verify that address is correct and try again.", + "cannot_connect": "Cannot connect to device. Confirm the IP Address is correct then try again.", "device_list_failed": "Failed to retrieve device list.\n{msg}", - "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." + "invalid_auth": "Failed to authenticate with device. Confirm the Device Id and Local Key are correct.", + "unknown": "An unknown error occurred.\n{ex}.", + "entity_already_configured": "This entity has already been configured.", + "address_in_use": "TCP port 6668 (used for discovery) is already in use. Check no other integration is using it.", + "discovery_failed": "Something failed when discovering devices. See log for details. If problem persists, create a new issue (including debug logs).", + "empty_dps": "Connection to device succeeded but no datapoints could be found. Please try set-up again. If problem persists, create a new issue (including debug logs)." }, "step": { "user": { "title": "Cloud API account configuration", - "description": "Input the credentials for Tuya Cloud API.", + "description": "Configure the credentials used to connect to the Tuya Cloud API.", "data": { - "region": "API server region", + "region": "Data Center Region", "client_id": "Client ID", - "client_secret": "Secret", + "client_secret": "Client Secret", "user_id": "User ID", - "user_name": "Username", - "no_cloud": "Do not configure a Cloud API account" + "username": "Username", + "no_cloud": "Disable Cloud API?" } } } }, "options": { "abort": { - "already_configured": "Device has already been configured.", + "already_configured": "This account has already been configured.", "device_success": "Device {dev_name} successfully {action}.", - "no_entities": "Cannot remove all entities from a device.\nIf you want to delete a device, enter it in the Devices menu, click the 3 dots in the 'Device info' frame, and press the Delete button." + "no_entities": "Cannot remove all entities from a device.\nIf you want to delete a device: Browse to `Devices & services` menu, search for your device in `Devices` tab, click the 3 dots in the `Device info` frame, and press the `Delete` button." }, "error": { "authentication_failed": "Failed to authenticate.\n{msg}", - "cannot_connect": "Cannot connect to device. Verify that address is correct and try again.", + "cannot_connect": "Cannot connect to device. Confirm the IP Address is correct then try again.", "device_list_failed": "Failed to retrieve device list.\n{msg}", - "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." + "invalid_auth": "Failed to authenticate with device. Confirm the Device Id and Local Key are correct.", + "unknown": "An unknown error occurred. \n{ex}.", + "entity_already_configured": "This entity has already been configured.", + "address_in_use": "TCP port 6668 (used for discovery) is already in use. Check no other integration is using it.", + "discovery_failed": "Something failed when discovering devices. See log for details. If problem persists, create a new issue (including debug logs).", + "empty_dps": "Connection to device succeeded but no datapoints could be found. Please try set-up again. If problem persists, create a new issue (including debug logs)." }, "step": { "yaml_import": { - "title": "Not Supported", - "description": "Options cannot be edited when configured via YAML." + "title": "Not supported", + "description": "Devices configured using `YAML` cannot be configured in the UI. Delete your device from `YAML` and re-create it in the UI or modify your `YAML` configuration." }, "init": { - "title": "LocalTuya Configuration", - "description": "Please select the desired action.", - "data": { - "add_device": "Add a new device", - "edit_device": "Edit a device", - "setup_cloud": "Reconfigure Cloud API account" + "title": "Configuration", + "description": "Select an option to proceed.", + "menu_options": { + "add_device": "Add new device", + "edit_device": "Reconfigure existing device", + "configure_cloud": "Manage Cloud API account" } }, "add_device": { - "title": "Add a new device", - "description": "Pick one of the automatically discovered devices or `...` to manually to add a device.", + "title": "Choose device to configure", + "description": "Compatible Tuya devices on your local network are discovered automatically once they have been set-up in the Tuya app. If you can't see the device you expected, choose `Add device manually` from the dropdown.", "data": { - "selected_device": "Discovered Devices" + "selected_device": "Discovered devices", + "mass_configure": "Configure all recognized devices automatically" } }, "edit_device": { - "title": "Edit a new device", - "description": "Pick the configured device you wish to edit.", + "title": "Reconfigure existing device", + "description": "Select the device you wish to re-configure.", "data": { - "selected_device": "Configured Devices", - "max_temperature_const": "Max Temperature Constant (optional)", - "min_temperature_const": "Min Temperature Constant (optional)", - "hvac_fan_mode_dp": "HVAC Fan Mode DP (optional)", - "hvac_fan_mode_set": "HVAC Fan Mode Set (optional)", - "hvac_swing_mode_dp": "HVAC Swing Mode DP (optional)", - "hvac_swing_mode_set": "HVAC Swing Mode Set (optional)" + "selected_device": "Configured devices" } }, - "cloud_setup": { - "title": "Cloud API account configuration", - "description": "Input the credentials for Tuya Cloud API.", + "configure_cloud": { + "title": "Manage Cloud API account", + "description": "Configure the credentials used to connect to the Tuya Cloud API.", "data": { - "region": "API server region", + "region": "Data Center Region", "client_id": "Client ID", - "client_secret": "Secret", + "client_secret": "Client Secret", "user_id": "User ID", - "user_name": "Username", - "no_cloud": "Do not configure Cloud API account" + "username": "Username", + "no_cloud": "Disable Cloud API?" } }, + "confirm": { + "title": "Confirmation", + "description": "{message}" + }, "configure_device": { - "title": "Configure Tuya device", - "description": "Fill in the device details{for_device}.", + "title": "Configure device connectivity", + "description": "Configure any device details{for_device} that are empty (if any) to allow LocalTuya to connect to the device.", "data": { - "friendly_name": "Name", - "host": "Host", + "friendly_name": "Device Name", + "host": "IP Address", "device_id": "Device ID", - "local_key": "Local key", + "local_key": "Local Key", + "node_id": "(Optional) Sub-devices Node Id", "protocol_version": "Protocol Version", - "enable_debug": "Enable debugging for this device (debug must be enabled also in configuration.yaml)", - "scan_interval": "Scan interval (seconds, only when not updating automatically)", - "entities": "Entities (uncheck an entity to remove it)", - "add_entities": "Add more entities in 'edit device' mode", - "manual_dps_strings": "Manual DPS to add (separated by commas ',') - used when detection is not working (optional)", - "reset_dpids": "DPIDs to send in RESET command (separated by commas ',')- Used when device does not respond to status requests after turning on (optional)" + "enable_debug": "Enable debug (must be manually enabled in `configuration.yaml` too)", + "scan_interval": "(Optional) Scan interval in seconds, if not scanning automatically", + "entities": "Configured entities (uncheck to delete)", + "add_entities": "Add new entity(s)", + "manual_dps_strings": "(Optional) Manual DPS's, if not detected automatically (separated by commas)", + "reset_dpids": "(Optional) DPIDs to send in RESET command, if device does not respond to status requests after turning on (separated by commas)", + "device_sleep_time": "(Optional) Device sleep time in seconds: If the device reports its state, then it goes into sleep", + "export_config": "Save entity configuration as template" + } + }, + "device_setup_method": { + "title": "Configure device entities", + "description": "LocalTuya will try to discover the rest of the configuration automatically. However, if this does not work for your device or you would like to tweak settings, choose the `manual` option.", + "menu_options": { + "auto_configure_device":"Discover device entities automatically", + "pick_entity_type": "Configure device entities manually", + "choose_template":"Use saved template" + } + }, + "auto_configure_device": { + "title": "Auto configure", + "description": "An error occurred: {err_msg}. If reason isn't showing, check logs.", + "menu_options": { + "device_setup_method":"Return to Setup method" } }, "pick_entity_type": { "title": "Entity type selection", - "description": "Please pick the type of entity you want to add.", + "description": "Choose the type of entity you want to add.", + "data": { + "platform_to_add": "Choose entity", + "no_additional_entities": "Finish configuring entities", + "use_template" : "Import template file" + } + }, + "choose_template":{ + "title": "Import template file", + "description": "Template files are located in the `templates` directory ([More Info](https://github.com/xZetsubou/hass-localtuya/discussions/13)).", "data": { - "platform_to_add": "Platform", - "no_additional_entities": "Do not add any more entities" + "templates": "Choose template" } }, "configure_entity": { "title": "Configure entity", - "description": "Please fill out the details for {entity} with type `{platform}`. All settings except for `ID` can be changed from the Options page later.", + "description": "Please fill out the details for {entity} with type {platform}. All settings (except for `Type` and `ID`) can be changed from the `Configure` page later.", "data": { - "id": "ID", - "friendly_name": "Friendly name", + "id": "DP ID", + "friendly_name": "Friendly name for Entity", "current": "Current", "current_consumption": "Current Consumption", "voltage": "Voltage", @@ -131,105 +157,106 @@ "positioning_mode": "Positioning mode", "current_position_dp": "Current Position (for *position* mode only)", "set_position_dp": "Set Position (for *position* mode only)", + "stop_switch_dp": "(Optional) Stop switch (if the cover has continue command?)", "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", + "unit_of_measurement": "(Optional) Unit of Measurement", + "device_class": "(Optional) Device Class", + "state_class": "(Optional) State Class", + "scaling": "(Optional) Scaling Factor", "state_on": "On Value", "state_off": "Off Value", - "powergo_dp": "Power DP (Usually 25 or 2)", + "powergo_dp": "Power DP (usually 25 or 2)", "idle_status_value": "Idle Status (comma-separated)", - "returning_status_value": "Returning Status", + "returning_status_value": "Returning Status (comma-separated)", "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)", + "fault_dp": "Fault DP (usually 11)", + "battery_dp": "Battery status DP (usually 14)", + "mode_dp": "Mode DP", "modes": "Modes list", "return_mode": "Return home mode", - "fan_speed_dp": "Fan speeds DP (Usually 30)", + "fan_speed_dp": "(Optional) Fan speeds DP", "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)", + "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)", + "pause_dp":"Pause DP", "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_temp_reverse": "Reverse Color Temperature?", "color": "Color", - "color_mode": "Color Mode", + "color_mode": "Color Mode aka Work Mode", "color_temp_min_kelvin": "Minimum Color Temperature in K", "color_temp_max_kelvin": "Maximum Color Temperature in K", - "music_mode": "Music mode available", + "music_mode": "Music mode available?", "scene": "Scene", - "select_options": "Valid entries, separate entries by a ;", - "select_options_friendly": "User Friendly options, separate entries by a ;", - "fan_speed_control": "Fan Speed Control dps", - "fan_oscillating_control": "Fan Oscillating Control dps", + "scene_values": "(Optional) Scene values", + "select_options": "Select options values", + "fan_speed_control": "Fan Speed Control DP", + "fan_oscillating_control": "Fan Oscillating Control DP", "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", + "fan_speed_ordered_list": "Fan speed list (overrides speed min/max), separate entries by comma ','", + "fan_direction":"Fan Direction DP", + "fan_direction_forward": "Forward DP string", + "fan_direction_reverse": "Reverse DP string", "fan_dps_type": "DP value type", "current_temperature_dp": "Current Temperature", "target_temperature_dp": "Target Temperature", - "temperature_step": "Temperature Step (optional)", - "max_temperature_dp": "Max Temperature DP (optional)", - "min_temperature_dp": "Min Temperature DP (optional)", - "max_temperature_const": "Max Temperature Constant (optional)", - "min_temperature_const": "Min Temperature Constant (optional)", + "temperature_step": "(Optional) Temperature Step", + "min_temperature": "Min Temperature", + "max_temperature": "Max Temperature", "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_fan_mode_dp": "HVAC Fan Mode DP (optional)", - "hvac_fan_mode_set": "HVAC Fan Mode Set (optional)", - "hvac_swing_mode_dp": "HVAC Swing Mode DP (optional)", - "hvac_swing_mode_set": "HVAC Swing 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)", - "dps_default_value": "Default value when un-initialised (optional)", - "restore_on_reconnect": "Restore the last set value in HomeAssistant after a lost connection", + "target_precision": "Target Precision (optional, for DP values)", + "temperature_unit": "(Optional) Temperature Unit", + "hvac_mode_dp": "(Optional) HVAC Mode DP", + "hvac_mode_set": "(Optional) HVAC Modes", + "hvac_add_off": "(Optional) Include `OFF` in HVAC Modes", + "hvac_action_dp": "(Optional) HVAC Current Action DP", + "hvac_action_set": "(Optional) HVAC Actions", + "preset_dp": "(Optional) Presets DP", + "preset_set": "(Optional) Presets", + "fan_speed_list": "(Optional) Fan supported speeds, separate entries by comma ','", + "eco_dp": "(Optional) Eco DP", + "eco_value": "(Optional) Eco value", + "heuristic_action": "(Optional) Enable heuristic action", + "dps_default_value": "(Optional) Default value when un-initialised", + "restore_on_reconnect": "Restore the last value set in Home Assistant after lost connection?", "min_value": "Minimum Value", "max_value": "Maximum Value", "step_size": "Minimum increment between numbers", - "is_passive_entity": "Passive entity - requires integration to send initialisation value" - } - } - } - }, - "services": { - "reload": { - "name": "Reload", - "description": "Reload localtuya and reconnect to all devices." - }, - "set_dp": { - "name": "Set datapoint", - "description": "Change the value of a datapoint (DP)", - "fields": { - "device_id": { - "name": "Device ID", - "description": "Device ID of device to change datapoint value for" - }, - "dp": { - "name": "DP", - "description": "Datapoint index" + "is_passive_entity": "Passive entity? (requires integration to send initialisation value)", + "entity_category": "Show the entity in this category", + "humidifier_available_modes": "(Optional) Available modes in the device", + "humidifier_current_humidity_dp": "(Optional) Current Humidity DP", + "humidifier_mode_dp": "(Optional) Set mode DP", + "humidifier_set_humidity_dp": "(Optional) Set Humidity DP", + "min_humidity": "Set the minimum supported humidity", + "max_humidity": "Set the maximum supported humidity", + "alarm_supported_states": "States supported by the device", + "receive_dp":"Receiving signals DP. (default is 202)", + "key_study_dp":"(Optional) Key Study DP (usually 7)", + "lock_state_dp":"(Optional) Lock state DP", + "jammed_dp":"(Optional) Jam DP", + "target_temperature_high_dp":"(Optional) Target Temperature High DP", + "target_temperature_low_dp":"(Optional) Target Temperature Low DP", + "color_mode_set":"Supported modes set (Leave as default if you aren't sure)" }, - "value": { - "name": "Value", - "description": "New value to set" + "data_description": { + "hvac_mode_set":"Each line represents [ hvac_mode: device_value ] [Supported HVAC Modes](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", + "hvac_action_set":"Each line represents [ hvac_action: device_value ] [Supported HVAC Actions](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action)", + "preset_set":"Each line represents [ device_value: friendly name ]", + "scene_values":"Each line represents [ device_value: friendly name ]", + "select_options":"Each line represents [ device_value: friendly name ]", + "alarm_supported_states":"Each line represents [ supported state: device value ] [Supported States](https://developers.home-assistant.io/docs/core/entity/alarm-control-panel/#states)", + "humidifier_available_modes":"Each line represents [ device_value: friendly name ]", + "device_class": "Find out more about [Device Classes](https://www.home-assistant.io/integrations/homeassistant/#device-class)", + "state_class": "Find out more about [State Classes](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes)" } } } diff --git a/custom_components/localtuya/translations/it.json b/custom_components/localtuya/translations/it.json index 264bb97f..0addbb05 100644 --- a/custom_components/localtuya/translations/it.json +++ b/custom_components/localtuya/translations/it.json @@ -1,216 +1,255 @@ { "config": { "abort": { - "already_configured": "Il dispositivo è già stato configurato.", + "already_configured": "Questo account è già stato configurato.", "device_updated": "La configurazione del dispositivo è stata aggiornata." }, "error": { - "authentication_failed": "Autenticazione fallita. Errore:\n{msg}", - "cannot_connect": "Impossibile connettersi al dispositivo. Verifica che l'indirizzo sia corretto e riprova.", - "device_list_failed": "Impossibile recuperare l'elenco dei dispositivi.\n{msg}", - "invalid_auth": "Impossibile autenticarsi con il dispositivo. Verificare che device_id e local_key siano corretti.", - "unknown": "Si è verificato un errore sconosciuto. Vedere registro per i dettagli.", - "entity_already_configured": "L'entity con questo ID è già stata configurata.", - "address_in_use": "L'indirizzo utilizzato per il discovery è già in uso. Assicurarsi che nessun'altra applicazione lo stia utilizzando (porta TCP 6668).", - "discovery_failed": "Qualcosa è fallito nella discovery dei dispositivi. Vedi registro per i dettagli.", - "empty_dps": "La connessione al dispositivo è riuscita ma non sono stati trovati i datapoint, riprova. Crea un nuovo Issue e includi i log di debug se il problema persiste." + "authentication_failed": "Autenticazione fallita.\n{msg}", + "cannot_connect": "Impossibile connettersi al dispositivo. Conferma che l'indirizzo IP sia corretto e riprova.", + "device_list_failed": "Recupero elenco dispositivi fallito.\n{msg}", + "invalid_auth": "Autenticazione fallita con il dispositivo. Conferma che l'ID dispositivo e la chiave locale siano corretti.", + "unknown": "Si è verificato un errore sconosciuto.\n{ex}.", + "entity_already_configured": "Questa entità è già stata configurata.", + "address_in_use": "La porta TCP 6668 (usata per la scoperta) è già in uso. Controlla che nessun'altra integrazione la stia utilizzando.", + "discovery_failed": "Qualcosa è andato storto durante la scoperta dei dispositivi. Consulta il registro per i dettagli. Se il problema persiste, crea una nuova segnalazione (includendo i log di debug).", + "empty_dps": "Connessione al dispositivo riuscita ma non è stato possibile trovare datapoint. Si prega di riprovare la configurazione. Se il problema persiste, crea una nuova segnalazione (includendo i log di debug)." }, "step": { "user": { - "title": "Configurazione dell'account Cloud API", - "description": "Inserisci le credenziali per l'account Cloud API Tuya.", + "title": "Configurazione dell'account dell'API cloud", + "description": "Configura le credenziali utilizzate per connettersi all'API Cloud di Tuya.", "data": { - "region": "Regione del server API", - "client_id": "Client ID", - "client_secret": "Secret", - "user_id": "User ID", - "user_name": "Username", - "no_cloud": "Non configurare un account Cloud API" + "region": "Regione del Data Center", + "client_id": "ID client", + "client_secret": "Segreto client", + "user_id": "ID utente", + "username": "Nome utente", + "no_cloud": "Disabilitare l'API Cloud?" } } } }, "options": { "abort": { - "already_configured": "Il dispositivo è già stato configurato.", - "device_success": "Dispositivo {dev_name} {action} con successo.", - "no_entities": "Non si possono rimuovere tutte le entities da un device.\nPer rimuovere un device, entrarci nel menu Devices, premere sui 3 punti nel riquadro 'Device info', e premere il pulsante Delete." + "already_configured": "Questo account è già stato configurato.", + "device_success": "Dispositivo {dev_name} configurato con successo {action}.", + "no_entities": "Impossibile rimuovere tutte le entità da un dispositivo.\nSe desideri eliminare un dispositivo: Vai al menu 'Dispositivi e servizi', cerca il tuo dispositivo nella scheda 'Dispositivi', fai clic sui 3 puntini nel riquadro 'Informazioni sul dispositivo' e premi il pulsante 'Elimina'." }, "error": { - "authentication_failed": "Autenticazione fallita. Errore:\n{msg}", - "cannot_connect": "Impossibile connettersi al dispositivo. Verifica che l'indirizzo sia corretto e riprova.", - "device_list_failed": "Impossibile recuperare l'elenco dei dispositivi.\n{msg}", - "invalid_auth": "Impossibile autenticarsi con il dispositivo. Verificare che device_id e local_key siano corretti.", - "unknown": "Si è verificato un errore sconosciuto. Vedere registro per i dettagli.", - "entity_already_configured": "L'entity con questo ID è già stata configurata.", - "address_in_use": "L'indirizzo utilizzato per il discovery è già in uso. Assicurarsi che nessun'altra applicazione lo stia utilizzando (porta TCP 6668).", - "discovery_failed": "Qualcosa è fallito nella discovery dei dispositivi. Vedi registro per i dettagli.", - "empty_dps": "La connessione al dispositivo è riuscita ma non sono stati trovati i datapoint, riprova. Crea un nuovo Issue e includi i log di debug se il problema persiste." + "authentication_failed": "Autenticazione fallita.\n{msg}", + "cannot_connect": "Impossibile connettersi al dispositivo. Conferma che l'indirizzo IP sia corretto e riprova.", + "device_list_failed": "Recupero elenco dispositivi fallito.\n{msg}", + "invalid_auth": "Autenticazione fallita con il dispositivo. Conferma che l'ID dispositivo e la chiave locale siano corretti.", + "unknown": "Si è verificato un errore sconosciuto. \n{ex}.", + "entity_already_configured": "Questa entità è già stata configurata.", + "address_in_use": "La porta TCP 6668 (usata per la scoperta) è già in uso. Controlla che nessun'altra integrazione la stia utilizzando.", + "discovery_failed": "Qualcosa è andato storto durante la scoperta dei dispositivi. Consulta il registro per i dettagli. Se il problema persiste, crea una nuova segnalazione (includendo i log di debug).", + "empty_dps": "Connessione al dispositivo riuscita ma non è stato possibile trovare datapoint. Si prega di riprovare la configurazione. Se il problema persiste, crea una nuova segnalazione (includendo i log di debug)." }, "step": { "yaml_import": { "title": "Non supportato", - "description": "Le impostazioni non possono essere configurate tramite file YAML." + "description": "I dispositivi configurati utilizzando `YAML` non possono essere configurati nell'interfaccia utente. Elimina il dispositivo da `YAML` e ricrealo nell'interfaccia utente o modifica la tua configurazione `YAML`." }, "init": { - "title": "Configurazione LocalTuya", - "description": "Seleziona l'azione desiderata.", - "data": { - "add_device": "Aggiungi un nuovo dispositivo", - "edit_device": "Modifica un dispositivo", - "setup_cloud": "Riconfigurare l'account Cloud API" + "title": "Configurazione", + "description": "Seleziona un'opzione per procedere.", + "menu_options": { + "add_device": "Aggiungi nuovo dispositivo", + "edit_device": "Riconfigura dispositivo esistente", + "configure_cloud": "Gestisci account dell'API Cloud" } }, "add_device": { - "title": "Aggiungi un nuovo dispositivo", - "description": "Scegli uno dei dispositivi trovati automaticamente o `...` per aggiungere manualmente un dispositivo.", + "title": "Scegli il dispositivo da configurare", + "description": "I dispositivi Tuya compatibili nella tua rete locale vengono scoperti automaticamente una volta configurati nell'app Tuya. Se non vedi il dispositivo previsto, scegli `Add Device Manually` dal menu a discesa.", "data": { "selected_device": "Dispositivi trovati" } }, "edit_device": { - "title": "Modifica un dispositivo", - "description": "Scegli il dispositivo configurato che si desidera modificare.", + "title": "Riconfigura dispositivo esistente", + "description": "Seleziona il dispositivo che desideri riconfigurare.", "data": { "selected_device": "Dispositivi configurati" } }, - "cloud_setup": { - "title": "Configurazione dell'account Cloud API", - "description": "Inserisci le credenziali per l'account Cloud API Tuya.", + "configure_cloud": { + "title": "Gestisci account dell'API Cloud", + "description": "Configura le credenziali utilizzate per connettersi all'API Cloud di Tuya.", "data": { - "region": "Regione del server API", - "client_id": "Client ID", - "client_secret": "Secret", - "user_id": "User ID", - "user_name": "Username", - "no_cloud": "Non configurare l'account Cloud API" + "region": "Regione del Data Center", + "client_id": "ID client", + "client_secret": "Segreto client", + "user_id": "ID utente", + "username": "Nome utente", + "no_cloud": "Disabilitare l'API Cloud?" } }, "configure_device": { - "title": "Configura il dispositivo", - "description": "Compila i dettagli del dispositivo {for_device}.", + "title": "Configura la connettività del dispositivo", + "description": "Configura eventuali dettagli del dispositivo {for_device} vuoti (se presenti) per consentire a LocalTuya di connettersi al dispositivo.", "data": { - "friendly_name": "Nome", - "host": "Host", - "device_id": "ID del dispositivo", + "friendly_name": "Nome dispositivo", + "host": "Indirizzo IP", + "device_id": "ID dispositivo", "local_key": "Chiave locale", + "node_id": "(Opzionale) ID nodo sottodispositivi", "protocol_version": "Versione del protocollo", - "enable_debug": "Abilita il debugging per questo device (il debug va abilitato anche in configuration.yaml)", - "scan_interval": "Intervallo di scansione (secondi, solo quando non si aggiorna automaticamente)", - "entities": "Entities (deseleziona un'entity per rimuoverla)" + "enable_debug": "Abilita debug (deve essere abilitato manualmente anche in `configuration.yaml`)", + "scan_interval": "(Opzionale) Intervallo di scansione in secondi, se la scansione automatica non è attiva", + "entities": "Entità configurate (deseleziona per eliminare)", + "add_entities": "Aggiungi nuove entità", + "manual_dps_strings": "(Opzionale) DPS manuali, se non rilevati automaticamente (separati da virgole)", + "reset_dpids": "(Opzionale) DPID da inviare nel comando RESET, se il dispositivo non risponde alle richieste di stato dopo l'accensione (separati da virgole)", + "device_sleep_time": "(Optional) Device sleep time in seconds: If the device reports its state, then it goes into sleep", + "export_config": "Salva configurazione entità come modello" + } + }, + "device_setup_method": { + "title": "Configura entità del dispositivo", + "description": "LocalTuya cercherà di scoprire automaticamente il resto della configurazione. Tuttavia, se ciò non funziona per il tuo dispositivo o se desideri regolare le impostazioni, scegli l'opzione `manuale`.", + "menu_options": { + "auto_configure_device": "Scopri entità del dispositivo automaticamente", + "pick_entity_type": "Configura manualmente entità del dispositivo", + "choose_template": "Usa modello salvato" + } + }, + "auto_configure_device": { + "title": "Configurazione automatica", + "description": "Si è verificato un errore: {err_msg}. Se il motivo non viene mostrato, controlla i log.", + "menu_options": { + "device_setup_method": "Torna al metodo di configurazione" } }, "pick_entity_type": { - "title": "Selezione del tipo di entity", - "description": "Scegli il tipo di entity che desideri aggiungere.", + "title": "Selezione tipo di entità", + "description": "Scegli il tipo di entità che desideri aggiungere.", "data": { - "platform_to_add": "piattaforma", - "no_additional_entities": "Non aggiungere altre entity" + "platform_to_add": "Scegli entità", + "no_additional_entities": "Termina configurazione entità", + "use_template": "Importa file modello" } }, - "configure_entity": { - "title": "Configurare entity", - "description": "Compila i dettagli per {entity} con tipo `{platform}`.Tutte le impostazioni ad eccezione di `id` possono essere modificate dalla pagina delle opzioni in seguito.", + "choose_template": { + "title": "Importa file modello", + "description": "I file modello si trovano nella directory `templates` ([Maggiori informazioni](https://github.com/xZetsubou/hass-localtuya/discussions/13)).", "data": { - "id": "ID", - "friendly_name": "Nome amichevole", - "current": "Corrente", - "current_consumption": "Potenza", - "voltage": "Tensione", - "commands_set": "Set di comandi Aperto_Chiuso_Stop", - "positioning_mode": "Modalità di posizionamento", - "current_position_dp": "Posizione attuale (solo per la modalità *posizione*)", - "set_position_dp": "Imposta posizione (solo per modalità *posizione*)", - "position_inverted": "Inverti posizione 0-100 (solo per modalità *posizione*)", - "span_time": "Tempo di apertura totale, in sec. (solo per modalità *a tempo*)", - "unit_of_measurement": "Unità di misura", - "device_class": "Classe del dispositivo", - "scaling": "Fattore di scala", - "state_on": "Valore di ON", - "state_off": "Valore di OFF", - "powergo_dp": "Potenza DP (di solito 25 o 2)", - "idle_status_value": "Stato di inattività (separato da virgole)", - "returning_status_value": "Stato di ritorno alla base", - "docked_status_value": "Stato di tornato alla base (separato da virgole)", - "fault_dp": "DP di guasto (di solito 11)", - "battery_dp": "DP di stato batteria (di solito 14)", - "mode_dp": "DP di modalità (di solito 27)", - "modes": "Elenco delle modalità", - "return_mode": "Ritorno in modalità home", - "fan_speed_dp": "DP di velocità del ventilatore (di solito 30)", - "fan_speeds": "DP di elenco delle velocità del ventilatore (separato da virgola)", - "clean_time_dp": "DP di tempo di pulizia (di solito 33)", - "clean_area_dp": "DP di area pulita (di solito 32)", - "clean_record_dp": "DP di record delle pulizie (di solito 34)", - "locate_dp": "DP di individuazione (di solito 31)", - "paused_state": "Stato di pausa (pausa, pausa, ecc.)", - "stop_status": "Stato di stop", - "brightness": "Luminosità (solo per il colore bianco)", - "brightness_lower": "Limite inferiore per la luminosità", - "brightness_upper": "Limite superiore per la luminosità", - "color_temp": "Temperatura di colore", - "color_temp_reverse": "Temperatura di colore invertita", - "color": "Colore", - "color_mode": "Modalità colore", - "color_temp_min_kelvin": "Minima temperatura di colore in K", - "color_temp_max_kelvin": "Massima temperatura di colore in k", - "music_mode": "Modalità musicale disponibile", - "scene": "Scena", - "select_options": "Opzioni valide, voci separate da una vigola (;)", - "select_options_friendly": "Opzioni intuitive, voci separate da una virgola", - "fan_speed_control": "DP di controllo di velocità del ventilatore", - "fan_oscillating_control": "DP di controllo dell'oscillazione del ventilatore", - "fan_speed_min": "Velocità del ventilatore minima", - "fan_speed_max": "Velocità del ventilatore massima", - "fan_speed_ordered_list": "Elenco delle modalità di velocità del ventilatore (sovrascrive velocità min/max)", - "fan_direction":"DP di direzione del ventilatore", - "fan_direction_forward": "Stringa del DP per avanti", - "fan_direction_reverse": "Stringa del DP per indietro", - "current_temperature_dp": "Temperatura attuale", - "target_temperature_dp": "Temperatura target", - "temperature_step": "Intervalli di temperatura (facoltativo)", - "max_temperature_dp": "Temperatura massima (opzionale)", - "min_temperature_dp": "Temperatura minima (opzionale)", - "precision": "Precisione (opzionale, per valori DP)", - "target_precision": "Precisione del target (opzionale, per valori DP)", - "temperature_unit": "Unità di temperatura (opzionale)", - "hvac_mode_dp": "Modalità HVAC attuale (opzionale)", - "hvac_mode_set": "Impostazione modalità HVAC (opzionale)", - "hvac_action_dp": "Azione HVAC attuale (opzionale)", - "hvac_action_set": "Impostazione azione HVAC (opzionale)", - "preset_dp": "Preset DP (opzionale)", - "preset_set": "Set di preset (opzionale)", - "eco_dp": "DP per Eco (opzionale)", - "eco_value": "Valore Eco (opzionale)", - "heuristic_action": "Abilita azione euristica (opzionale)" + "templates": "Scegli modello" } - } - } - }, - "services": { - "reload": { - "name": "Reload", - "description": "Reload localtuya and reconnect to all devices." - }, - "set_dp": { - "name": "Set datapoint", - "description": "Change the value of a datapoint (DP)", - "fields": { - "device_id": { - "name": "Device ID", - "description": "Device ID of device to change datapoint value for" - }, - "dp": { - "name": "DP", - "description": "Datapoint index" + }, + "configure_entity": { + "title": "Configure entity", + "description": "Please fill out the details for {entity} with type {platform}. All settings (except for `Type` and `ID`) can be changed from the `Configure` page later.", + "data": { + "id": "DP ID", + "friendly_name": "Friendly name for Entity", + "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)", + "stop_switch_dp": "(Optional) Stop switch (if the cover has continue command?)", + "position_inverted": "Invert 0-100 position (for *position* mode only)", + "span_time": "Full opening time, in secs. (for *timed* mode only)", + "unit_of_measurement": "(Optional) Unit of Measurement", + "device_class": "(Optional) Device Class", + "state_class": "(Optional) State Class", + "scaling": "(Optional) 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 (comma-separated)", + "docked_status_value": "Docked Status (comma-separated)", + "fault_dp": "Fault DP (usually 11)", + "battery_dp": "Battery status DP (usually 14)", + "mode_dp": "Mode DP", + "modes": "Modes list", + "return_mode": "Return home mode", + "fan_speed_dp": "(Optional) Fan speeds DP", + "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)", + "pause_dp":"Pause DP", + "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": "Reverse Color Temperature?", + "color": "Color", + "color_mode": "Color Mode aka Work 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", + "scene_values": "(Optional) Scene values", + "select_options": "Select options values", + "fan_speed_control": "Fan Speed Control DP", + "fan_oscillating_control": "Fan Oscillating Control DP", + "fan_speed_min": "minimum fan speed integer", + "fan_speed_max": "maximum fan speed integer", + "fan_speed_ordered_list": "Fan speed list (overrides speed min/max), separate entries by comma ','", + "fan_direction":"Fan Direction DP", + "fan_direction_forward": "Forward DP string", + "fan_direction_reverse": "Reverse DP string", + "fan_dps_type": "DP value type", + "current_temperature_dp": "Current Temperature", + "target_temperature_dp": "Target Temperature", + "temperature_step": "(Optional) Temperature Step", + "min_temperature": "Min Temperature", + "max_temperature": "Max Temperature", + "precision": "Precision (optional, for DPs values)", + "target_precision": "Target Precision (optional, for DP values)", + "temperature_unit": "(Optional) Temperature Unit", + "hvac_mode_dp": "(Optional) HVAC Mode DP", + "hvac_mode_set": "(Optional) HVAC Modes", + "hvac_add_off": "(Optional) Include `OFF` in HVAC Modes", + "hvac_action_dp": "(Optional) HVAC Current Action DP", + "hvac_action_set": "(Optional) HVAC Actions", + "preset_dp": "(Optional) Presets DP", + "preset_set": "(Optional) Presets", + "fan_speed_list": "(Optional) Fan supported speeds, separate entries by comma ','", + "eco_dp": "(Optional) Eco DP", + "eco_value": "(Optional) Eco value", + "heuristic_action": "(Optional) Enable heuristic action", + "dps_default_value": "(Optional) Default value when un-initialised", + "restore_on_reconnect": "Restore the last value set in Home Assistant after lost connection?", + "min_value": "Minimum Value", + "max_value": "Maximum Value", + "step_size": "Minimum increment between numbers", + "is_passive_entity": "Passive entity? (requires integration to send initialisation value)", + "entity_category": "Show the entity in this category", + "humidifier_available_modes": "(Optional) Available modes in the device", + "humidifier_current_humidity_dp": "(Optional) Current Humidity DP", + "humidifier_mode_dp": "(Optional) Set mode DP", + "humidifier_set_humidity_dp": "(Optional) Set Humidity DP", + "min_humidity": "Set the minimum supported humidity", + "max_humidity": "Set the maximum supported humidity", + "alarm_supported_states": "States supported by the device", + "receive_dp":"Receiving signals DP. (default is 202)", + "key_study_dp":"(Optional) Key Study DP (usually 7)" }, - "value": { - "name": "Value", - "description": "New value to set" + "data_description": { + "hvac_mode_set":"Each line represents [ hvac_mode: device_value ] [Supported HVAC Modes](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", + "hvac_action_set":"Each line represents [ hvac_action: device_value ] [Supported HVAC Actions](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action)", + "preset_set":"Each line represents [ device_value: friendly name ]", + "scene_values":"Each line represents [ device_value: friendly name ]", + "select_options":"Each line represents [ device_value: friendly name ]", + "alarm_supported_states":"Each line represents [ supported state: device value ] [Supported States](https://developers.home-assistant.io/docs/core/entity/alarm-control-panel/#states)", + "humidifier_available_modes":"Each line represents [ device_value: friendly name ]", + "device_class": "Find out more about [Device Classes](https://www.home-assistant.io/integrations/homeassistant/#device-class)", + "state_class": "Find out more about [State Classes](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes)" } } } }, "title": "LocalTuya" -} +} \ No newline at end of file diff --git a/custom_components/localtuya/translations/pl.json b/custom_components/localtuya/translations/pl.json new file mode 100644 index 00000000..6854090c --- /dev/null +++ b/custom_components/localtuya/translations/pl.json @@ -0,0 +1,260 @@ +{ + "config": { + "abort": { + "already_configured": "To konto zostało już skonfigurowane.", + "device_updated": "Konfiguracja urządzenia została zaktualizowana." + }, + "error": { + "authentication_failed": "Nie udało się uwierzytelnić.\n{msg}", + "cannot_connect": "Nie można połączyć się z urządzeniem. Potwierdź, że adres IP jest poprawny, a następnie spróbuj ponownie.", + "device_list_failed": "Nie udało się pobrać listy urządzeń.\n{msg}", + "invalid_auth": "Nie udało się uwierzytelnić z urządzeniem. Upewnij się, że identyfikator urządzenia i klucz lokalny są prawidłowe.", + "unknown": "Wystąpił nieznany błąd.\n{ex}.", + "entity_already_configured": "Ta encja została już skonfigurowana.", + "address_in_use": "Port TCP 6668 (używany do wykrywania) jest już używany. Sprawdź, czy nie używa go żadna inna integracja.", + "discovery_failed": "Coś nie powiodło się podczas wykrywania urządzeń. Szczegóły znajdziesz w logu. Jeśli problem będzie się powtarzał, utwórz nowy problem (w tym dzienniki debugowania).", + "empty_dps": "Połączenie z urządzeniem powiodło się, ale nie znaleziono żadnych punktów danych. Spróbuj ponownie dokonać konfiguracji. Jeśli problem będzie się powtarzał, utwórz nowy problem (w tym dzienniki debugowania)." + }, + "step": { + "user": { + "title": "Konfiguracja konta Cloud API", + "description": "Skonfiguruj dane uwierzytelniające używane do łączenia się z API Tuya Cloud.", + "data": { + "region": "Region centrum danych", + "client_id": "ID Klienta", + "client_secret": "Hasło klienta", + "user_id": "ID Użytkownika", + "username": "Nazwa użytkownika", + "no_cloud": "Wyłączyć interfejs Cloud API?" + } + } + } + }, + "options": { + "abort": { + "already_configured": "To konto zostało już skonfigurowane.", + "device_success": "Urządzenie {dev_name} pomyślnie wykonało {action}.", + "no_entities": "Nie można usunąć wszystkich elementów z urządzenia.\nJeśli chcesz usunąć urządzenie: Przejdź do menu „Urządzenia oraz usługi”, wyszukaj swoje urządzenie w zakładce „Urządzenia”, kliknij trzy kropki w ramce „Informacje o urządzeniu” i naciśnij przycisk „Usuń”." + }, + "error": { + "authentication_failed": "Nie udało się uwierzytelnić.\n{msg}", + "cannot_connect": "Nie można połączyć się z urządzeniem. Potwierdź, że adres IP jest poprawny, a następnie spróbuj ponownie.", + "device_list_failed": "Nie udało się pobrać listy urządzeń.\n{msg}", + "invalid_auth": "Nie udało się uwierzytelnić z urządzeniem. Upewnij się, że identyfikator urządzenia i klucz lokalny są prawidłowe.", + "unknown": "Wystąpił nieznany błąd. \n{ex}.", + "entity_already_configured": "Ta encja została już skonfigurowana.", + "address_in_use": "Port TCP 6668 (używany do wykrywania) jest już używany. Sprawdź, czy nie używa go żadna inna integracja.", + "discovery_failed": "Coś nie powiodło się podczas wykrywania urządzeń. Szczegóły znajdziesz w logu. Jeśli problem będzie się powtarzał, utwórz nowy problem (w tym dzienniki debugowania)..", + "empty_dps": "Połączenie z urządzeniem powiodło się, ale nie znaleziono żadnych punktów danych. Spróbuj ponownie dokonać konfiguracji. Jeśli problem będzie się powtarzał, utwórz nowy problem (w tym dzienniki debugowania)." + }, + "step": { + "yaml_import": { + "title": "Nieobsługiwany", + "description": "Urządzenia skonfigurowane przy użyciu konfiguracji `YAML` nie mogą być konfigurowane w interfejsie użytkownika. Usuń swoje urządzenie z konfiguracji `YAML` i utwórz je ponownie w interfejsie użytkownika lub zmodyfikuj konfigurację `YAML`." + }, + "init": { + "title": "Konfiguracja", + "description": "Wybierz opcję, aby kontynuować.", + "menu_options": { + "add_device": "Dodaj nowe urządzenie", + "edit_device": "Skonfiguruj ponownie istniejące urządzenie", + "configure_cloud": "Zarządzaj kontem Cloud API" + } + }, + "add_device": { + "title": "Wybierz urządzenie do skonfigurowania", + "description": "Kompatybilne urządzenia Tuya w Twojej sieci lokalnej są wykrywane automatycznie po skonfigurowaniu ich w aplikacji Tuya. Jeśli nie widzisz oczekiwanego urządzenia, wybierz z menu rozwijanego opcję „Dodaj urządzenie ręcznie”.", + "data": { + "selected_device": "Znalezione urządzenia", + "mass_configure": "Skonfiguruj automatycznie wszystkie rozpoznane urządzenia" + } + }, + "edit_device": { + "title": "Skonfiguruj ponownie istniejące urządzenie", + "description": "Wybierz urządzenie, które chcesz ponownie skonfigurować.", + "data": { + "selected_device": "Skonfigurowane urządzenia" + } + }, + "configure_cloud": { + "title": "Zarządzaj kontem Cloud API", + "description": "Skonfiguruj dane uwierzytelniające używane do łączenia się z API Tuya Cloud.", + "data": { + "region": "Region centrum danych", + "client_id": "ID Klienta", + "client_secret": "Hasło klienta", + "user_id": "ID Użytkownika", + "username": "Nazwa użytkownika", + "no_cloud": "Wyłączyć interfejs Cloud API?" + } + }, + "confirm": { + "title": "Confirmation", + "description": "{message}" + }, + "configure_device": { + "title": "Skonfiguruj łączność urządzenia", + "description": "Skonfiguruj wszystkie szczegóły urządzenia{for_device}, które są puste (jeśli istnieją), aby umożliwić LocalTuya połączenie się z urządzeniem.", + "data": { + "friendly_name": "Nazwa Urządzenia", + "host": "Adres IP", + "device_id": "ID Urządzenia", + "local_key": "Klucz Lokalny", + "node_id": "(Opcjonalnie) Id Node urządzenia podrzędnego", + "protocol_version": "Wersja Protokołu", + "enable_debug": "Włącz debugowanie (należy włączyć również ręcznie w pliku `configuration.yaml`)", + "scan_interval": "(Opcjonalnie) Interwał skanowania w sekundach, jeśli nie skanuje się automatycznie", + "entities": "Skonfigurowane encje (odznacz, aby usunąć)", + "add_entities": "Dodaj nowe encje", + "manual_dps_strings": "(Opcjonalnie) Ręczne DPS, jeśli nie zostaną wykryte automatycznie (oddzielone przecinkami)", + "reset_dpids": "(Opcjonalnie) Identyfikatory DPID do wysłania polecenia RESET, jeśli urządzenie nie odpowiada na żądania statusu po włączeniu (oddzielone przecinkami)", + "device_sleep_time": "(Optional) Device sleep time in seconds: If the device reports its state, then it goes into sleep", + "export_config": "Zapisz konfigurację encji jako szablon" + } + }, + "device_setup_method": { + "title": "Skonfiguruj encje urządzenia", + "description": "LocalTuya spróbuje automatycznie znaleść resztę konfiguracji. Jeśli jednak to nie zadziała na Twoim urządzeniu lub chcesz dostosować ustawienia, wybierz opcję „ręczną”.", + "menu_options": { + "auto_configure_device":"Automatycznie wykrywaj encje urządzenia", + "pick_entity_type": "Skonfiguruj ręcznie enje urządzenia", + "choose_template":"Użyj zapisanego szablonu" + } + }, + "auto_configure_device": { + "title": "Automatyczna konfiguracja", + "description": "Wystąpił błąd: {err_msg}. Jeśli przyczyna nie jest widoczna, sprawdź dzienniki.", + "menu_options": { + "device_setup_method":"Wróć do metody konfiguracji" + } + }, + "pick_entity_type": { + "title": "Wybór typu encji", + "description": "Wybierz typ encji, którą chcesz dodać.", + "data": { + "platform_to_add": "Wybierz encję", + "no_additional_entities": "Zakończ konfigurowanie encji", + "use_template" : "Importuj plik szablonu" + } + }, + "choose_template":{ + "title": "Importuj plik szablonu", + "description": "Pliki szablonów znajdują się w katalogu `templates` ([Więcej informacji](https://github.com/xZetsubou/hass-localtuya/discussions/13)).", + "data": { + "templates": "Wybierz szablon" + } + }, + "configure_entity": { + "title": "Skonfiguruj encje", + "description": "Podaj szczegóły dotyczące elementu {entity}, wpisując typ {platform}. Wszystkie ustawienia (z wyjątkiem „Typu” i „ID”) można później zmienić na stronie „Konfiguruj”.", + "data": { + "id": "DP ID", + "friendly_name": "Przyjazna nazwa dla encji", + "current": "Prąd", + "current_consumption": "Obecne zużycie", + "voltage": "Napięcie", + "commands_set": "Zestaw poleceń Otwórz_Zamknij_zatrzymaj", + "positioning_mode": "Tryb pozycjonowania", + "current_position_dp": "Bieżąca pozycja (tylko dla trybu *pozycjonowania*)", + "set_position_dp": "Ustaw pozycję (tylko dla trybu *pozycjonowania*)", + "stop_switch_dp": "(Optional) Stop switch (if the cover has continue command?)", + "position_inverted": "Odwróć pozycję 0-100 (tylko dla trybu *pozycjonowania*)", + "span_time": "Pełny czas otwarcia, w sekundach. (tylko dla trybu *czasowego*)", + "unit_of_measurement": "Jednostka miary (opcjonalnie)", + "device_class": "(Opcjonalnie) Klasa urządzenia", + "state_class": "(Opcjonalnie) Klasa stanu", + "scaling": "Współczynnik skalowania", + "state_on": "Wartość włączenia", + "state_off": "Wartość wyłączenia", + "powergo_dp": "DP mocy (zazwyczaj 25 or 2)", + "idle_status_value": "Stan bezczynności (oddzielone przecinkami)", + "returning_status_value": "Stan powrotu (oddzielone przecinkami)", + "docked_status_value": "Stan zadokowania (oddzielone przecinkami)", + "fault_dp": "DP błędu (zazwyczaj 11)", + "battery_dp": "DP statusu baterii (zazwyczaj 14)", + "mode_dp": "DP trybu", + "modes": "Lista trybów", + "return_mode": "Tryb powrotu do domu", + "fan_speed_dp": "(Opcjonalnie) DP prędkości wentylatora", + "fan_speeds": "Lista prędkości wentylatorów (oddzielone przecinkami)", + "clean_time_dp": "DP czasu czyszczenia(zazwyczaj 33)", + "clean_area_dp": "DP obszaru czyszczenia (zazwyczaj 32)", + "clean_record_dp": "DP zapisu czyszczenia (zazwyczaj 34)", + "locate_dp": "DP lokalizacji (zazwyczaj 31)", + "pause_dp":"Pause DP", + "paused_state": "Stan pauzy (pauza, itp)", + "stop_status": "Status zatrzymania", + "brightness": "Jasność (tylko dla koloru białego)", + "brightness_lower": "Najniższa wartość jasności", + "brightness_upper": "Najwyższa wartość jasności", + "color_temp": "Temperatura barwy", + "color_temp_reverse": "Odwrócona temperatura barwy", + "color": "Kolor", + "color_mode": "Tryb koloru, czyli tryb pracy", + "color_temp_min_kelvin": "Minimalna temperatura barwowa w K", + "color_temp_max_kelvin": "Maksymalna temperatura barwowa w K", + "music_mode": "Dostępny tryb muzyczny", + "scene": "Scena", + "scene_values": "Wartości scen", + "select_options": "Prawidłowe wpisy", + "fan_speed_control": "DP Kontroli prędkości wentylatora", + "fan_oscillating_control": "DP Sterowania oscylacyjnego wentylatora", + "fan_speed_min": "minimalna prędkość wentylatora", + "fan_speed_max": "maksymalna prędkośc wentylatora", + "fan_speed_ordered_list": "Lista trybów prędkości wentylatora (zastępuje prędkość min./maks.), wpisy oddzielaj przecinkami ','", + "fan_direction":"DP kierunku wentylatora", + "fan_direction_forward": "DP do przodu", + "fan_direction_reverse": "DP do tyłu", + "fan_dps_type": "DP typu", + "current_temperature_dp": "Obecna temperatura", + "target_temperature_dp": "Docelowa temperatura", + "temperature_step": "Krok temperatury (opcjonalnie)", + "max_temp": "Maksymalna temperatura (liczba)", + "min_temp": "Minimalna temperatura (liczba)", + "precision": "Precyzja (opcjonalnie, dla wartości DPs)", + "target_precision": "Dokładność docelowa (opcjonalnie, dla wartości DP)", + "temperature_unit": "(opcjonalnie) Jednostka temperatury", + "hvac_mode_dp": "(opcjonalnie) DP trybu HVAC", + "hvac_mode_set": "(opcjonalnie) Ustawiony tryb HVAC", + "hvac_add_off": "(Opcjonalnie) Dodaj `OFF` do trybów HVAC", + "hvac_action_dp": "(opcjonalnie) Bieżące działanie HVAC DP", + "hvac_action_set": "(opcjonalnie) Zestaw bieżących działań HVAC", + "preset_dp": "(opcjonalnie) DP ustawień wstępnych", + "preset_set": "(opcjonalnie) Zestaw ustawień wstępnych", + "fan_speed_list": "(Optional) Fan supported speeds, separate entries by comma ','", + "eco_dp": "(opcjonalnie) DP trybu eco", + "eco_value": "(opcjonalnie) Tryb eco", + "heuristic_action": "(opcjonalnie) Włącz działanie heurystyczne", + "dps_default_value": "(opcjonalnie) Wartość domyślna w przypadku niezainicjowania", + "restore_on_reconnect": "Przywróć ostatnią wartość ustawioną w Home Assistant po utracie połączenia", + "min_value": "Minimalna wartość", + "max_value": "Maksymalna wartość", + "step_size": "Minimalny odstęp między liczbami", + "is_passive_entity": "Jednostka pasywna? (wymaga integracji w celu przesłania wartości inicjalizacyjnej)", + "entity_category": "Pokaż encje w tej kategorii", + "humidifier_available_modes": "(opcjonalnie) Dostępne tryby w urządzeniu", + "humidifier_current_humidity_dp": "(opcjonalnie) DP aktualnej wilgotności", + "humidifier_mode_dp": "(opcjonalnie) DP ustawienia trybu", + "humidifier_set_humidity_dp": "(opcjonalnie) DP ustawienia wilgotności", + "min_humidity": "Ustaw minimalną obsługiwaną wilgotność", + "max_humidity": "Ustaw maksymalną obsługiwaną wilgotność", + "alarm_supported_states": "States supported by the device", + "receive_dp":"Receiving signals DP. (default is 202)", + "key_study_dp":"(Optional) Key Study DP (usually 7)" + }, + "data_description": { + "hvac_mode_set":"Każda linia reprezentuje [ hvac_mode: device_value ] [Obsługiwane tryby HVAC](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", + "hvac_action_set":"Każda linia reprezentuje [ hvac_action: device_value ] [Obsługiwane działania HVAC](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action)", + "preset_set":"Każda linia reprezentuje [ device_value: friendly name ]", + "scene_values":"Każda linia reprezentuje [ device_value: friendly name ]", + "select_options":"Każda linia reprezentuje [ device_value: friendly name ]", + "alarm_supported_states":"Each line represents [ supported state: device value ] [Supported States](https://developers.home-assistant.io/docs/core/entity/alarm-control-panel/#states)", + "humidifier_available_modes":"Każda linia reprezentuje [ device_value: friendly name ]", + "device_class": "Dowiedz się więcej o [Klasach urządzeń](https://www.home-assistant.io/integrations/homeassistant/#device-class)", + "state_class": "Dowiedz się więcej o [Klasach stanów](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes)" + } + } + } + }, + "title": "LocalTuya" +} \ No newline at end of file diff --git a/custom_components/localtuya/translations/pt-BR.json b/custom_components/localtuya/translations/pt-BR.json index 74884ee6..e9fd7723 100644 --- a/custom_components/localtuya/translations/pt-BR.json +++ b/custom_components/localtuya/translations/pt-BR.json @@ -1,216 +1,255 @@ { "config": { "abort": { - "already_configured": "O dispositivo já foi configurado.", - "device_updated": "A configuração do dispositivo foi atualizada!" + "already_configured": "Esta conta já foi configurada.", + "device_updated": "A configuração do dispositivo foi atualizada." }, "error": { - "authentication_failed": "Falha ao autenticar.\n{msg}", - "cannot_connect": "Não é possível se conectar ao dispositivo. Verifique se o endereço está correto e tente novamente", + "authentication_failed": "Falha na autenticação.\n{msg}", + "cannot_connect": "Não é possível se conectar ao dispositivo. Confirme se o Endereço IP está correto e tente novamente.", "device_list_failed": "Falha ao recuperar a lista de dispositivos.\n{msg}", - "invalid_auth": "Falha ao autenticar com o dispositivo. Verifique se o ID do dispositivo e a chave local estão corretos.", - "unknown": "Ocorreu um erro desconhecido. Consulte o registro para obter detalhes.", - "entity_already_configured": "A entidade com este ID já foi configurada.", - "address_in_use": "AddresO endereço usado para descoberta já está em uso. Certifique-se de que nenhum outro aplicativo o esteja usando (porta TCP 6668).s used for discovery is already in use. Make sure no other application is using it (TCP port 6668).", - "discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para obter detalhes.", - "empty_dps": "A conexão com o dispositivo foi bem-sucedida, mas nenhum ponto de dados foi encontrado. Tente novamente. Crie um novo issue e inclua os logs de depuração se o problema persistir." + "invalid_auth": "Falha na autenticação do dispositivo. Confirme se o ID do Dispositivo e a Chave Local estão corretos.", + "unknown": "Ocorreu um erro desconhecido.\n{ex}.", + "entity_already_configured": "Esta entidade já foi configurada.", + "address_in_use": "A porta TCP 6668 (usada para descoberta) já está em uso. Verifique se nenhuma outra integração a está utilizando.", + "discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para detalhes. Se o problema persistir, crie um novo problema (incluindo registros de depuração).", + "empty_dps": "A conexão com o dispositivo foi bem-sucedida, mas nenhum ponto de dados pôde ser encontrado. Tente configurar novamente. Se o problema persistir, crie um novo problema (incluindo registros de depuração)." }, "step": { "user": { - "title": "Configuração da conta da API do Cloud", - "description": "Insira as credenciais para a API Tuya Cloud.", + "title": "Configuração da conta da API de Nuvem", + "description": "Configure as credenciais usadas para se conectar à API de Nuvem Tuya.", "data": { - "region": "Região do servidor de API", - "client_id": "ID do cliente", - "client_secret": "Secret", - "user_id": "ID de usuário", - "user_name": "Nome de usuário", - "no_cloud": "Não configure uma conta de API da Cloud" + "region": "Região do Centro de Dados", + "client_id": "ID do Cliente", + "client_secret": "Segredo do Cliente", + "user_id": "ID do Usuário", + "username": "Nome de Usuário", + "no_cloud": "Desativar a API de Nuvem?" } } } }, "options": { "abort": { - "already_configured": "O dispositivo já foi configurado.", - "device_success": "Dispositivo {dev_name} {action} com sucesso.", - "no_entities": "Não é possível remover todas as entidades de um dispositivo.\nSe você deseja excluir um dispositivo, insira-o no menu Dispositivos, clique nos 3 pontos no quadro 'Informações do dispositivo' e pressione o botão Excluir." + "already_configured": "Esta conta já foi configurada.", + "device_success": "Dispositivo {dev_name} configurado com sucesso {action}.", + "no_entities": "Não é possível remover todas as entidades de um dispositivo.\nSe deseja excluir um dispositivo: Acesse o menu 'Dispositivos e serviços', procure seu dispositivo na guia 'Dispositivos', clique nos 3 pontos no quadro 'Informações do Dispositivo' e pressione o botão 'Excluir'." }, "error": { - "authentication_failed": "Falha ao autenticar.\n{msg}", - "cannot_connect": "Não é possível se conectar ao dispositivo. Verifique se o endereço está correto e tente novamente", + "authentication_failed": "Falha na autenticação.\n{msg}", + "cannot_connect": "Não é possível se conectar ao dispositivo. Confirme se o Endereço IP está correto e tente novamente.", "device_list_failed": "Falha ao recuperar a lista de dispositivos.\n{msg}", - "invalid_auth": "Falha ao autenticar com o dispositivo. Verifique se o ID do dispositivo e a chave local estão corretos.", - "unknown": "Ocorreu um erro desconhecido. Consulte o registro para obter detalhes.", - "entity_already_configured": "A entidade com este ID já foi configurada.", - "address_in_use": "O endereço usado para descoberta já está em uso. Certifique-se de que nenhum outro aplicativo o esteja usando (porta TCP 6668).", - "discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para obter detalhes.", - "empty_dps": "A conexão com o dispositivo foi bem-sucedida, mas nenhum ponto de dados foi encontrado. Tente novamente. Crie um novo issue e inclua os logs de depuração se o problema persistir." + "invalid_auth": "Falha na autenticação do dispositivo. Confirme se o ID do Dispositivo e a Chave Local estão corretos.", + "unknown": "Ocorreu um erro desconhecido.\n{ex}.", + "entity_already_configured": "Esta entidade já foi configurada.", + "address_in_use": "A porta TCP 6668 (usada para descoberta) já está em uso. Verifique se nenhuma outra integração a está utilizando.", + "discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para detalhes. Se o problema persistir, crie um novo problema (incluindo registros de depuração).", + "empty_dps": "A conexão com o dispositivo foi bem-sucedida, mas nenhum ponto de dados pôde ser encontrado. Tente configurar novamente. Se o problema persistir, crie um novo problema (incluindo registros de depuração)." }, "step": { "yaml_import": { "title": "Não suportado", - "description": "As opções não podem ser editadas quando configuradas via YAML." + "description": "Dispositivos configurados usando `YAML` não podem ser configurados na interface do usuário. Exclua seu dispositivo do `YAML` e recrie-o na interface do usuário ou modifique sua configuração `YAML`." }, "init": { - "title": "Configuração LocalTuya", - "description": "Selecione a ação desejada.", - "data": { - "add_device": "Adicionar um novo dispositivo", - "edit_device": "Editar um dispositivo", - "setup_cloud": "Reconfigurar a conta da API da Cloud" + "title": "Configuração", + "description": "Selecione uma opção para prosseguir.", + "menu_options": { + "add_device": "Adicionar novo dispositivo", + "edit_device": "Reconfigurar dispositivo existente", + "configure_cloud": "Gerenciar conta da API de Nuvem" } }, "add_device": { - "title": "Adicionar um novo dispositivo", - "description": "Escolha um dos dispositivos descobertos automaticamente ou `...` para adicionar um dispositivo manualmente.", + "title": "Escolha o dispositivo para configurar", + "description": "Os dispositivos Tuya compatíveis na sua rede local são descobertos automaticamente depois de configurados no aplicativo Tuya. Se você não conseguir ver o dispositivo esperado, escolha `Add Device Manually` no menu suspenso.", "data": { "selected_device": "Dispositivos descobertos" } }, "edit_device": { - "title": "Editar um novo dispositivo", - "description": "Escolha o dispositivo configurado que você deseja editar.", + "title": "Reconfigurar dispositivo existente", + "description": "Selecione o dispositivo que deseja reconfigurar.", "data": { "selected_device": "Dispositivos configurados" } }, - "cloud_setup": { - "title": "Configuração da conta da API da Cloud", - "description": "Insira as credenciais para a API Tuya Cloud.", + "configure_cloud": { + "title": "Gerenciar conta da API de Nuvem", + "description": "Configure as credenciais usadas para se conectar à API de Nuvem Tuya.", "data": { - "region": "Região do servidor de API", + "region": "Região do Centro de Dados", "client_id": "ID do Cliente", - "client_secret": "Secret", - "user_id": "ID do usuário", - "user_name": "Nome de usuário", - "no_cloud": "Não configure a conta da API da Cloud" + "client_secret": "Segredo do Cliente", + "user_id": "ID do Usuário", + "username": "Nome de Usuário", + "no_cloud": "Desativar a API de Nuvem?" } }, "configure_device": { - "title": "Configurar dispositivo Tuya", - "description": "Preencha os detalhes do dispositivo {for_device}.", + "title": "Configurar conectividade do dispositivo", + "description": "Configure quaisquer detalhes do dispositivo{for_device} que estejam vazios (se houver) para permitir que o LocalTuya se conecte ao dispositivo.", "data": { - "friendly_name": "Nome", - "host": "Host", - "device_id": "ID do dispositivo", - "local_key": "Local key", - "protocol_version": "Versão do protocolo", - "enable_debug": "Ative a depuração para este dispositivo (a depuração também deve ser ativada em configuration.yaml)", - "scan_interval": "Intervalo de escaneamento (segundos, somente quando não estiver atualizando automaticamente)", - "entities": "Entidades (desmarque uma entidade para removê-la)" + "friendly_name": "Nome do Dispositivo", + "host": "Endereço IP", + "device_id": "ID do Dispositivo (device id)", + "local_key": "Chave Local (local key)", + "node_id": "(Opcional) ID do nó de subdispositivos", + "protocol_version": "Versão do Protocolo", + "enable_debug": "Habilitar depuração (deve ser habilitado manualmente em `configuration.yaml` também)", + "scan_interval": "(Opcional) Intervalo de varredura em segundos, se não estiver escaneando automaticamente", + "entities": "Entidades configuradas (desmarque para excluir)", + "add_entities": "Adicionar nova(s) entidade(s)", + "manual_dps_strings": "(Opcional) DPS's Manuais, se não detectados automaticamente (separados por vírgulas)", + "reset_dpids": "(Opcional) DPIDs a serem enviados no comando RESET, se o dispositivo não responder a solicitações de status após ligar (separados por vírgulas)", + "device_sleep_time": "(Optional) Device sleep time in seconds: If the device reports its state, then it goes into sleep", + "export_config": "Salvar configuração de entidade como modelo" + } + }, + "device_setup_method": { + "title": "Configurar entidades do dispositivo", + "description": "O LocalTuya tentará descobrir o restante da configuração automaticamente. No entanto, se isso não funcionar para o seu dispositivo ou se desejar ajustar configurações, escolha a opção `manual`.", + "menu_options": { + "auto_configure_device":"Descobrir entidades do dispositivo automaticamente", + "pick_entity_type": "Configurar entidades do dispositivo manualmente", + "choose_template":"Usar modelo salvo" + } + }, + "auto_configure_device": { + "title": "Configuração automática", + "description": "Ocorreu um erro: {err_msg}. Se o motivo não estiver sendo exibido, verifique os registros.", + "menu_options": { + "device_setup_method":"Voltar ao método de configuração" } }, "pick_entity_type": { "title": "Seleção do tipo de entidade", "description": "Escolha o tipo de entidade que deseja adicionar.", "data": { - "platform_to_add": "Plataforma", - "no_additional_entities": "Não adicione mais entidades" + "platform_to_add": "Escolher entidade", + "no_additional_entities": "Terminar de configurar as entidades", + "use_template" : "Importar arquivo de modelo" } }, - "configure_entity": { - "title": "Configurar entidade", - "description": "Por favor, preencha os detalhes de {entity} com o tipo `{platform}`. Todas as configurações, exceto `ID`, podem ser alteradas na página Opções posteriormente.", + "choose_template":{ + "title": "Importar arquivo de modelo", + "description": "Os arquivos de modelo estão localizados no diretório 'templates' ([Mais Informações](https://github.com/xZetsubou/hass-localtuya/discussions/13)).", "data": { - "id": "ID", - "friendly_name": "Nome fantasia", - "current": "Atual", - "current_consumption": "Consumo atual", - "voltage": "Voltagem", - "commands_set": "Conjunto de comandos Abrir_Fechar_Parar", - "positioning_mode": "Modo de posicionamento", - "current_position_dp": "Posição atual (somente para o modo *posição*)", - "set_position_dp": "Definir posição (somente para o modo *posição*)", - "position_inverted": "Inverter 0-100 posição (somente para o modo *posição*)", - "span_time": "Tempo de abertura completo, em segundos. (somente para o modo *temporizado*)", - "unit_of_measurement": "Unidade de medida", - "device_class": "Classe do dispositivo", - "scaling": "Fator de escala", - "state_on": "Valor ligado", - "state_off": "Valor desligado", - "powergo_dp": "Potência DP (Geralmente 25 ou 2)", - "idle_status_value": "Status ocioso (separado por vírgula)", - "returning_status_value": "Status de retorno", - "docked_status_value": "Status encaixado (separado por vírgula)", - "fault_dp": "Falha DP (Geralmente 11)", - "battery_dp": "Status da bateria DP (normalmente 14)", - "mode_dp": "Modo DP (Geralmente 27)", - "modes": "Lista de modos", - "return_mode": "Modo de retorno para casa", - "fan_speed_dp": "Velocidades do ventilador DP (normalmente 30)", - "fan_speeds": "Lista de velocidades do ventilador (separadas por vírgulas)", - "clean_time_dp": "Tempo Limpo DP (Geralmente 33)", - "clean_area_dp": "Área Limpa DP (Geralmente 32)", - "clean_record_dp": "Limpar Registro DP (Geralmente 34)", - "locate_dp": "Localize DP (Geralmente 31)", - "paused_state": "Estado de pausa (pausa, pausado, etc)", - "stop_status": "Status de parada", - "brightness": "Brilho (somente para cor branca)", - "brightness_lower": "Valor mais baixo de brilho", - "brightness_upper": "Valor superior de brilho", - "color_temp": "Temperatura da cor", - "color_temp_reverse": "Temperatura da cor reversa", - "color": "Cor", - "color_mode": "Modo de cor", - "color_temp_min_kelvin": "Temperatura de cor mínima em K", - "color_temp_max_kelvin": "Temperatura máxima de cor em K", - "music_mode": "Modo de música disponível", - "scene": "Cena", - "select_options": "Entradas válidas, entradas separadas por um ;", - "select_options_friendly": "Opções fantasia ao usuário, entradas separadas por um ;", - "fan_speed_control": "Dps de controle de velocidade do ventilador", - "fan_oscillating_control": "Dps de controle oscilante do ventilador", - "fan_speed_min": "Velocidade mínima do ventilador inteiro", - "fan_speed_max": "Velocidade máxima do ventilador inteiro", - "fan_speed_ordered_list": "Lista de modos de velocidade do ventilador (substitui a velocidade min/max)", - "fan_direction":"Direção do ventilador dps", - "fan_direction_forward": "Seqüência de dps para frente", - "fan_direction_reverse": "String dps reversa", - "current_temperature_dp": "Temperatura atual", - "target_temperature_dp": "Temperatura alvo", - "temperature_step": "Etapa de temperatura (opcional)", - "max_temperature_dp": "Temperatura máxima (opcional)", - "min_temperature_dp": "Temperatura mínima (opcional)", - "precision": "Precisão (opcional, para valores de DPs)", - "target_precision": "Precisão do alvo (opcional, para valores de DPs)", - "temperature_unit": "Unidade de Temperatura (opcional)", - "hvac_mode_dp": "Modo HVAC DP (opcional)", - "hvac_mode_set": "Conjunto de modo HVAC (opcional)", - "hvac_action_dp": "Ação atual de HVAC DP (opcional)", - "hvac_action_set": "Conjunto de ação atual HVAC (opcional)", - "preset_dp": "Predefinições DP (opcional)", - "preset_set": "Conjunto de predefinições (opcional)", - "eco_dp": "Eco DP (opcional)", - "eco_value": "Valor eco (opcional)", - "heuristic_action": "Ativar ação heurística (opcional)" + "templates": "Escolher modelo" } - } - } - }, - "services": { - "reload": { - "name": "Reload", - "description": "Reload localtuya and reconnect to all devices." - }, - "set_dp": { - "name": "Set datapoint", - "description": "Change the value of a datapoint (DP)", - "fields": { - "device_id": { - "name": "Device ID", - "description": "Device ID of device to change datapoint value for" - }, - "dp": { - "name": "DP", - "description": "Datapoint index" + }, + "configure_entity": { + "title": "Configure entity", + "description": "Please fill out the details for {entity} with type {platform}. All settings (except for `Type` and `ID`) can be changed from the `Configure` page later.", + "data": { + "id": "DP ID", + "friendly_name": "Friendly name for Entity", + "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)", + "stop_switch_dp": "(Optional) Stop switch (if the cover has continue command?)", + "position_inverted": "Invert 0-100 position (for *position* mode only)", + "span_time": "Full opening time, in secs. (for *timed* mode only)", + "unit_of_measurement": "(Optional) Unit of Measurement", + "device_class": "(Optional) Device Class", + "state_class": "(Optional) State Class", + "scaling": "(Optional) 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 (comma-separated)", + "docked_status_value": "Docked Status (comma-separated)", + "fault_dp": "Fault DP (usually 11)", + "battery_dp": "Battery status DP (usually 14)", + "mode_dp": "Mode DP ", + "modes": "Modes list", + "return_mode": "Return home mode", + "fan_speed_dp": "(Optional) Fan speeds DP", + "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)", + "pause_dp":"Pause DP", + "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": "Reverse Color Temperature?", + "color": "Color", + "color_mode": "Color Mode aka Work 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", + "scene_values": "(Optional) Scene values", + "select_options": "Select options values", + "fan_speed_control": "Fan Speed Control DP", + "fan_oscillating_control": "Fan Oscillating Control DP", + "fan_speed_min": "minimum fan speed integer", + "fan_speed_max": "maximum fan speed integer", + "fan_speed_ordered_list": "Fan speed list (overrides speed min/max), separate entries by comma ','", + "fan_direction":"Fan Direction DP", + "fan_direction_forward": "Forward DP string", + "fan_direction_reverse": "Reverse DP string", + "fan_dps_type": "DP value type", + "current_temperature_dp": "Current Temperature", + "target_temperature_dp": "Target Temperature", + "temperature_step": "(Optional) Temperature Step", + "min_temperature": "Min Temperature", + "max_temperature": "Max Temperature", + "precision": "Precision (optional, for DPs values)", + "target_precision": "Target Precision (optional, for DP values)", + "temperature_unit": "(Optional) Temperature Unit", + "hvac_mode_dp": "(Optional) HVAC Mode DP", + "hvac_mode_set": "(Optional) HVAC Modes", + "hvac_add_off": "(Optional) Include `OFF` in HVAC Modes", + "hvac_action_dp": "(Optional) HVAC Current Action DP", + "hvac_action_set": "(Optional) HVAC Actions", + "preset_dp": "(Optional) Presets DP", + "preset_set": "(Optional) Presets", + "fan_speed_list": "(Optional) Fan supported speeds, separate entries by comma ','", + "eco_dp": "(Optional) Eco DP", + "eco_value": "(Optional) Eco value", + "heuristic_action": "(Optional) Enable heuristic action", + "dps_default_value": "(Optional) Default value when un-initialised", + "restore_on_reconnect": "Restore the last value set in Home Assistant after lost connection?", + "min_value": "Minimum Value", + "max_value": "Maximum Value", + "step_size": "Minimum increment between numbers", + "is_passive_entity": "Passive entity? (requires integration to send initialisation value)", + "entity_category": "Show the entity in this category", + "humidifier_available_modes": "(Optional) Available modes in the device", + "humidifier_current_humidity_dp": "(Optional) Current Humidity DP", + "humidifier_mode_dp": "(Optional) Set mode DP", + "humidifier_set_humidity_dp": "(Optional) Set Humidity DP", + "min_humidity": "Set the minimum supported humidity", + "max_humidity": "Set the maximum supported humidity", + "alarm_supported_states": "States supported by the device", + "receive_dp":"Receiving signals DP. (default is 202)", + "key_study_dp":"(Optional) Key Study DP (usually 7)" }, - "value": { - "name": "Value", - "description": "New value to set" + "data_description": { + "hvac_mode_set":"Each line represents [ hvac_mode: device_value ] [Supported HVAC Modes](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", + "hvac_action_set":"Each line represents [ hvac_action: device_value ] [Supported HVAC Actions](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action)", + "preset_set":"Each line represents [ device_value: friendly name ]", + "scene_values":"Each line represents [ device_value: friendly name ]", + "select_options":"Each line represents [ device_value: friendly name ]", + "alarm_supported_states":"Each line represents [ supported state: device value ] [Supported States](https://developers.home-assistant.io/docs/core/entity/alarm-control-panel/#states)", + "humidifier_available_modes":"Each line represents [ device_value: friendly name ]", + "device_class": "Find out more about [Device Classes](https://www.home-assistant.io/integrations/homeassistant/#device-class)", + "state_class": "Find out more about [State Classes](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes)" } } } }, "title": "LocalTuya" -} +} \ No newline at end of file diff --git a/custom_components/localtuya/vacuum.py b/custom_components/localtuya/vacuum.py index 11e7a61e..330588c1 100644 --- a/custom_components/localtuya/vacuum.py +++ b/custom_components/localtuya/vacuum.py @@ -1,6 +1,8 @@ """Platform to locally control Tuya-based vacuum devices.""" + import logging from functools import partial +from .config_flow import col_to_select import voluptuous as vol from homeassistant.components.vacuum import ( @@ -11,10 +13,11 @@ STATE_IDLE, STATE_PAUSED, STATE_RETURNING, - StateVacuumEntity, VacuumEntityFeature, + VacuumEntityFeature, + StateVacuumEntity, ) -from .common import LocalTuyaEntity, async_setup_entry +from .entity import LocalTuyaEntity, async_setup_entry from .const import ( CONF_BATTERY_DP, CONF_CLEAN_AREA_DP, @@ -33,6 +36,7 @@ CONF_RETURN_MODE, CONF_RETURNING_STATUS_VALUE, CONF_STOP_STATUS, + CONF_PAUSE_DP, ) _LOGGER = logging.getLogger(__name__) @@ -45,8 +49,8 @@ FAULT = "fault" DEFAULT_IDLE_STATUS = "standby,sleep" -DEFAULT_RETURNING_STATUS = "docking" -DEFAULT_DOCKED_STATUS = "charging,chargecompleted" +DEFAULT_RETURNING_STATUS = "docking,to_charge,goto_charge" +DEFAULT_DOCKED_STATUS = "charging,chargecompleted,charge_done" DEFAULT_MODES = "smart,wall_follow,spiral,single" DEFAULT_FAN_SPEEDS = "low,normal,high" DEFAULT_PAUSED_STATE = "paused" @@ -57,33 +61,34 @@ def flow_schema(dps): """Return schema used in config flow.""" return { + vol.Required(CONF_POWERGO_DP): col_to_select(dps, is_dps=True), 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_PAUSED_STATE, default=DEFAULT_PAUSED_STATE): str, + vol.Optional(CONF_STOP_STATUS, default=DEFAULT_STOP_STATUS): str, + vol.Optional(CONF_PAUSE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_BATTERY_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_MODE_DP): col_to_select(dps, is_dps=True), 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_SPEED_DP): col_to_select(dps, is_dps=True), 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, + vol.Optional(CONF_CLEAN_TIME_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_CLEAN_AREA_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_CLEAN_RECORD_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_LOCATE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_FAULT_DP): col_to_select(dps, is_dps=True), } -class LocaltuyaVacuum(LocalTuyaEntity, StateVacuumEntity): +class LocalTuyaVacuum(LocalTuyaEntity, StateVacuumEntity): """Tuya vacuum device.""" def __init__(self, device, config_entry, switchid, **kwargs): - """Initialize a new LocaltuyaVacuum.""" + """Initialize a new LocalTuyaVacuum.""" super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) self._state = None self._battery_level = None @@ -91,27 +96,35 @@ def __init__(self, device, config_entry, switchid, **kwargs): self._idle_status_list = [] if self.has_config(CONF_IDLE_STATUS_VALUE): - self._idle_status_list = self._config[CONF_IDLE_STATUS_VALUE].split(",") + status = self._config[CONF_IDLE_STATUS_VALUE].split(",") + self._idle_status_list = [state.lstrip() for state in status] self._modes_list = [] if self.has_config(CONF_MODES): - self._modes_list = self._config[CONF_MODES].split(",") + modes_list = self._config[CONF_MODES].split(",") + self._modes_list = [mode.lstrip() for mode in modes_list] self._attrs[MODES_LIST] = self._modes_list + self._returning_status_list = [] + if self.has_config(CONF_RETURNING_STATUS_VALUE): + returning_status = self._config[CONF_RETURNING_STATUS_VALUE].split(",") + self._returning_status_list = [state.lstrip() for state in returning_status] + self._docked_status_list = [] if self.has_config(CONF_DOCKED_STATUS_VALUE): - self._docked_status_list = self._config[CONF_DOCKED_STATUS_VALUE].split(",") + docked_status = self._config[CONF_DOCKED_STATUS_VALUE].split(",") + self._docked_status_list = [state.lstrip() for state in docked_status] self._fan_speed_list = [] if self.has_config(CONF_FAN_SPEEDS): - self._fan_speed_list = self._config[CONF_FAN_SPEEDS].split(",") + fan_speeds = self._config[CONF_FAN_SPEEDS].split(",") + self._fan_speed_list = [speed.lstrip() for speed in fan_speeds] self._fan_speed = "" self._cleaning_mode = "" - _LOGGER.debug("Initialized vacuum [%s]", self.name) @property - def supported_features(self): + def supported_features(self) -> VacuumEntityFeature: """Flag supported features.""" supported_features = ( VacuumEntityFeature.START @@ -121,14 +134,17 @@ def supported_features(self): | VacuumEntityFeature.STATE ) - if self.has_config(CONF_RETURN_MODE): - supported_features = supported_features | VacuumEntityFeature.RETURN_HOME + if ( + self.has_config(CONF_RETURN_MODE) + and self._config[CONF_RETURN_MODE] in self._modes_list + ): + supported_features |= VacuumEntityFeature.RETURN_HOME if self.has_config(CONF_FAN_SPEED_DP): - supported_features = supported_features | VacuumEntityFeature.FAN_SPEED + supported_features |= VacuumEntityFeature.FAN_SPEED if self.has_config(CONF_BATTERY_DP): - supported_features = supported_features | VacuumEntityFeature.BATTERY + supported_features |= VacuumEntityFeature.BATTERY if self.has_config(CONF_LOCATE_DP): - supported_features = supported_features | VacuumEntityFeature.LOCATE + supported_features |= VacuumEntityFeature.LOCATE return supported_features @@ -161,9 +177,25 @@ 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_stop(self, **kwargs): + """Turn the vacuum off stopping the cleaning.""" + if ( + self.has_config(CONF_STOP_STATUS) + and self._config[CONF_STOP_STATUS] in self._modes_list + ): + await self._device.set_dp( + self._config[CONF_STOP_STATUS], self._config[CONF_MODE_DP] + ) + else: + await self._device.set_dp(False, self._config[CONF_POWERGO_DP]) + # _LOGGER.error("Missing command for stop in commands set.") + 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]) + if self.has_config(CONF_PAUSE_DP): + return await self._device.set_dp(True, self._config[CONF_PAUSE_DP]) + + await self.async_stop() async def async_return_to_base(self, **kwargs): """Set the vacuum cleaner to return to the dock.""" @@ -174,15 +206,6 @@ async def async_return_to_base(self, **kwargs): 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 @@ -190,7 +213,7 @@ async def async_clean_spot(self, **kwargs): 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]) + await self._device.set_dp(True, self._config[CONF_LOCATE_DP]) async def async_set_fan_speed(self, fan_speed, **kwargs): """Set the fan speed.""" @@ -204,44 +227,48 @@ async def async_send_command(self, command, params=None, **kwargs): def status_updated(self): """Device status was updated.""" - state_value = str(self.dps(self._dp_id)) + state_value = str(self.dp_value(self._dp_id)) - if state_value in self._idle_status_list: + if state_value == "None": + self._state = None + elif 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]: + elif state_value in self._returning_status_list: self._state = STATE_RETURNING - elif state_value == self._config[CONF_PAUSED_STATE]: + elif state_value in [self._config[CONF_PAUSED_STATE], "pause"] or ( + not state_value and self.dp_value(CONF_PAUSE_DP) is True + ): 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._battery_level = self.dp_value(CONF_BATTERY_DP) self._cleaning_mode = "" if self.has_config(CONF_MODES): - self._cleaning_mode = self.dps_conf(CONF_MODE_DP) + self._cleaning_mode = self.dp_value(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) + self._fan_speed = self.dp_value(CONF_FAN_SPEED_DP) if self.has_config(CONF_CLEAN_TIME_DP): - self._attrs[CLEAN_TIME] = self.dps_conf(CONF_CLEAN_TIME_DP) + self._attrs[CLEAN_TIME] = self.dp_value(CONF_CLEAN_TIME_DP) if self.has_config(CONF_CLEAN_AREA_DP): - self._attrs[CLEAN_AREA] = self.dps_conf(CONF_CLEAN_AREA_DP) + self._attrs[CLEAN_AREA] = self.dp_value(CONF_CLEAN_AREA_DP) if self.has_config(CONF_CLEAN_RECORD_DP): - self._attrs[CLEAN_RECORD] = self.dps_conf(CONF_CLEAN_RECORD_DP) + self._attrs[CLEAN_RECORD] = self.dp_value(CONF_CLEAN_RECORD_DP) if self.has_config(CONF_FAULT_DP): - self._attrs[FAULT] = self.dps_conf(CONF_FAULT_DP) + self._attrs[FAULT] = self.dp_value(CONF_FAULT_DP) if self._attrs[FAULT] != 0: self._state = STATE_ERROR -async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaVacuum, flow_schema) +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaVacuum, flow_schema) diff --git a/custom_components/localtuya/water_heater.py b/custom_components/localtuya/water_heater.py new file mode 100644 index 00000000..f2624a4f --- /dev/null +++ b/custom_components/localtuya/water_heater.py @@ -0,0 +1,243 @@ +"""Platform to locally control Tuya-based WaterHeater devices.""" + +import logging +from functools import partial +from .config_flow import col_to_select +from homeassistant.helpers import selector + +import voluptuous as vol +from homeassistant.components.water_heater import ( + DEFAULT_MIN_TEMP, + DEFAULT_MAX_TEMP, + DOMAIN, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.components.water_heater.const import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_PERFORMANCE, + STATE_HIGH_DEMAND, + STATE_HEAT_PUMP, + STATE_GAS, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_TEMPERATURE_UNIT, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + UnitOfTemperature, +) +from .entity import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_TARGET_TEMPERATURE_DP, + CONF_CURRENT_TEMPERATURE_DP, + CONF_MIN_TEMP, + CONF_MAX_TEMP, + CONF_PRECISION, + CONF_TARGET_PRECISION, + CONF_MODE_DP, + CONF_MODES, + CONF_TARGET_TEMPERATURE_LOW_DP, + CONF_TARGET_TEMPERATURE_HIGH_DP, +) + +_LOGGER = logging.getLogger(__name__) + + +TEMPERATURE_CELSIUS = "celsius" +TEMPERATURE_FAHRENHEIT = "fahrenheit" + +DEFAULT_TEMPERATURE_UNIT = TEMPERATURE_CELSIUS +DEFAULT_PRECISION = PRECISION_TENTHS +DEFAULT_TEMPERATURE_STEP = PRECISION_HALVES +PERCISION_SET = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + +OFF_MODE = "Off" + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_TARGET_TEMPERATURE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_TARGET_TEMPERATURE_LOW_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_TARGET_TEMPERATURE_HIGH_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_CURRENT_TEMPERATURE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), + vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float), + vol.Optional(CONF_PRECISION, default=str(DEFAULT_PRECISION)): col_to_select( + PERCISION_SET + ), + vol.Optional( + CONF_TARGET_PRECISION, default=str(DEFAULT_PRECISION) + ): col_to_select(PERCISION_SET), + vol.Optional(CONF_MODE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_MODES, default={}): selector.ObjectSelector(), + vol.Optional(CONF_TEMPERATURE_UNIT): col_to_select( + [TEMPERATURE_CELSIUS, TEMPERATURE_FAHRENHEIT] + ), + } + + +def config_unit(unit): + if unit == TEMPERATURE_FAHRENHEIT: + return UnitOfTemperature.FAHRENHEIT + else: + return UnitOfTemperature.CELSIUS + + +class LocalTuyaWaterHeater(LocalTuyaEntity, WaterHeaterEntity): + """Tuya WaterHeater device.""" + + _enable_turn_on_off_backwards_compatibility = False + + def __init__( + self, + device, + config_entry, + switchid, + **kwargs, + ): + """Initialize a new LocalTuyaWaterHeater.""" + super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) + self._state = None + self._target_temperature = None + self._current_temperature = None + self._dp_mode = self._config.get(CONF_MODE_DP, None) + + self._available_modes = self._config.get(CONF_MODES, {}) + self._modes_name_to_value = {v: k for k, v in self._available_modes.items()} + + self._precision = float(self._config.get(CONF_PRECISION, DEFAULT_PRECISION)) + self._precision_target = float( + self._config.get(CONF_TARGET_PRECISION, DEFAULT_PRECISION) + ) + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = WaterHeaterEntityFeature(0) + if self.has_config(CONF_TARGET_TEMPERATURE_DP): + supported_features |= WaterHeaterEntityFeature.TARGET_TEMPERATURE + if self.has_config(CONF_MODE_DP): + supported_features |= WaterHeaterEntityFeature.OPERATION_MODE + + supported_features |= WaterHeaterEntityFeature.ON_OFF + + return supported_features + + @property + def precision(self): + """Return the precision of the system.""" + return self._precision + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + return config_unit(self._config.get(CONF_TEMPERATURE_UNIT)) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._config.get(CONF_MIN_TEMP, DEFAULT_MIN_TEMP) + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._config.get(CONF_MAX_TEMP, DEFAULT_MAX_TEMP) + + @property + def operation_list(self) -> list[str] | None: + """Return the list of available operation modes.""" + return list(self._modes_name_to_value) + [OFF_MODE] + + @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_high(self) -> float | None: + """Return the highbound target temperature we try to reach.""" + return self._attr_target_temperature_high + + @property + def target_temperature_low(self) -> float | None: + """Return the lowbound target temperature we try to reach.""" + return self._attr_target_temperature_low + + 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 = kwargs[ATTR_TEMPERATURE] + + temperature = round(temperature / self._precision_target) + await self._device.set_dp( + temperature, self._config[CONF_TARGET_TEMPERATURE_DP] + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + status = {} + if operation_mode == OFF_MODE: + return await self.async_turn_off() + elif not self._state: + status[self._dp_id] = True + + mode = self._modes_name_to_value.get(operation_mode) + status[self._dp_mode] = mode + + await self._device.set_dps(status) + + 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) + + def status_updated(self): + """Device status was updated.""" + self._state = self.dp_value(self._dp_id) + + # Update target temperature + if self.has_config(CONF_TARGET_TEMPERATURE_DP): + self._target_temperature = ( + self.dp_value(CONF_TARGET_TEMPERATURE_DP) * self._precision_target + ) + + # Update current temperature + if self.has_config(CONF_CURRENT_TEMPERATURE_DP): + self._current_temperature = ( + self.dp_value(CONF_CURRENT_TEMPERATURE_DP) * self._precision + ) + + # Update modes states + if not self._state: + self._attr_current_operation = OFF_MODE + elif self._dp_mode is not None: + for mode_value, mode_name in self._available_modes.items(): + if str(self.dp_value(CONF_MODE_DP)) == str(mode_value): + self._attr_current_operation = mode_name + + if ( + target_high := self.dp_value(CONF_TARGET_TEMPERATURE_HIGH_DP) + ) or target_high is not None: + self._attr_target_temperature_high = target_high + + if ( + target_low := self.dp_value(CONF_TARGET_TEMPERATURE_LOW_DP) + ) or target_low is not None: + self._attr_target_temperature_low = target_low + + +async_setup_entry = partial( + async_setup_entry, DOMAIN, LocalTuyaWaterHeater, flow_schema +) diff --git a/custom_components/mediabrowser/__pycache__/__init__.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 248bbc03..00000000 Binary files a/custom_components/mediabrowser/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/__init__.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/__init__.cpython-313.pyc index e3ec1e49..f502951f 100644 Binary files a/custom_components/mediabrowser/__pycache__/__init__.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/browse.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/browse.cpython-312.pyc deleted file mode 100644 index 2152dd41..00000000 Binary files a/custom_components/mediabrowser/__pycache__/browse.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/browse.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/browse.cpython-313.pyc index f1005821..5049e16d 100644 Binary files a/custom_components/mediabrowser/__pycache__/browse.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/browse.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/browse_media.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/browse_media.cpython-312.pyc deleted file mode 100644 index 40d7fd8c..00000000 Binary files a/custom_components/mediabrowser/__pycache__/browse_media.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/browse_media.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/browse_media.cpython-313.pyc index 2fecb940..02c4c399 100644 Binary files a/custom_components/mediabrowser/__pycache__/browse_media.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/browse_media.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/button.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/button.cpython-312.pyc deleted file mode 100644 index 715efa3d..00000000 Binary files a/custom_components/mediabrowser/__pycache__/button.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/button.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/button.cpython-313.pyc index 32acb34b..c48792d4 100644 Binary files a/custom_components/mediabrowser/__pycache__/button.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/button.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/config_flow.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index e749f0fd..00000000 Binary files a/custom_components/mediabrowser/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/config_flow.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/config_flow.cpython-313.pyc index ae0241a3..6533a0bd 100644 Binary files a/custom_components/mediabrowser/__pycache__/config_flow.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/config_flow.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/const.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/const.cpython-312.pyc deleted file mode 100644 index d8f242e9..00000000 Binary files a/custom_components/mediabrowser/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/const.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/const.cpython-313.pyc index 44c330ec..3bb60c59 100644 Binary files a/custom_components/mediabrowser/__pycache__/const.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/const.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/discovery.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/discovery.cpython-312.pyc deleted file mode 100644 index 87b7c89d..00000000 Binary files a/custom_components/mediabrowser/__pycache__/discovery.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/discovery.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/discovery.cpython-313.pyc index c7498c1f..618bddac 100644 Binary files a/custom_components/mediabrowser/__pycache__/discovery.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/discovery.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/entity.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/entity.cpython-312.pyc deleted file mode 100644 index a23ff6ae..00000000 Binary files a/custom_components/mediabrowser/__pycache__/entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/entity.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/entity.cpython-313.pyc index 1960dc9c..19e83716 100644 Binary files a/custom_components/mediabrowser/__pycache__/entity.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/entity.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/errors.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/errors.cpython-312.pyc deleted file mode 100644 index 45b95a39..00000000 Binary files a/custom_components/mediabrowser/__pycache__/errors.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/errors.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/errors.cpython-313.pyc index 1f649f76..d0c8bba7 100644 Binary files a/custom_components/mediabrowser/__pycache__/errors.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/errors.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/helpers.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/helpers.cpython-312.pyc deleted file mode 100644 index 7718646d..00000000 Binary files a/custom_components/mediabrowser/__pycache__/helpers.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/helpers.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/helpers.cpython-313.pyc index a7922b30..92af8a42 100644 Binary files a/custom_components/mediabrowser/__pycache__/helpers.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/helpers.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/hub.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/hub.cpython-312.pyc deleted file mode 100644 index 5f6fc4eb..00000000 Binary files a/custom_components/mediabrowser/__pycache__/hub.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/hub.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/hub.cpython-313.pyc index a2eec1a8..82d11058 100644 Binary files a/custom_components/mediabrowser/__pycache__/hub.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/hub.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/icons.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/icons.cpython-312.pyc deleted file mode 100644 index e29ce054..00000000 Binary files a/custom_components/mediabrowser/__pycache__/icons.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/icons.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/icons.cpython-313.pyc index aff5705f..ecad8849 100644 Binary files a/custom_components/mediabrowser/__pycache__/icons.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/icons.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/media_player.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/media_player.cpython-312.pyc deleted file mode 100644 index 27a20914..00000000 Binary files a/custom_components/mediabrowser/__pycache__/media_player.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/media_player.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/media_player.cpython-313.pyc index bcf754c6..d9d76471 100644 Binary files a/custom_components/mediabrowser/__pycache__/media_player.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/media_player.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/media_source.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/media_source.cpython-312.pyc deleted file mode 100644 index 5b8d2c5a..00000000 Binary files a/custom_components/mediabrowser/__pycache__/media_source.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/media_source.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/media_source.cpython-313.pyc index 9c10c8d1..16e1e00e 100644 Binary files a/custom_components/mediabrowser/__pycache__/media_source.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/media_source.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/__pycache__/sensor.cpython-312.pyc b/custom_components/mediabrowser/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index c97a3810..00000000 Binary files a/custom_components/mediabrowser/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/mediabrowser/__pycache__/sensor.cpython-313.pyc b/custom_components/mediabrowser/__pycache__/sensor.cpython-313.pyc index 30d3dcb7..40bb040c 100644 Binary files a/custom_components/mediabrowser/__pycache__/sensor.cpython-313.pyc and b/custom_components/mediabrowser/__pycache__/sensor.cpython-313.pyc differ diff --git a/custom_components/mediabrowser/browse.py b/custom_components/mediabrowser/browse.py index 6a818efa..c854fdda 100644 --- a/custom_components/mediabrowser/browse.py +++ b/custom_components/mediabrowser/browse.py @@ -606,10 +606,7 @@ async def get_stream_url( mime_type = ( f"{item_media_type.lower()}/{best.get(MediaSource.CONTAINER)}" ) - if best.get(MediaSource.DIRECT_STREAM_URL): - url = f"{hub.server_url}{best[MediaSource.DIRECT_STREAM_URL]}" - else: - url = f"{hub.server_url}/Videos/{best['Id']}/stream?static=true&DeviceId={hub.device_id}" + url = f"{hub.server_url}{best[MediaSource.DIRECT_STREAM_URL]}" elif best.get(MediaSource.SUPPORTS_TRANSCODING, False): url = f"{hub.server_url}{best[MediaSource.TRANSCODING_URL]}" mime_type = "/".join( diff --git a/custom_components/monitor_docker/__pycache__/__init__.cpython-312.pyc b/custom_components/monitor_docker/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 86167987..00000000 Binary files a/custom_components/monitor_docker/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/monitor_docker/__pycache__/button.cpython-312.pyc b/custom_components/monitor_docker/__pycache__/button.cpython-312.pyc deleted file mode 100644 index 93e7361d..00000000 Binary files a/custom_components/monitor_docker/__pycache__/button.cpython-312.pyc and /dev/null differ diff --git a/custom_components/monitor_docker/__pycache__/const.cpython-312.pyc b/custom_components/monitor_docker/__pycache__/const.cpython-312.pyc deleted file mode 100644 index d91c70cc..00000000 Binary files a/custom_components/monitor_docker/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/monitor_docker/__pycache__/helpers.cpython-312.pyc b/custom_components/monitor_docker/__pycache__/helpers.cpython-312.pyc deleted file mode 100644 index 94a6ceea..00000000 Binary files a/custom_components/monitor_docker/__pycache__/helpers.cpython-312.pyc and /dev/null differ diff --git a/custom_components/monitor_docker/__pycache__/sensor.cpython-312.pyc b/custom_components/monitor_docker/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index efb6279d..00000000 Binary files a/custom_components/monitor_docker/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/monitor_docker/__pycache__/sensor.cpython-313.pyc b/custom_components/monitor_docker/__pycache__/sensor.cpython-313.pyc index 65b90f8e..42596cd9 100644 Binary files a/custom_components/monitor_docker/__pycache__/sensor.cpython-313.pyc and b/custom_components/monitor_docker/__pycache__/sensor.cpython-313.pyc differ diff --git a/custom_components/monitor_docker/__pycache__/switch.cpython-312.pyc b/custom_components/monitor_docker/__pycache__/switch.cpython-312.pyc deleted file mode 100644 index 35413c0d..00000000 Binary files a/custom_components/monitor_docker/__pycache__/switch.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/__init__.cpython-312.pyc b/custom_components/pyscript/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index be2ca6b9..00000000 Binary files a/custom_components/pyscript/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/__init__.cpython-313.pyc b/custom_components/pyscript/__pycache__/__init__.cpython-313.pyc index fc1c9636..3b604efe 100644 Binary files a/custom_components/pyscript/__pycache__/__init__.cpython-313.pyc and b/custom_components/pyscript/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/pyscript/__pycache__/config_flow.cpython-312.pyc b/custom_components/pyscript/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index 829fdafc..00000000 Binary files a/custom_components/pyscript/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/config_flow.cpython-313.pyc b/custom_components/pyscript/__pycache__/config_flow.cpython-313.pyc index 23c1cc69..da6b904b 100644 Binary files a/custom_components/pyscript/__pycache__/config_flow.cpython-313.pyc and b/custom_components/pyscript/__pycache__/config_flow.cpython-313.pyc differ diff --git a/custom_components/pyscript/__pycache__/const.cpython-312.pyc b/custom_components/pyscript/__pycache__/const.cpython-312.pyc deleted file mode 100644 index 08d8b6ac..00000000 Binary files a/custom_components/pyscript/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/entity.cpython-312.pyc b/custom_components/pyscript/__pycache__/entity.cpython-312.pyc deleted file mode 100644 index 92e735a2..00000000 Binary files a/custom_components/pyscript/__pycache__/entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/eval.cpython-312.pyc b/custom_components/pyscript/__pycache__/eval.cpython-312.pyc deleted file mode 100644 index 48b1a530..00000000 Binary files a/custom_components/pyscript/__pycache__/eval.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/eval.cpython-313.pyc b/custom_components/pyscript/__pycache__/eval.cpython-313.pyc index 7982358f..18966bf2 100644 Binary files a/custom_components/pyscript/__pycache__/eval.cpython-313.pyc and b/custom_components/pyscript/__pycache__/eval.cpython-313.pyc differ diff --git a/custom_components/pyscript/__pycache__/event.cpython-312.pyc b/custom_components/pyscript/__pycache__/event.cpython-312.pyc deleted file mode 100644 index 454304bb..00000000 Binary files a/custom_components/pyscript/__pycache__/event.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/function.cpython-312.pyc b/custom_components/pyscript/__pycache__/function.cpython-312.pyc deleted file mode 100644 index b2ac93c1..00000000 Binary files a/custom_components/pyscript/__pycache__/function.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/function.cpython-313.pyc b/custom_components/pyscript/__pycache__/function.cpython-313.pyc index eeb5b000..fb72a600 100644 Binary files a/custom_components/pyscript/__pycache__/function.cpython-313.pyc and b/custom_components/pyscript/__pycache__/function.cpython-313.pyc differ diff --git a/custom_components/pyscript/__pycache__/global_ctx.cpython-312.pyc b/custom_components/pyscript/__pycache__/global_ctx.cpython-312.pyc deleted file mode 100644 index fee9cfd6..00000000 Binary files a/custom_components/pyscript/__pycache__/global_ctx.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/jupyter_kernel.cpython-312.pyc b/custom_components/pyscript/__pycache__/jupyter_kernel.cpython-312.pyc deleted file mode 100644 index 92abd449..00000000 Binary files a/custom_components/pyscript/__pycache__/jupyter_kernel.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/logbook.cpython-312.pyc b/custom_components/pyscript/__pycache__/logbook.cpython-312.pyc deleted file mode 100644 index ab4f8303..00000000 Binary files a/custom_components/pyscript/__pycache__/logbook.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/mqtt.cpython-312.pyc b/custom_components/pyscript/__pycache__/mqtt.cpython-312.pyc deleted file mode 100644 index e51cc439..00000000 Binary files a/custom_components/pyscript/__pycache__/mqtt.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/requirements.cpython-312.pyc b/custom_components/pyscript/__pycache__/requirements.cpython-312.pyc deleted file mode 100644 index 0df4ae25..00000000 Binary files a/custom_components/pyscript/__pycache__/requirements.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/requirements.cpython-313.pyc b/custom_components/pyscript/__pycache__/requirements.cpython-313.pyc index 8b10c853..1b553cbd 100644 Binary files a/custom_components/pyscript/__pycache__/requirements.cpython-313.pyc and b/custom_components/pyscript/__pycache__/requirements.cpython-313.pyc differ diff --git a/custom_components/pyscript/__pycache__/state.cpython-312.pyc b/custom_components/pyscript/__pycache__/state.cpython-312.pyc deleted file mode 100644 index e0b03f7a..00000000 Binary files a/custom_components/pyscript/__pycache__/state.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/state.cpython-313.pyc b/custom_components/pyscript/__pycache__/state.cpython-313.pyc index 2fecc685..9cc85b65 100644 Binary files a/custom_components/pyscript/__pycache__/state.cpython-313.pyc and b/custom_components/pyscript/__pycache__/state.cpython-313.pyc differ diff --git a/custom_components/pyscript/__pycache__/trigger.cpython-312.pyc b/custom_components/pyscript/__pycache__/trigger.cpython-312.pyc deleted file mode 100644 index d2c892c3..00000000 Binary files a/custom_components/pyscript/__pycache__/trigger.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/webhook.cpython-312.pyc b/custom_components/pyscript/__pycache__/webhook.cpython-312.pyc deleted file mode 100644 index 15b2b521..00000000 Binary files a/custom_components/pyscript/__pycache__/webhook.cpython-312.pyc and /dev/null differ diff --git a/custom_components/pyscript/__pycache__/webhook.cpython-313.pyc b/custom_components/pyscript/__pycache__/webhook.cpython-313.pyc index c7d94332..ffe2744a 100644 Binary files a/custom_components/pyscript/__pycache__/webhook.cpython-313.pyc and b/custom_components/pyscript/__pycache__/webhook.cpython-313.pyc differ diff --git a/custom_components/scheduler/__pycache__/__init__.cpython-312.pyc b/custom_components/scheduler/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index bcfdbe2f..00000000 Binary files a/custom_components/scheduler/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/scheduler/__pycache__/__init__.cpython-313.pyc b/custom_components/scheduler/__pycache__/__init__.cpython-313.pyc index 2403f861..023704e5 100644 Binary files a/custom_components/scheduler/__pycache__/__init__.cpython-313.pyc and b/custom_components/scheduler/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/scheduler/__pycache__/actions.cpython-312.pyc b/custom_components/scheduler/__pycache__/actions.cpython-312.pyc deleted file mode 100644 index 38ade4cf..00000000 Binary files a/custom_components/scheduler/__pycache__/actions.cpython-312.pyc and /dev/null differ diff --git a/custom_components/scheduler/__pycache__/actions.cpython-313.pyc b/custom_components/scheduler/__pycache__/actions.cpython-313.pyc index 98969904..beba5238 100644 Binary files a/custom_components/scheduler/__pycache__/actions.cpython-313.pyc and b/custom_components/scheduler/__pycache__/actions.cpython-313.pyc differ diff --git a/custom_components/scheduler/__pycache__/config_flow.cpython-312.pyc b/custom_components/scheduler/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index 648718dc..00000000 Binary files a/custom_components/scheduler/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/scheduler/__pycache__/const.cpython-312.pyc b/custom_components/scheduler/__pycache__/const.cpython-312.pyc deleted file mode 100644 index d71f01c8..00000000 Binary files a/custom_components/scheduler/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/scheduler/__pycache__/store.cpython-312.pyc b/custom_components/scheduler/__pycache__/store.cpython-312.pyc deleted file mode 100644 index 421fbaaf..00000000 Binary files a/custom_components/scheduler/__pycache__/store.cpython-312.pyc and /dev/null differ diff --git a/custom_components/scheduler/__pycache__/store.cpython-313.pyc b/custom_components/scheduler/__pycache__/store.cpython-313.pyc index b9da5897..66952595 100644 Binary files a/custom_components/scheduler/__pycache__/store.cpython-313.pyc and b/custom_components/scheduler/__pycache__/store.cpython-313.pyc differ diff --git a/custom_components/scheduler/__pycache__/switch.cpython-312.pyc b/custom_components/scheduler/__pycache__/switch.cpython-312.pyc deleted file mode 100644 index 31eb6000..00000000 Binary files a/custom_components/scheduler/__pycache__/switch.cpython-312.pyc and /dev/null differ diff --git a/custom_components/scheduler/__pycache__/switch.cpython-313.pyc b/custom_components/scheduler/__pycache__/switch.cpython-313.pyc index 01a1cffc..18752936 100644 Binary files a/custom_components/scheduler/__pycache__/switch.cpython-313.pyc and b/custom_components/scheduler/__pycache__/switch.cpython-313.pyc differ diff --git a/custom_components/scheduler/__pycache__/timer.cpython-312.pyc b/custom_components/scheduler/__pycache__/timer.cpython-312.pyc deleted file mode 100644 index ef92334c..00000000 Binary files a/custom_components/scheduler/__pycache__/timer.cpython-312.pyc and /dev/null differ diff --git a/custom_components/scheduler/__pycache__/timer.cpython-313.pyc b/custom_components/scheduler/__pycache__/timer.cpython-313.pyc index 7ce9dc16..2be652f9 100644 Binary files a/custom_components/scheduler/__pycache__/timer.cpython-313.pyc and b/custom_components/scheduler/__pycache__/timer.cpython-313.pyc differ diff --git a/custom_components/scheduler/__pycache__/websockets.cpython-312.pyc b/custom_components/scheduler/__pycache__/websockets.cpython-312.pyc deleted file mode 100644 index 38e42a02..00000000 Binary files a/custom_components/scheduler/__pycache__/websockets.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index ee9017f1..00000000 Binary files a/custom_components/spook/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/binary_sensor.cpython-312.pyc b/custom_components/spook/__pycache__/binary_sensor.cpython-312.pyc deleted file mode 100644 index 9c80038e..00000000 Binary files a/custom_components/spook/__pycache__/binary_sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/button.cpython-312.pyc b/custom_components/spook/__pycache__/button.cpython-312.pyc deleted file mode 100644 index ebe69b05..00000000 Binary files a/custom_components/spook/__pycache__/button.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/config_flow.cpython-312.pyc b/custom_components/spook/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index ad2cc3eb..00000000 Binary files a/custom_components/spook/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/const.cpython-312.pyc b/custom_components/spook/__pycache__/const.cpython-312.pyc deleted file mode 100644 index 01361b79..00000000 Binary files a/custom_components/spook/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/entity.cpython-312.pyc b/custom_components/spook/__pycache__/entity.cpython-312.pyc deleted file mode 100644 index ff692653..00000000 Binary files a/custom_components/spook/__pycache__/entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/event.cpython-312.pyc b/custom_components/spook/__pycache__/event.cpython-312.pyc deleted file mode 100644 index 511f75a2..00000000 Binary files a/custom_components/spook/__pycache__/event.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/number.cpython-312.pyc b/custom_components/spook/__pycache__/number.cpython-312.pyc deleted file mode 100644 index fad2a297..00000000 Binary files a/custom_components/spook/__pycache__/number.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/repairs.cpython-312.pyc b/custom_components/spook/__pycache__/repairs.cpython-312.pyc deleted file mode 100644 index 6b0de84f..00000000 Binary files a/custom_components/spook/__pycache__/repairs.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/repairs.cpython-313.pyc b/custom_components/spook/__pycache__/repairs.cpython-313.pyc index 3537bee5..5bf837d5 100644 Binary files a/custom_components/spook/__pycache__/repairs.cpython-313.pyc and b/custom_components/spook/__pycache__/repairs.cpython-313.pyc differ diff --git a/custom_components/spook/__pycache__/select.cpython-312.pyc b/custom_components/spook/__pycache__/select.cpython-312.pyc deleted file mode 100644 index 078db905..00000000 Binary files a/custom_components/spook/__pycache__/select.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/sensor.cpython-312.pyc b/custom_components/spook/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index f6ff2d20..00000000 Binary files a/custom_components/spook/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/services.cpython-312.pyc b/custom_components/spook/__pycache__/services.cpython-312.pyc deleted file mode 100644 index 5ec1378c..00000000 Binary files a/custom_components/spook/__pycache__/services.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/services.cpython-313.pyc b/custom_components/spook/__pycache__/services.cpython-313.pyc index fa4e7380..eaab6b36 100644 Binary files a/custom_components/spook/__pycache__/services.cpython-313.pyc and b/custom_components/spook/__pycache__/services.cpython-313.pyc differ diff --git a/custom_components/spook/__pycache__/switch.cpython-312.pyc b/custom_components/spook/__pycache__/switch.cpython-312.pyc deleted file mode 100644 index 0699e433..00000000 Binary files a/custom_components/spook/__pycache__/switch.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/templating.cpython-312.pyc b/custom_components/spook/__pycache__/templating.cpython-312.pyc deleted file mode 100644 index 9a043d5d..00000000 Binary files a/custom_components/spook/__pycache__/templating.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/time.cpython-312.pyc b/custom_components/spook/__pycache__/time.cpython-312.pyc deleted file mode 100644 index 2966efa0..00000000 Binary files a/custom_components/spook/__pycache__/time.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/util.cpython-312.pyc b/custom_components/spook/__pycache__/util.cpython-312.pyc deleted file mode 100644 index b0d5acb0..00000000 Binary files a/custom_components/spook/__pycache__/util.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/__pycache__/util.cpython-313.pyc b/custom_components/spook/__pycache__/util.cpython-313.pyc index 58516813..cbc11ffb 100644 Binary files a/custom_components/spook/__pycache__/util.cpython-313.pyc and b/custom_components/spook/__pycache__/util.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index f31577d5..00000000 Binary files a/custom_components/spook/ectoplasms/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/automation/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/automation/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 793a4b5f..00000000 Binary files a/custom_components/spook/ectoplasms/automation/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 3d1a1114..00000000 Binary files a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_area_references.cpython-312.pyc b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_area_references.cpython-312.pyc deleted file mode 100644 index 9638e0b7..00000000 Binary files a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_area_references.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_area_references.cpython-313.pyc b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_area_references.cpython-313.pyc index c347faf6..34201412 100644 Binary files a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_area_references.cpython-313.pyc and b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_area_references.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_device_references.cpython-312.pyc b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_device_references.cpython-312.pyc deleted file mode 100644 index e2b39773..00000000 Binary files a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_device_references.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_device_references.cpython-313.pyc b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_device_references.cpython-313.pyc index b642879a..10973a46 100644 Binary files a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_device_references.cpython-313.pyc and b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_device_references.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_entity_references.cpython-312.pyc b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_entity_references.cpython-312.pyc deleted file mode 100644 index 3b582788..00000000 Binary files a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_entity_references.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_entity_references.cpython-313.pyc b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_entity_references.cpython-313.pyc index 2b4817d1..e8d7f19c 100644 Binary files a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_entity_references.cpython-313.pyc and b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_entity_references.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_floor_references.cpython-312.pyc b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_floor_references.cpython-312.pyc deleted file mode 100644 index c238ca6d..00000000 Binary files a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_floor_references.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_floor_references.cpython-313.pyc b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_floor_references.cpython-313.pyc index 12c7ecb8..613631de 100644 Binary files a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_floor_references.cpython-313.pyc and b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_floor_references.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_label_references.cpython-312.pyc b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_label_references.cpython-312.pyc deleted file mode 100644 index 3806871b..00000000 Binary files a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_label_references.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_label_references.cpython-313.pyc b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_label_references.cpython-313.pyc index 7c345207..18bc3208 100644 Binary files a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_label_references.cpython-313.pyc and b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_label_references.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_service_references.cpython-312.pyc b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_service_references.cpython-312.pyc deleted file mode 100644 index 7757d072..00000000 Binary files a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_service_references.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_service_references.cpython-313.pyc b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_service_references.cpython-313.pyc index e3dac993..a1800d99 100644 Binary files a/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_service_references.cpython-313.pyc and b/custom_components/spook/ectoplasms/automation/repairs/__pycache__/unknown_service_references.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/blueprint/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/blueprint/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 13a8932d..00000000 Binary files a/custom_components/spook/ectoplasms/blueprint/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/blueprint/services/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/blueprint/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 898adc47..00000000 Binary files a/custom_components/spook/ectoplasms/blueprint/services/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/blueprint/services/__pycache__/importer.cpython-312.pyc b/custom_components/spook/ectoplasms/blueprint/services/__pycache__/importer.cpython-312.pyc deleted file mode 100644 index 7a1e9204..00000000 Binary files a/custom_components/spook/ectoplasms/blueprint/services/__pycache__/importer.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/cloud/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/cloud/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 3111d9f8..00000000 Binary files a/custom_components/spook/ectoplasms/cloud/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/cloud/__pycache__/entity.cpython-312.pyc b/custom_components/spook/ectoplasms/cloud/__pycache__/entity.cpython-312.pyc deleted file mode 100644 index e71c3e84..00000000 Binary files a/custom_components/spook/ectoplasms/cloud/__pycache__/entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/cloud/__pycache__/switch.cpython-312.pyc b/custom_components/spook/ectoplasms/cloud/__pycache__/switch.cpython-312.pyc deleted file mode 100644 index 524d3d93..00000000 Binary files a/custom_components/spook/ectoplasms/cloud/__pycache__/switch.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/cloud/__pycache__/switch.cpython-313.pyc b/custom_components/spook/ectoplasms/cloud/__pycache__/switch.cpython-313.pyc index b13380dd..1902b699 100644 Binary files a/custom_components/spook/ectoplasms/cloud/__pycache__/switch.cpython-313.pyc and b/custom_components/spook/ectoplasms/cloud/__pycache__/switch.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/group/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/group/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 1ca46743..00000000 Binary files a/custom_components/spook/ectoplasms/group/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/group/repairs/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/group/repairs/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index ffc41f57..00000000 Binary files a/custom_components/spook/ectoplasms/group/repairs/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/group/repairs/__pycache__/unknown_members.cpython-312.pyc b/custom_components/spook/ectoplasms/group/repairs/__pycache__/unknown_members.cpython-312.pyc deleted file mode 100644 index 965f70a3..00000000 Binary files a/custom_components/spook/ectoplasms/group/repairs/__pycache__/unknown_members.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/group/repairs/__pycache__/unknown_members.cpython-313.pyc b/custom_components/spook/ectoplasms/group/repairs/__pycache__/unknown_members.cpython-313.pyc index a4e7cb70..5e872bc8 100644 Binary files a/custom_components/spook/ectoplasms/group/repairs/__pycache__/unknown_members.cpython-313.pyc and b/custom_components/spook/ectoplasms/group/repairs/__pycache__/unknown_members.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/homeassistant/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a6948a5f..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/__pycache__/button.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/__pycache__/button.cpython-312.pyc deleted file mode 100644 index ebea5752..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/__pycache__/button.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/__pycache__/button.cpython-313.pyc b/custom_components/spook/ectoplasms/homeassistant/__pycache__/button.cpython-313.pyc index 764b928e..5dc5e7c6 100644 Binary files a/custom_components/spook/ectoplasms/homeassistant/__pycache__/button.cpython-313.pyc and b/custom_components/spook/ectoplasms/homeassistant/__pycache__/button.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/homeassistant/__pycache__/entity.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/__pycache__/entity.cpython-312.pyc deleted file mode 100644 index ecc3fa68..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/__pycache__/entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/__pycache__/sensor.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index a462403c..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/__pycache__/sensor.cpython-313.pyc b/custom_components/spook/ectoplasms/homeassistant/__pycache__/sensor.cpython-313.pyc index 67c7ee4e..531bd721 100644 Binary files a/custom_components/spook/ectoplasms/homeassistant/__pycache__/sensor.cpython-313.pyc and b/custom_components/spook/ectoplasms/homeassistant/__pycache__/sensor.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index b14c617f..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_alias_to_area.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_alias_to_area.cpython-312.pyc deleted file mode 100644 index 8eac8faf..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_alias_to_area.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_alias_to_floor.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_alias_to_floor.cpython-312.pyc deleted file mode 100644 index 9311d1a1..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_alias_to_floor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_area_to_floor.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_area_to_floor.cpython-312.pyc deleted file mode 100644 index 5751f27f..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_area_to_floor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_device_to_area.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_device_to_area.cpython-312.pyc deleted file mode 100644 index 5960f0c5..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_device_to_area.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_entity_to_area.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_entity_to_area.cpython-312.pyc deleted file mode 100644 index 80653c2d..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_entity_to_area.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_label_to_area.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_label_to_area.cpython-312.pyc deleted file mode 100644 index 17078250..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_label_to_area.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_label_to_device.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_label_to_device.cpython-312.pyc deleted file mode 100644 index e5605dd9..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_label_to_device.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_label_to_entity.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_label_to_entity.cpython-312.pyc deleted file mode 100644 index 3ee1d014..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/add_label_to_entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/create_area.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/create_area.cpython-312.pyc deleted file mode 100644 index bb030465..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/create_area.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/create_floor.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/create_floor.cpython-312.pyc deleted file mode 100644 index 1c114fba..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/create_floor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/create_label.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/create_label.cpython-312.pyc deleted file mode 100644 index a0ec69e7..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/create_label.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/delete_all_orphaned_entities.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/delete_all_orphaned_entities.cpython-312.pyc deleted file mode 100644 index 546be2d9..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/delete_all_orphaned_entities.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/delete_area.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/delete_area.cpython-312.pyc deleted file mode 100644 index ab863859..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/delete_area.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/delete_floor.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/delete_floor.cpython-312.pyc deleted file mode 100644 index d2582787..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/delete_floor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/delete_label.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/delete_label.cpython-312.pyc deleted file mode 100644 index 4b0ca5d2..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/delete_label.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/disable_config_entry.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/disable_config_entry.cpython-312.pyc deleted file mode 100644 index 0c26831f..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/disable_config_entry.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/disable_device.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/disable_device.cpython-312.pyc deleted file mode 100644 index 093bd347..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/disable_device.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/disable_entity.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/disable_entity.cpython-312.pyc deleted file mode 100644 index 3d619eda..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/disable_entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/disable_polling.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/disable_polling.cpython-312.pyc deleted file mode 100644 index 50311f11..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/disable_polling.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/enable_config_entry.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/enable_config_entry.cpython-312.pyc deleted file mode 100644 index 65235336..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/enable_config_entry.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/enable_device.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/enable_device.cpython-312.pyc deleted file mode 100644 index dddb1615..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/enable_device.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/enable_entity.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/enable_entity.cpython-312.pyc deleted file mode 100644 index 8271bc92..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/enable_entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/enable_polling.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/enable_polling.cpython-312.pyc deleted file mode 100644 index e3d60a0c..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/enable_polling.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/hide_entity.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/hide_entity.cpython-312.pyc deleted file mode 100644 index 01ff8e70..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/hide_entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/ignore_all_discovered.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/ignore_all_discovered.cpython-312.pyc deleted file mode 100644 index 96dd9a5f..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/ignore_all_discovered.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/ignore_all_discovered.cpython-313.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/ignore_all_discovered.cpython-313.pyc index df879e37..23d684e0 100644 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/ignore_all_discovered.cpython-313.pyc and b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/ignore_all_discovered.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/list_orphaned_database_entities.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/list_orphaned_database_entities.cpython-312.pyc deleted file mode 100644 index 8f96af8a..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/list_orphaned_database_entities.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/list_orphaned_database_entities.cpython-313.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/list_orphaned_database_entities.cpython-313.pyc index 97b49a46..aecba9b7 100644 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/list_orphaned_database_entities.cpython-313.pyc and b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/list_orphaned_database_entities.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_alias_from_area.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_alias_from_area.cpython-312.pyc deleted file mode 100644 index c11f1cb4..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_alias_from_area.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_alias_from_floor.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_alias_from_floor.cpython-312.pyc deleted file mode 100644 index b758fabf..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_alias_from_floor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_area_from_floor.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_area_from_floor.cpython-312.pyc deleted file mode 100644 index 60a06a2e..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_area_from_floor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_device_from_area.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_device_from_area.cpython-312.pyc deleted file mode 100644 index fb28c663..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_device_from_area.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_entity_from_area.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_entity_from_area.cpython-312.pyc deleted file mode 100644 index 66ede64f..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_entity_from_area.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_label_from_area.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_label_from_area.cpython-312.pyc deleted file mode 100644 index 3a20cf2f..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_label_from_area.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_label_from_device.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_label_from_device.cpython-312.pyc deleted file mode 100644 index 2d701a17..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_label_from_device.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_label_from_entity.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_label_from_entity.cpython-312.pyc deleted file mode 100644 index 95a7ce28..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/remove_label_from_entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/restart.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/restart.cpython-312.pyc deleted file mode 100644 index 1215f05e..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/restart.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/set_area_aliases.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/set_area_aliases.cpython-312.pyc deleted file mode 100644 index ab28a82c..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/set_area_aliases.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/set_floor_aliases.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/set_floor_aliases.cpython-312.pyc deleted file mode 100644 index c243e50d..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/set_floor_aliases.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/unhide_entity.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/unhide_entity.cpython-312.pyc deleted file mode 100644 index 4a8330b3..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/unhide_entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/update_entity_id.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/update_entity_id.cpython-312.pyc deleted file mode 100644 index 84edfde0..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/services/__pycache__/update_entity_id.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a6c84776..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/flatten.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/flatten.cpython-312.pyc deleted file mode 100644 index 2431eefa..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/flatten.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch.cpython-312.pyc deleted file mode 100644 index 851ce694..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch.cpython-313.pyc b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch.cpython-313.pyc index 7984740b..4c6135dd 100644 Binary files a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch.cpython-313.pyc and b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch_filter.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch_filter.cpython-312.pyc deleted file mode 100644 index 5be430fe..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch_filter.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch_filter.cpython-313.pyc b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch_filter.cpython-313.pyc index 678fbe2b..0f5410e0 100644 Binary files a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch_filter.cpython-313.pyc and b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/fnmatch_filter.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/md5.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/md5.cpython-312.pyc deleted file mode 100644 index e390366f..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/md5.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/sha1.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/sha1.cpython-312.pyc deleted file mode 100644 index f56108e0..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/sha1.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/sha256.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/sha256.cpython-312.pyc deleted file mode 100644 index ea448696..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/sha256.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/sha512.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/sha512.cpython-312.pyc deleted file mode 100644 index 42e11dab..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/sha512.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/shuffle.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/shuffle.cpython-312.pyc deleted file mode 100644 index c1166173..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/shuffle.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/typeof.cpython-312.pyc b/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/typeof.cpython-312.pyc deleted file mode 100644 index 67823eb7..00000000 Binary files a/custom_components/spook/ectoplasms/homeassistant/templating/__pycache__/typeof.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/input_number/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/input_number/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 07fa1233..00000000 Binary files a/custom_components/spook/ectoplasms/input_number/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/input_number/services/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/input_number/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 4b4585e5..00000000 Binary files a/custom_components/spook/ectoplasms/input_number/services/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/input_number/services/__pycache__/decrement.cpython-312.pyc b/custom_components/spook/ectoplasms/input_number/services/__pycache__/decrement.cpython-312.pyc deleted file mode 100644 index 0d55a582..00000000 Binary files a/custom_components/spook/ectoplasms/input_number/services/__pycache__/decrement.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/input_number/services/__pycache__/increment.cpython-312.pyc b/custom_components/spook/ectoplasms/input_number/services/__pycache__/increment.cpython-312.pyc deleted file mode 100644 index c846ec76..00000000 Binary files a/custom_components/spook/ectoplasms/input_number/services/__pycache__/increment.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/input_number/services/__pycache__/max.cpython-312.pyc b/custom_components/spook/ectoplasms/input_number/services/__pycache__/max.cpython-312.pyc deleted file mode 100644 index 11166061..00000000 Binary files a/custom_components/spook/ectoplasms/input_number/services/__pycache__/max.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/input_number/services/__pycache__/min.cpython-312.pyc b/custom_components/spook/ectoplasms/input_number/services/__pycache__/min.cpython-312.pyc deleted file mode 100644 index b11bab40..00000000 Binary files a/custom_components/spook/ectoplasms/input_number/services/__pycache__/min.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/input_select/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/input_select/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 276577d9..00000000 Binary files a/custom_components/spook/ectoplasms/input_select/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/input_select/services/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/input_select/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 023fb011..00000000 Binary files a/custom_components/spook/ectoplasms/input_select/services/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/input_select/services/__pycache__/random.cpython-312.pyc b/custom_components/spook/ectoplasms/input_select/services/__pycache__/random.cpython-312.pyc deleted file mode 100644 index fd6f7976..00000000 Binary files a/custom_components/spook/ectoplasms/input_select/services/__pycache__/random.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/input_select/services/__pycache__/shuffle.cpython-312.pyc b/custom_components/spook/ectoplasms/input_select/services/__pycache__/shuffle.cpython-312.pyc deleted file mode 100644 index 7a40a5d3..00000000 Binary files a/custom_components/spook/ectoplasms/input_select/services/__pycache__/shuffle.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/input_select/services/__pycache__/sort.cpython-312.pyc b/custom_components/spook/ectoplasms/input_select/services/__pycache__/sort.cpython-312.pyc deleted file mode 100644 index 562f8c38..00000000 Binary files a/custom_components/spook/ectoplasms/input_select/services/__pycache__/sort.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/integration/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/integration/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 8e03b804..00000000 Binary files a/custom_components/spook/ectoplasms/integration/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/integration/repairs/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/integration/repairs/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 5f01a22e..00000000 Binary files a/custom_components/spook/ectoplasms/integration/repairs/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/integration/repairs/__pycache__/unknown_source.cpython-312.pyc b/custom_components/spook/ectoplasms/integration/repairs/__pycache__/unknown_source.cpython-312.pyc deleted file mode 100644 index 973c5d3e..00000000 Binary files a/custom_components/spook/ectoplasms/integration/repairs/__pycache__/unknown_source.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/lovelace/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/lovelace/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e3cd5b67..00000000 Binary files a/custom_components/spook/ectoplasms/lovelace/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/lovelace/repairs/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/lovelace/repairs/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 9c1bf010..00000000 Binary files a/custom_components/spook/ectoplasms/lovelace/repairs/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/lovelace/repairs/__pycache__/unknown_entity_references.cpython-312.pyc b/custom_components/spook/ectoplasms/lovelace/repairs/__pycache__/unknown_entity_references.cpython-312.pyc deleted file mode 100644 index 43f428e1..00000000 Binary files a/custom_components/spook/ectoplasms/lovelace/repairs/__pycache__/unknown_entity_references.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/lovelace/repairs/__pycache__/unknown_entity_references.cpython-313.pyc b/custom_components/spook/ectoplasms/lovelace/repairs/__pycache__/unknown_entity_references.cpython-313.pyc index 78985c55..3a8e1cf1 100644 Binary files a/custom_components/spook/ectoplasms/lovelace/repairs/__pycache__/unknown_entity_references.cpython-313.pyc and b/custom_components/spook/ectoplasms/lovelace/repairs/__pycache__/unknown_entity_references.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/number/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/number/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index cabfdf46..00000000 Binary files a/custom_components/spook/ectoplasms/number/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/number/services/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/number/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e50aed7a..00000000 Binary files a/custom_components/spook/ectoplasms/number/services/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/number/services/__pycache__/decrement.cpython-312.pyc b/custom_components/spook/ectoplasms/number/services/__pycache__/decrement.cpython-312.pyc deleted file mode 100644 index e218317e..00000000 Binary files a/custom_components/spook/ectoplasms/number/services/__pycache__/decrement.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/number/services/__pycache__/increment.cpython-312.pyc b/custom_components/spook/ectoplasms/number/services/__pycache__/increment.cpython-312.pyc deleted file mode 100644 index cf7b5ea6..00000000 Binary files a/custom_components/spook/ectoplasms/number/services/__pycache__/increment.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/number/services/__pycache__/max.cpython-312.pyc b/custom_components/spook/ectoplasms/number/services/__pycache__/max.cpython-312.pyc deleted file mode 100644 index 5048694c..00000000 Binary files a/custom_components/spook/ectoplasms/number/services/__pycache__/max.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/number/services/__pycache__/min.cpython-312.pyc b/custom_components/spook/ectoplasms/number/services/__pycache__/min.cpython-312.pyc deleted file mode 100644 index 1f9da99e..00000000 Binary files a/custom_components/spook/ectoplasms/number/services/__pycache__/min.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/person/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/person/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index c2848922..00000000 Binary files a/custom_components/spook/ectoplasms/person/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/person/services/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/person/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 6f225901..00000000 Binary files a/custom_components/spook/ectoplasms/person/services/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/person/services/__pycache__/add_device_tracker.cpython-312.pyc b/custom_components/spook/ectoplasms/person/services/__pycache__/add_device_tracker.cpython-312.pyc deleted file mode 100644 index e7460a67..00000000 Binary files a/custom_components/spook/ectoplasms/person/services/__pycache__/add_device_tracker.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/person/services/__pycache__/remove_device_tracker.cpython-312.pyc b/custom_components/spook/ectoplasms/person/services/__pycache__/remove_device_tracker.cpython-312.pyc deleted file mode 100644 index 4462a403..00000000 Binary files a/custom_components/spook/ectoplasms/person/services/__pycache__/remove_device_tracker.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/proximity/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/proximity/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 48e02190..00000000 Binary files a/custom_components/spook/ectoplasms/proximity/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a07370a5..00000000 Binary files a/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_ignored_zones.cpython-312.pyc b/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_ignored_zones.cpython-312.pyc deleted file mode 100644 index d965b79a..00000000 Binary files a/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_ignored_zones.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_ignored_zones.cpython-313.pyc b/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_ignored_zones.cpython-313.pyc index e219afd0..28a1752b 100644 Binary files a/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_ignored_zones.cpython-313.pyc and b/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_ignored_zones.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_tracked_entities.cpython-312.pyc b/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_tracked_entities.cpython-312.pyc deleted file mode 100644 index ec88d40e..00000000 Binary files a/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_tracked_entities.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_tracked_entities.cpython-313.pyc b/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_tracked_entities.cpython-313.pyc index d72de645..386ac1f3 100644 Binary files a/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_tracked_entities.cpython-313.pyc and b/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_tracked_entities.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_zone.cpython-312.pyc b/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_zone.cpython-312.pyc deleted file mode 100644 index 613aee60..00000000 Binary files a/custom_components/spook/ectoplasms/proximity/repairs/__pycache__/unknown_zone.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/recorder/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/recorder/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 2ec19b43..00000000 Binary files a/custom_components/spook/ectoplasms/recorder/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/recorder/services/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/recorder/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a2ab045f..00000000 Binary files a/custom_components/spook/ectoplasms/recorder/services/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/recorder/services/__pycache__/import_statistics.cpython-312.pyc b/custom_components/spook/ectoplasms/recorder/services/__pycache__/import_statistics.cpython-312.pyc deleted file mode 100644 index bbadf8fd..00000000 Binary files a/custom_components/spook/ectoplasms/recorder/services/__pycache__/import_statistics.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/repairs/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/repairs/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 751b2067..00000000 Binary files a/custom_components/spook/ectoplasms/repairs/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/repairs/__pycache__/button.cpython-312.pyc b/custom_components/spook/ectoplasms/repairs/__pycache__/button.cpython-312.pyc deleted file mode 100644 index 11b33572..00000000 Binary files a/custom_components/spook/ectoplasms/repairs/__pycache__/button.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/repairs/__pycache__/button.cpython-313.pyc b/custom_components/spook/ectoplasms/repairs/__pycache__/button.cpython-313.pyc index 026e6dfb..7250dc71 100644 Binary files a/custom_components/spook/ectoplasms/repairs/__pycache__/button.cpython-313.pyc and b/custom_components/spook/ectoplasms/repairs/__pycache__/button.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/repairs/__pycache__/entity.cpython-312.pyc b/custom_components/spook/ectoplasms/repairs/__pycache__/entity.cpython-312.pyc deleted file mode 100644 index 60b555e8..00000000 Binary files a/custom_components/spook/ectoplasms/repairs/__pycache__/entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/repairs/__pycache__/event.cpython-312.pyc b/custom_components/spook/ectoplasms/repairs/__pycache__/event.cpython-312.pyc deleted file mode 100644 index f4dedad8..00000000 Binary files a/custom_components/spook/ectoplasms/repairs/__pycache__/event.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/repairs/__pycache__/sensor.cpython-312.pyc b/custom_components/spook/ectoplasms/repairs/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index 9756497a..00000000 Binary files a/custom_components/spook/ectoplasms/repairs/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/repairs/__pycache__/sensor.cpython-313.pyc b/custom_components/spook/ectoplasms/repairs/__pycache__/sensor.cpython-313.pyc index d6546ad9..9ca45634 100644 Binary files a/custom_components/spook/ectoplasms/repairs/__pycache__/sensor.cpython-313.pyc and b/custom_components/spook/ectoplasms/repairs/__pycache__/sensor.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/repairs/services/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/repairs/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 44ab07f2..00000000 Binary files a/custom_components/spook/ectoplasms/repairs/services/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/repairs/services/__pycache__/create.cpython-312.pyc b/custom_components/spook/ectoplasms/repairs/services/__pycache__/create.cpython-312.pyc deleted file mode 100644 index 2a7b1801..00000000 Binary files a/custom_components/spook/ectoplasms/repairs/services/__pycache__/create.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/repairs/services/__pycache__/ignore_all.cpython-312.pyc b/custom_components/spook/ectoplasms/repairs/services/__pycache__/ignore_all.cpython-312.pyc deleted file mode 100644 index 3b60df44..00000000 Binary files a/custom_components/spook/ectoplasms/repairs/services/__pycache__/ignore_all.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/repairs/services/__pycache__/remove.cpython-312.pyc b/custom_components/spook/ectoplasms/repairs/services/__pycache__/remove.cpython-312.pyc deleted file mode 100644 index 7ead2be9..00000000 Binary files a/custom_components/spook/ectoplasms/repairs/services/__pycache__/remove.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/repairs/services/__pycache__/unignore_all.cpython-312.pyc b/custom_components/spook/ectoplasms/repairs/services/__pycache__/unignore_all.cpython-312.pyc deleted file mode 100644 index 57444ed8..00000000 Binary files a/custom_components/spook/ectoplasms/repairs/services/__pycache__/unignore_all.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/scene/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/scene/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 39a62d94..00000000 Binary files a/custom_components/spook/ectoplasms/scene/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/scene/repairs/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/scene/repairs/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 77ebd30c..00000000 Binary files a/custom_components/spook/ectoplasms/scene/repairs/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/scene/repairs/__pycache__/unknown_entity_references.cpython-312.pyc b/custom_components/spook/ectoplasms/scene/repairs/__pycache__/unknown_entity_references.cpython-312.pyc deleted file mode 100644 index 82837242..00000000 Binary files a/custom_components/spook/ectoplasms/scene/repairs/__pycache__/unknown_entity_references.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/scene/repairs/__pycache__/unknown_entity_references.cpython-313.pyc b/custom_components/spook/ectoplasms/scene/repairs/__pycache__/unknown_entity_references.cpython-313.pyc index 4b6016ca..bc0767e6 100644 Binary files a/custom_components/spook/ectoplasms/scene/repairs/__pycache__/unknown_entity_references.cpython-313.pyc and b/custom_components/spook/ectoplasms/scene/repairs/__pycache__/unknown_entity_references.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/script/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/script/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 90cf6977..00000000 Binary files a/custom_components/spook/ectoplasms/script/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/script/repairs/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/script/repairs/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 1e48dec8..00000000 Binary files a/custom_components/spook/ectoplasms/script/repairs/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_area_references.cpython-312.pyc b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_area_references.cpython-312.pyc deleted file mode 100644 index d56115e1..00000000 Binary files a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_area_references.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_area_references.cpython-313.pyc b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_area_references.cpython-313.pyc index 5b5fa562..274238da 100644 Binary files a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_area_references.cpython-313.pyc and b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_area_references.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_device_references.cpython-312.pyc b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_device_references.cpython-312.pyc deleted file mode 100644 index 8589df5b..00000000 Binary files a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_device_references.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_device_references.cpython-313.pyc b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_device_references.cpython-313.pyc index 62d32c9e..8e78d4fc 100644 Binary files a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_device_references.cpython-313.pyc and b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_device_references.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_entity_references.cpython-312.pyc b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_entity_references.cpython-312.pyc deleted file mode 100644 index 799a439f..00000000 Binary files a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_entity_references.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_entity_references.cpython-313.pyc b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_entity_references.cpython-313.pyc index 6d0bbca0..8ff7b875 100644 Binary files a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_entity_references.cpython-313.pyc and b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_entity_references.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_floor_references.cpython-312.pyc b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_floor_references.cpython-312.pyc deleted file mode 100644 index edc42209..00000000 Binary files a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_floor_references.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_floor_references.cpython-313.pyc b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_floor_references.cpython-313.pyc index 0e499c6d..1d4997c0 100644 Binary files a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_floor_references.cpython-313.pyc and b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_floor_references.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_label_references.cpython-312.pyc b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_label_references.cpython-312.pyc deleted file mode 100644 index 384b2248..00000000 Binary files a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_label_references.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_label_references.cpython-313.pyc b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_label_references.cpython-313.pyc index cc132489..6606e443 100644 Binary files a/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_label_references.cpython-313.pyc and b/custom_components/spook/ectoplasms/script/repairs/__pycache__/unknown_label_references.cpython-313.pyc differ diff --git a/custom_components/spook/ectoplasms/select/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/select/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 772b7223..00000000 Binary files a/custom_components/spook/ectoplasms/select/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/select/services/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/select/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 1a0fe662..00000000 Binary files a/custom_components/spook/ectoplasms/select/services/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/select/services/__pycache__/random.cpython-312.pyc b/custom_components/spook/ectoplasms/select/services/__pycache__/random.cpython-312.pyc deleted file mode 100644 index b975b0a8..00000000 Binary files a/custom_components/spook/ectoplasms/select/services/__pycache__/random.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/spook/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/spook/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 3f035e58..00000000 Binary files a/custom_components/spook/ectoplasms/spook/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/spook/services/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/spook/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 4ab00bac..00000000 Binary files a/custom_components/spook/ectoplasms/spook/services/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/spook/services/__pycache__/boo.cpython-312.pyc b/custom_components/spook/ectoplasms/spook/services/__pycache__/boo.cpython-312.pyc deleted file mode 100644 index d2a2f23c..00000000 Binary files a/custom_components/spook/ectoplasms/spook/services/__pycache__/boo.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/spook/services/__pycache__/random_fail.cpython-312.pyc b/custom_components/spook/ectoplasms/spook/services/__pycache__/random_fail.cpython-312.pyc deleted file mode 100644 index f2ca80be..00000000 Binary files a/custom_components/spook/ectoplasms/spook/services/__pycache__/random_fail.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/switch_as_x/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/switch_as_x/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index bb0995de..00000000 Binary files a/custom_components/spook/ectoplasms/switch_as_x/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/switch_as_x/repairs/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/switch_as_x/repairs/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index bb65ab64..00000000 Binary files a/custom_components/spook/ectoplasms/switch_as_x/repairs/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/switch_as_x/repairs/__pycache__/unknown_source.cpython-312.pyc b/custom_components/spook/ectoplasms/switch_as_x/repairs/__pycache__/unknown_source.cpython-312.pyc deleted file mode 100644 index 7bde55f0..00000000 Binary files a/custom_components/spook/ectoplasms/switch_as_x/repairs/__pycache__/unknown_source.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/timer/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/timer/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 8cc5f056..00000000 Binary files a/custom_components/spook/ectoplasms/timer/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/timer/services/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/timer/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 6bf10789..00000000 Binary files a/custom_components/spook/ectoplasms/timer/services/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/timer/services/__pycache__/set_duration.cpython-312.pyc b/custom_components/spook/ectoplasms/timer/services/__pycache__/set_duration.cpython-312.pyc deleted file mode 100644 index c5d186c8..00000000 Binary files a/custom_components/spook/ectoplasms/timer/services/__pycache__/set_duration.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/trend/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/trend/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index f33f00da..00000000 Binary files a/custom_components/spook/ectoplasms/trend/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/trend/repairs/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/trend/repairs/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index acfb2f70..00000000 Binary files a/custom_components/spook/ectoplasms/trend/repairs/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/trend/repairs/__pycache__/unknown_source.cpython-312.pyc b/custom_components/spook/ectoplasms/trend/repairs/__pycache__/unknown_source.cpython-312.pyc deleted file mode 100644 index d55f09d7..00000000 Binary files a/custom_components/spook/ectoplasms/trend/repairs/__pycache__/unknown_source.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/utility_meter/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/utility_meter/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 7b69b0c6..00000000 Binary files a/custom_components/spook/ectoplasms/utility_meter/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/utility_meter/repairs/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/utility_meter/repairs/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 43bb900c..00000000 Binary files a/custom_components/spook/ectoplasms/utility_meter/repairs/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/utility_meter/repairs/__pycache__/unknown_source.cpython-312.pyc b/custom_components/spook/ectoplasms/utility_meter/repairs/__pycache__/unknown_source.cpython-312.pyc deleted file mode 100644 index 48303c18..00000000 Binary files a/custom_components/spook/ectoplasms/utility_meter/repairs/__pycache__/unknown_source.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/zone/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/zone/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 340d24c7..00000000 Binary files a/custom_components/spook/ectoplasms/zone/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/zone/services/__pycache__/__init__.cpython-312.pyc b/custom_components/spook/ectoplasms/zone/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 865f0fd0..00000000 Binary files a/custom_components/spook/ectoplasms/zone/services/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/zone/services/__pycache__/create.cpython-312.pyc b/custom_components/spook/ectoplasms/zone/services/__pycache__/create.cpython-312.pyc deleted file mode 100644 index c3302654..00000000 Binary files a/custom_components/spook/ectoplasms/zone/services/__pycache__/create.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/zone/services/__pycache__/delete.cpython-312.pyc b/custom_components/spook/ectoplasms/zone/services/__pycache__/delete.cpython-312.pyc deleted file mode 100644 index 1acd1473..00000000 Binary files a/custom_components/spook/ectoplasms/zone/services/__pycache__/delete.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spook/ectoplasms/zone/services/__pycache__/update.cpython-312.pyc b/custom_components/spook/ectoplasms/zone/services/__pycache__/update.cpython-312.pyc deleted file mode 100644 index 3fd3c9f7..00000000 Binary files a/custom_components/spook/ectoplasms/zone/services/__pycache__/update.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spotcast/__pycache__/__init__.cpython-312.pyc b/custom_components/spotcast/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 6d4759c9..00000000 Binary files a/custom_components/spotcast/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spotcast/__pycache__/__init__.cpython-313.pyc b/custom_components/spotcast/__pycache__/__init__.cpython-313.pyc index bca93e74..bb2e00d9 100644 Binary files a/custom_components/spotcast/__pycache__/__init__.cpython-313.pyc and b/custom_components/spotcast/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/spotcast/__pycache__/cast.cpython-312.pyc b/custom_components/spotcast/__pycache__/cast.cpython-312.pyc deleted file mode 100644 index 8cce1da9..00000000 Binary files a/custom_components/spotcast/__pycache__/cast.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spotcast/__pycache__/const.cpython-312.pyc b/custom_components/spotcast/__pycache__/const.cpython-312.pyc deleted file mode 100644 index 2604674e..00000000 Binary files a/custom_components/spotcast/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spotcast/__pycache__/error.cpython-312.pyc b/custom_components/spotcast/__pycache__/error.cpython-312.pyc deleted file mode 100644 index 71726f8b..00000000 Binary files a/custom_components/spotcast/__pycache__/error.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spotcast/__pycache__/helpers.cpython-312.pyc b/custom_components/spotcast/__pycache__/helpers.cpython-312.pyc deleted file mode 100644 index 5310b5f8..00000000 Binary files a/custom_components/spotcast/__pycache__/helpers.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spotcast/__pycache__/spotcast_controller.cpython-312.pyc b/custom_components/spotcast/__pycache__/spotcast_controller.cpython-312.pyc deleted file mode 100644 index 1b72b09c..00000000 Binary files a/custom_components/spotcast/__pycache__/spotcast_controller.cpython-312.pyc and /dev/null differ diff --git a/custom_components/spotcast/__pycache__/spotcast_controller.cpython-313.pyc b/custom_components/spotcast/__pycache__/spotcast_controller.cpython-313.pyc index b452e098..fd7dfd2d 100644 Binary files a/custom_components/spotcast/__pycache__/spotcast_controller.cpython-313.pyc and b/custom_components/spotcast/__pycache__/spotcast_controller.cpython-313.pyc differ diff --git a/custom_components/spotcast/__pycache__/spotify_controller.cpython-312.pyc b/custom_components/spotcast/__pycache__/spotify_controller.cpython-312.pyc deleted file mode 100644 index b278b778..00000000 Binary files a/custom_components/spotcast/__pycache__/spotify_controller.cpython-312.pyc and /dev/null differ diff --git a/custom_components/start_time/__pycache__/__init__.cpython-312.pyc b/custom_components/start_time/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index fa0498ef..00000000 Binary files a/custom_components/start_time/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/start_time/__pycache__/config_flow.cpython-312.pyc b/custom_components/start_time/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index 435ebfd1..00000000 Binary files a/custom_components/start_time/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/start_time/__pycache__/sensor.cpython-312.pyc b/custom_components/start_time/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index 51417477..00000000 Binary files a/custom_components/start_time/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/start_time/__pycache__/sensor.cpython-313.pyc b/custom_components/start_time/__pycache__/sensor.cpython-313.pyc index 85b1f10f..29b08400 100644 Binary files a/custom_components/start_time/__pycache__/sensor.cpython-313.pyc and b/custom_components/start_time/__pycache__/sensor.cpython-313.pyc differ diff --git a/custom_components/tplink_deco/__pycache__/__init__.cpython-312.pyc b/custom_components/tplink_deco/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index ccab066d..00000000 Binary files a/custom_components/tplink_deco/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_deco/__pycache__/api.cpython-312.pyc b/custom_components/tplink_deco/__pycache__/api.cpython-312.pyc deleted file mode 100644 index 22c875a3..00000000 Binary files a/custom_components/tplink_deco/__pycache__/api.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_deco/__pycache__/config_flow.cpython-312.pyc b/custom_components/tplink_deco/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index 0d4db864..00000000 Binary files a/custom_components/tplink_deco/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_deco/__pycache__/const.cpython-312.pyc b/custom_components/tplink_deco/__pycache__/const.cpython-312.pyc deleted file mode 100644 index 9c105bb6..00000000 Binary files a/custom_components/tplink_deco/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_deco/__pycache__/coordinator.cpython-312.pyc b/custom_components/tplink_deco/__pycache__/coordinator.cpython-312.pyc deleted file mode 100644 index 8f9df752..00000000 Binary files a/custom_components/tplink_deco/__pycache__/coordinator.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_deco/__pycache__/device.cpython-312.pyc b/custom_components/tplink_deco/__pycache__/device.cpython-312.pyc deleted file mode 100644 index e935946c..00000000 Binary files a/custom_components/tplink_deco/__pycache__/device.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_deco/__pycache__/device_tracker.cpython-312.pyc b/custom_components/tplink_deco/__pycache__/device_tracker.cpython-312.pyc deleted file mode 100644 index 250f04e9..00000000 Binary files a/custom_components/tplink_deco/__pycache__/device_tracker.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_deco/__pycache__/exceptions.cpython-312.pyc b/custom_components/tplink_deco/__pycache__/exceptions.cpython-312.pyc deleted file mode 100644 index 3c99ea7e..00000000 Binary files a/custom_components/tplink_deco/__pycache__/exceptions.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_deco/__pycache__/sensor.cpython-312.pyc b/custom_components/tplink_deco/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index 54676565..00000000 Binary files a/custom_components/tplink_deco/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_router/__pycache__/__init__.cpython-312.pyc b/custom_components/tplink_router/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 29759454..00000000 Binary files a/custom_components/tplink_router/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_router/__pycache__/button.cpython-312.pyc b/custom_components/tplink_router/__pycache__/button.cpython-312.pyc deleted file mode 100644 index 1391252e..00000000 Binary files a/custom_components/tplink_router/__pycache__/button.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_router/__pycache__/config_flow.cpython-312.pyc b/custom_components/tplink_router/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index ee20f342..00000000 Binary files a/custom_components/tplink_router/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_router/__pycache__/const.cpython-312.pyc b/custom_components/tplink_router/__pycache__/const.cpython-312.pyc deleted file mode 100644 index ebdf6b2f..00000000 Binary files a/custom_components/tplink_router/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_router/__pycache__/coordinator.cpython-312.pyc b/custom_components/tplink_router/__pycache__/coordinator.cpython-312.pyc deleted file mode 100644 index aa06693e..00000000 Binary files a/custom_components/tplink_router/__pycache__/coordinator.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_router/__pycache__/device_tracker.cpython-312.pyc b/custom_components/tplink_router/__pycache__/device_tracker.cpython-312.pyc deleted file mode 100644 index 224ad21f..00000000 Binary files a/custom_components/tplink_router/__pycache__/device_tracker.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_router/__pycache__/sensor.cpython-312.pyc b/custom_components/tplink_router/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index 84421b6b..00000000 Binary files a/custom_components/tplink_router/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/tplink_router/__pycache__/switch.cpython-312.pyc b/custom_components/tplink_router/__pycache__/switch.cpython-312.pyc deleted file mode 100644 index ab387ee7..00000000 Binary files a/custom_components/tplink_router/__pycache__/switch.cpython-312.pyc and /dev/null differ diff --git a/custom_components/watchman/__pycache__/__init__.cpython-312.pyc b/custom_components/watchman/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 68baaf81..00000000 Binary files a/custom_components/watchman/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/watchman/__pycache__/__init__.cpython-313.pyc b/custom_components/watchman/__pycache__/__init__.cpython-313.pyc index 4e88cec1..1ea5c674 100644 Binary files a/custom_components/watchman/__pycache__/__init__.cpython-313.pyc and b/custom_components/watchman/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/watchman/__pycache__/config_flow.cpython-312.pyc b/custom_components/watchman/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index 6b525907..00000000 Binary files a/custom_components/watchman/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/watchman/__pycache__/config_flow.cpython-313.pyc b/custom_components/watchman/__pycache__/config_flow.cpython-313.pyc index 2c15614b..22468326 100644 Binary files a/custom_components/watchman/__pycache__/config_flow.cpython-313.pyc and b/custom_components/watchman/__pycache__/config_flow.cpython-313.pyc differ diff --git a/custom_components/watchman/__pycache__/const.cpython-312.pyc b/custom_components/watchman/__pycache__/const.cpython-312.pyc deleted file mode 100644 index 3130aab0..00000000 Binary files a/custom_components/watchman/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/watchman/__pycache__/coordinator.cpython-312.pyc b/custom_components/watchman/__pycache__/coordinator.cpython-312.pyc deleted file mode 100644 index fc137fd2..00000000 Binary files a/custom_components/watchman/__pycache__/coordinator.cpython-312.pyc and /dev/null differ diff --git a/custom_components/watchman/__pycache__/entity.cpython-312.pyc b/custom_components/watchman/__pycache__/entity.cpython-312.pyc deleted file mode 100644 index 4a99f855..00000000 Binary files a/custom_components/watchman/__pycache__/entity.cpython-312.pyc and /dev/null differ diff --git a/custom_components/watchman/__pycache__/sensor.cpython-312.pyc b/custom_components/watchman/__pycache__/sensor.cpython-312.pyc deleted file mode 100644 index d8fa035e..00000000 Binary files a/custom_components/watchman/__pycache__/sensor.cpython-312.pyc and /dev/null differ diff --git a/custom_components/watchman/utils/__pycache__/__init__.cpython-312.pyc b/custom_components/watchman/utils/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 04551e95..00000000 Binary files a/custom_components/watchman/utils/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/watchman/utils/__pycache__/logger.cpython-312.pyc b/custom_components/watchman/utils/__pycache__/logger.cpython-312.pyc deleted file mode 100644 index 39941a01..00000000 Binary files a/custom_components/watchman/utils/__pycache__/logger.cpython-312.pyc and /dev/null differ diff --git a/custom_components/watchman/utils/__pycache__/utils.cpython-312.pyc b/custom_components/watchman/utils/__pycache__/utils.cpython-312.pyc deleted file mode 100644 index d5844026..00000000 Binary files a/custom_components/watchman/utils/__pycache__/utils.cpython-312.pyc and /dev/null differ diff --git a/custom_components/watchman/utils/__pycache__/utils.cpython-313.pyc b/custom_components/watchman/utils/__pycache__/utils.cpython-313.pyc index 3264877a..a18258b1 100644 Binary files a/custom_components/watchman/utils/__pycache__/utils.cpython-313.pyc and b/custom_components/watchman/utils/__pycache__/utils.cpython-313.pyc differ diff --git a/custom_components/ytube_music_player/__init__.py b/custom_components/ytube_music_player/__init__.py new file mode 100644 index 00000000..dc44641b --- /dev/null +++ b/custom_components/ytube_music_player/__init__.py @@ -0,0 +1,44 @@ +"""Provide the initial setup.""" +import logging +from .const import * + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Provide Setup of platform.""" + return True + + +async def async_setup_entry(hass, config_entry): + """Set up this integration using UI/YAML.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config_entry.entry_id] = {} + hass.config_entries.async_update_entry(config_entry, data=ensure_config(config_entry.data)) + + if not config_entry.update_listeners: + config_entry.add_update_listener(async_update_options) + + # Add entities to HA + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True + + + +async def async_remove_entry(hass, config_entry): + """Handle removal of an entry.""" + for platform in PLATFORMS: + try: + await hass.config_entries.async_forward_entry_unload(config_entry, platform) + _LOGGER.info( + "Successfully removed entities from the integration" + ) + except ValueError: + pass + + +async def async_update_options(hass, config_entry): + _LOGGER.debug("Config updated,reload the entities.") + for platform in PLATFORMS: + await hass.config_entries.async_forward_entry_unload(config_entry, platform) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/custom_components/ytube_music_player/__pycache__/__init__.cpython-313.pyc b/custom_components/ytube_music_player/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..27636ba9 Binary files /dev/null and b/custom_components/ytube_music_player/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/ytube_music_player/__pycache__/browse_media.cpython-313.pyc b/custom_components/ytube_music_player/__pycache__/browse_media.cpython-313.pyc new file mode 100644 index 00000000..b37a8d26 Binary files /dev/null and b/custom_components/ytube_music_player/__pycache__/browse_media.cpython-313.pyc differ diff --git a/custom_components/ytube_music_player/__pycache__/config_flow.cpython-313.pyc b/custom_components/ytube_music_player/__pycache__/config_flow.cpython-313.pyc new file mode 100644 index 00000000..dbeee875 Binary files /dev/null and b/custom_components/ytube_music_player/__pycache__/config_flow.cpython-313.pyc differ diff --git a/custom_components/ytube_music_player/__pycache__/const.cpython-313.pyc b/custom_components/ytube_music_player/__pycache__/const.cpython-313.pyc new file mode 100644 index 00000000..e8892a06 Binary files /dev/null and b/custom_components/ytube_music_player/__pycache__/const.cpython-313.pyc differ diff --git a/custom_components/ytube_music_player/__pycache__/media_player.cpython-313.pyc b/custom_components/ytube_music_player/__pycache__/media_player.cpython-313.pyc new file mode 100644 index 00000000..b2840dfa Binary files /dev/null and b/custom_components/ytube_music_player/__pycache__/media_player.cpython-313.pyc differ diff --git a/custom_components/ytube_music_player/__pycache__/select.cpython-313.pyc b/custom_components/ytube_music_player/__pycache__/select.cpython-313.pyc new file mode 100644 index 00000000..d8424ec4 Binary files /dev/null and b/custom_components/ytube_music_player/__pycache__/select.cpython-313.pyc differ diff --git a/custom_components/ytube_music_player/__pycache__/sensor.cpython-313.pyc b/custom_components/ytube_music_player/__pycache__/sensor.cpython-313.pyc new file mode 100644 index 00000000..18df734e Binary files /dev/null and b/custom_components/ytube_music_player/__pycache__/sensor.cpython-313.pyc differ diff --git a/custom_components/ytube_music_player/browse_media.py b/custom_components/ytube_music_player/browse_media.py new file mode 100644 index 00000000..a9f96f01 --- /dev/null +++ b/custom_components/ytube_music_player/browse_media.py @@ -0,0 +1,684 @@ +"""Support for media browsing.""" +import logging +from homeassistant.components.media_player import BrowseError, BrowseMedia +from ytmusicapi import ytmusic +from .const import * +import random + + + +PLAYABLE_MEDIA_TYPES = [ + MediaType.ALBUM, + USER_ALBUM, + USER_ARTIST, + MediaType.TRACK, + MediaType.PLAYLIST, + LIB_TRACKS, + HISTORY, + USER_TRACKS, + ALBUM_OF_TRACK +] + +CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS = { + MediaType.ALBUM: MediaClass.ALBUM, + LIB_ALBUM: MediaClass.ALBUM, + MediaType.ARTIST: MediaClass.ARTIST, + MediaType.PLAYLIST: MediaClass.PLAYLIST, + LIB_PLAYLIST: MediaClass.PLAYLIST, + HISTORY: MediaClass.PLAYLIST, + USER_TRACKS: MediaClass.PLAYLIST, + MediaType.SEASON: MediaClass.SEASON, + MediaType.TVSHOW: MediaClass.TV_SHOW, +} + +CHILD_TYPE_MEDIA_CLASS = { + MediaType.SEASON: MediaClass.SEASON, + MediaType.ALBUM: MediaClass.ALBUM, + MediaType.ARTIST: MediaClass.ARTIST, + MediaType.MOVIE: MediaClass.MOVIE, + MediaType.PLAYLIST: MediaClass.PLAYLIST, + MediaType.TRACK: MediaClass.TRACK, + MediaType.TVSHOW: MediaClass.TV_SHOW, + MediaType.CHANNEL: MediaClass.CHANNEL, + MediaType.EPISODE: MediaClass.EPISODE, +} + +_LOGGER = logging.getLogger(__name__) + + +class UnknownMediaType(BrowseError): + """Unknown media type.""" + + +async def build_item_response(ytmusicplayer, payload): + """Create response payload for the provided media query.""" + search_id = payload[SEARCH_ID] + search_type = payload[SEARCH_TYPE] + media_library = ytmusicplayer._api + hass = ytmusicplayer.hass + children = [] + header_thumbnail = None + header_title = None + media = None + sort_list = ytmusicplayer._sortBrowser + p1 = datetime.datetime.now() + + # the only static source is the ytmusicplayer, to store content over multiple calls + # is this the smartest way to figure out if the var exists? probably not, but the only i found :) + try: + lastHomeMedia = ytmusicplayer.lastHomeMedia + except: + lastHomeMedia = "" + + _LOGGER.debug("- build_item_response for: " + search_type) + + if search_type == LIB_PLAYLIST: # playlist OVERVIEW -> lists playlists + media = await hass.async_add_executor_job(media_library.get_library_playlists, BROWSER_LIMIT) + header_title = LIB_PLAYLIST_TITLE # single playlist + + for item in media: + children.append(BrowseMedia( + title = f"{item['title']}", # noqa: E251 + media_class = MediaClass.PLAYLIST, # noqa: E251 + media_content_type = MediaType.PLAYLIST, # noqa: E251 + media_content_id = f"{item['playlistId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = True, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + + elif search_type == HOME_CAT: + sort_list = False + header_title = HOME_TITLE + media = await hass.async_add_executor_job(media_library.get_home, 20) + ytmusicplayer.lastHomeMedia = media # store for next round, to keep the same respnse, two seperate calls lead to different data + + for item in media: + #_LOGGER.debug(item) + #_LOGGER.debug("") + if(("contents" in item) and ("title" in item)): + if(item["contents"][0] != None): + thumbnail = find_thumbnail(item["contents"][int(random.random()*len(item["contents"]))]) + children.append(BrowseMedia( + title = f"{item['title']}", # noqa: E251 + media_class = MediaClass.PLAYLIST, # noqa: E251 + media_content_type = HOME_CAT_2, # noqa: E251 + media_content_id = f"{item['title']}", # noqa: E251 + can_play = False, # noqa: E251 + can_expand = True, # noqa: E251 + thumbnail = thumbnail # noqa: E251 + )) + + elif search_type == HOME_CAT_2: + sort_list = False + # try to run with the same session as HOME_CAT had in the first call + media = lastHomeMedia + header_title = search_id + # backup if this fails (e.g. direct URL call that jumped the HOME_CAT) + if(media == ""): + media = await hass.async_add_executor_job(media_library.get_home, 20) + for item in media: + if(item['title'] == search_id): + for content in item['contents']: + if(content != None): + play_id = "" + item_title = "" + #_LOGGER.debug(content) + #_LOGGER.debug("") + if('videoId' in content): + item_title = content["title"] + if("artists" in content): + for artist in content["artists"]: + if(artist["id"] != None): + item_title += " - "+artist["name"] + break + play_id = f"{content['videoId']}" + play_type = MediaClass.TRACK + playable = True + browsable = False + #_LOGGER.debug("1") + elif('browseId' in content): + # ok, this can be an Artist or and Album + # album start with MPRE + if(content['browseId'].startswith('MPREb_')): + item_title = content["title"] + if("year" in content): # WTF YT, why is the artist stored in 'year'? + item_title += " - "+content["year"] + play_id = f"{content['browseId']}" + play_type = MediaClass.ALBUM + playable = True + browsable = True + #_LOGGER.debug("2") + #else: + #_LOGGER.debug("2.2") + elif('playlistId' in content): + # RDAMPL - playlist / album radio + # RDAMVL - track radio + item_title = content["title"] + play_id = f"{content['playlistId']}" + play_type = MediaClass.PLAYLIST + playable = True + browsable = True + #_LOGGER.debug("3") + else: + _LOGGER.debug("didn't get this item:") + _LOGGER.debug(content) + _LOGGER.debug("") + + if(play_id!=""): + children.append(BrowseMedia( + title = item_title, # noqa: E251 + media_class = MediaClass.PLAYLIST, # noqa: E251 + media_content_type = play_type, # noqa: E251 + media_content_id = play_id, # noqa: E251 + can_play = playable, # noqa: E251 + can_expand = browsable, # noqa: E251 + thumbnail = find_thumbnail(content) # noqa: E251 + )) + break + + elif search_type == MediaType.PLAYLIST: # single playlist -> lists tracks + media = await hass.async_add_executor_job(media_library.get_playlist, search_id, BROWSER_LIMIT) + header_title = media['title'] + header_thumbnail = find_thumbnail(media) + + for item in media['tracks']: + item_title = f"{item['title']}" + if("artists" in item): + artist = "" + if(isinstance(item["artists"], str)): + artist = item["artists"] + elif(isinstance(item["artists"], list)): + artist = item["artists"][0]["name"] + if(artist): + item_title = artist + " - " + item_title + + children.append(BrowseMedia( + title = item_title, # noqa: E251 + media_class = MediaClass.TRACK, # noqa: E251 + media_content_type = MediaType.TRACK, # noqa: E251 + media_content_id = f"{item['videoId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = False, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + + elif search_type == LIB_ALBUM: # LIB! album OVERVIEW, not uploaded -> lists albums + media = await hass.async_add_executor_job(media_library.get_library_albums, BROWSER_LIMIT) + header_title = LIB_ALBUM_TITLE + + for item in media: + item_title = item['title'] + if("artists" in item): + artist = "" + if(isinstance(item["artists"], str)): + artist = item["artists"] + elif(isinstance(item["artists"], list)): + artist = item["artists"][0]["name"] + if(artist): + item_title = item['title'] + " - " + artist + + children.append(BrowseMedia( + title = item_title, # noqa: E251 + media_class = MediaClass.ALBUM, # noqa: E251 + media_content_type = MediaType.ALBUM, # noqa: E251 + media_content_id = f"{item['browseId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = True, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + + elif search_type == MediaType.ALBUM: # single album (NOT uploaded) -> lists tracks + res = await hass.async_add_executor_job(media_library.get_album, search_id) + media = res['tracks'] + header_title = res['title'] + header_thumbnail = find_thumbnail(res) + + for item in media: + children.append(BrowseMedia( + title = f"{item['title']}", # noqa: E251 + media_class = MediaClass.TRACK, # noqa: E251 + media_content_type = MediaType.TRACK, # noqa: E251 + media_content_id = f"{item['videoId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = False, # noqa: E251 + thumbnail = find_thumbnail(res) # noqa: E251 + )) + + elif search_type == LIB_TRACKS: # liked songs (direct list, NOT uploaded) -> lists tracks + media = await hass.async_add_executor_job(lambda: media_library.get_library_songs(limit=BROWSER_LIMIT)) + header_title = LIB_TRACKS_TITLE + + for item in media: + item_title = f"{item['title']}" + if("artists" in item): + artist = "" + if(isinstance(item["artists"], str)): + artist = item["artists"] + elif(isinstance(item["artists"], list)): + artist = item["artists"][0]["name"] + if(artist): + item_title = artist + " - " + item_title + + children.append(BrowseMedia( + title = item_title, # noqa: E251 + media_class = MediaClass.TRACK, # noqa: E251 + media_content_type = MediaType.TRACK, # noqa: E251 + media_content_id = f"{item['videoId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = False, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + + elif search_type == HISTORY: # history songs (direct list) -> lists tracks + media = await hass.async_add_executor_job(media_library.get_history) + search_id = HISTORY + header_title = HISTORY_TITLE + + for item in media: + item_title = f"{item['title']}" + if("artists" in item): + artist = "" + if(isinstance(item["artists"], str)): + artist = item["artists"] + elif(isinstance(item["artists"], list)): + artist = item["artists"][0]["name"] + if(artist): + item_title = artist + " - " + item_title + + children.append(BrowseMedia( + title = item_title, # noqa: E251 + media_class = MediaClass.TRACK, # noqa: E251 + media_content_type = MediaType.TRACK, # noqa: E251 + media_content_id = f"{item['videoId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = False, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + + elif search_type == USER_TRACKS: # list all uploaded songs -> lists tracks + media = await hass.async_add_executor_job(media_library.get_library_upload_songs, BROWSER_LIMIT) + search_id = USER_TRACKS + header_title = USER_TRACKS_TITLE + + for item in media: + item_title = f"{item['title']}" + if("artist" in item): + artist = "" + if(isinstance(item["artist"], str)): + artist = item["artist"] + elif(isinstance(item["artist"], list)): + artist = item["artist"][0]["name"] + if(artist): + item_title = artist + " - " + item_title + + children.append(BrowseMedia( + title = item_title, # noqa: E251 + media_class = MediaClass.TRACK, # noqa: E251 + media_content_type = MediaType.TRACK, # noqa: E251 + media_content_id = f"{item['videoId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = False, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + + elif search_type == USER_ALBUMS: # uploaded album overview!! -> lists user albums + media = await hass.async_add_executor_job(media_library.get_library_upload_albums, BROWSER_LIMIT) + header_title = USER_ALBUMS_TITLE + + for item in media: + children.append(BrowseMedia( + title = f"{item['title']}", # noqa: E251 + media_class = MediaClass.ALBUM, # noqa: E251 + media_content_type = USER_ALBUM, # noqa: E251 + media_content_id = f"{item['browseId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = True, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + + elif search_type == USER_ALBUM: # single uploaded album -> lists tracks + res = await hass.async_add_executor_job(media_library.get_library_upload_album, search_id) + media = res['tracks'] + header_title = res['title'] + + for item in media: + children.append(BrowseMedia( + title = f"{item['title']}", # noqa: E251 + media_class = MediaClass.TRACK, # noqa: E251 + media_content_type = MediaType.TRACK, # noqa: E251 + media_content_id = f"{item['videoId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = False, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + + elif search_type == USER_ARTISTS: # with S + media = await hass.async_add_executor_job(media_library.get_library_upload_artists, BROWSER_LIMIT) + header_title = USER_ARTISTS_TITLE + + for item in media: + children.append(BrowseMedia( + title = f"{item['artist']}", # noqa: E251 + media_class = MediaClass.ARTIST, # noqa: E251 + media_content_type = USER_ARTIST, # noqa: E251 + media_content_id = f"{item['browseId']}", # noqa: E251 + can_play = False, # noqa: E251 + can_expand = True, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + + elif search_type == USER_ARTISTS_2: # list all artists now, but follow up will be the albums of that artist + media = await hass.async_add_executor_job(media_library.get_library_upload_artists, BROWSER_LIMIT) + header_title = USER_ARTISTS_2_TITLE + + for item in media: + children.append(BrowseMedia( + title = f"{item['artist']}", # noqa: E251 + media_class = MediaClass.ARTIST, # noqa: E251 + media_content_type = USER_ARTIST_2, # noqa: E251 + media_content_id = f"{item['browseId']}", # noqa: E251 + can_play = False, # noqa: E251 + can_expand = True, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + + elif search_type == USER_ARTIST: # without S + media = await hass.async_add_executor_job(media_library.get_library_upload_artist, search_id, BROWSER_LIMIT) + header_title = USER_ARTIST_TITLE + if(isinstance(media, list)): + if('artist' in media[0]): + if(isinstance(media[0]['artist'], list)): + if('name' in media[0]['artist'][0]): + header_title = media[0]['artist'][0]['name'] + elif('artists' in media[0]): + if(isinstance(media[0]['artists'], list)): + if('name' in media[0]['artists'][0]): + header_title = media[0]['artists'][0]['name'] + + for item in media: + children.append(BrowseMedia( + title = f"{item['title']}", # noqa: E251 + media_class = MediaClass.TRACK, # noqa: E251 + media_content_type = MediaType.TRACK, # noqa: E251 + media_content_id = f"{item['videoId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = False, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + + elif search_type == USER_ARTIST_2: # list each album of an uploaded artists only once .. next will be uploaded album view 'USER_ALBUM' + media_all = await hass.async_add_executor_job(media_library.get_library_upload_artist, search_id, BROWSER_LIMIT) + header_title = USER_ARTIST_2_TITLE + media = list() + for item in media_all: + if('album' in item): + if('name' in item['album']): + if(all(item['album']['name'] != a['title'] for a in media)): + media.append({ + 'type': 'user_album', + 'browseId': item['album']['id'], + 'title': item['album']['name'], + 'thumbnails': item['thumbnails'] + }) + if('artist' in media_all[0]): + if(isinstance(media_all[0]['artist'], list)): + if('name' in media_all[0]['artist'][0]): + title = "Uploaded albums of " + media_all[0]['artist'][0]['name'] + + + for item in media: + children.append(BrowseMedia( + title = f"{item['title']}", # noqa: E251 + media_class = MediaClass.ALBUM, # noqa: E251 + media_content_type = USER_ALBUM, # noqa: E251 + media_content_id = f"{item['browseId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = True, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + + + elif search_type == SEARCH: + header_title = SEARCH_TITLE + if ytmusicplayer._search is not None: + media_all = await hass.async_add_executor_job(lambda: media_library.search(query=ytmusicplayer._search.get('query', ""), filter=ytmusicplayer._search.get('filter', None), limit=int(ytmusicplayer._search.get('limit', 20)))) + + if(ytmusicplayer._search.get('filter', None) is not None): + helper = {} + else: + helper = {'song': "Track: ", 'playlist': "Playlist: ", 'album': "Album: ", 'artist': "Artist: "} + + for a in media_all: + if(a['category'] in ["Top result", "Podcast"]): + continue + + if(a['resultType'] == 'song'): + artists = "" + if("artist" in a): + artists = a["artist"] + if("artists" in a): + artists = ', '.join(artist["name"] for artist in a["artists"] if "name" in artist) + children.append(BrowseMedia( + title = helper.get(a['resultType'], "") + artists + " - " + a['title'], # noqa: E251 + media_class = MediaClass.TRACK, # noqa: E251 + media_content_type = MediaType.TRACK, # noqa: E251 + media_content_id = a['videoId'], # noqa: E251 + can_play = True, # noqa: E251 + can_expand = False, # noqa: E251 + thumbnail = find_thumbnail(a) # noqa: E251 + )) + elif(a['resultType'] == 'playlist'): + children.append(BrowseMedia( + title = helper.get(a['resultType'], "") + a['title'], # noqa: E251 + media_class = MediaClass.PLAYLIST, # noqa: E251 + media_content_type = MediaType.PLAYLIST, # noqa: E251 + media_content_id = f"{a['browseId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = True, # noqa: E251 + thumbnail = find_thumbnail(a) # noqa: E251 + )) + elif(a['resultType'] == 'album'): + children.append(BrowseMedia( + title = helper.get(a['resultType'], "") + a['title'], # noqa: E251 + media_class = MediaClass.ALBUM, # noqa: E251 + media_content_type = MediaType.ALBUM, # noqa: E251 + media_content_id = f"{a['browseId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = True, # noqa: E251 + thumbnail = find_thumbnail(a) # noqa: E251 + )) + elif(a['resultType'] == 'artist'): + _LOGGER.debug("a: %s", a) + if not('artist' in a): + a['artist'] = a['artists'][0]['name'] # Fix Top result + a['browseId'] = a['artists'][0]['id'] # Fix Top result + children.append(BrowseMedia( + title = helper.get(a['resultType'], "") + a['artist'], # noqa: E251 + media_class = MediaClass.ARTIST, # noqa: E251 + media_content_type = MediaType.ARTIST, # noqa: E251 + media_content_id = f"{a['browseId']}", # noqa: E251 + can_play = False, # noqa: E251 + can_expand = True, # noqa: E251 + thumbnail = find_thumbnail(a) # noqa: E251 + )) + else: # video / artists / uploads are currently ignored + continue + + # _LOGGER.debug("search entry end") + elif search_type == MediaType.ARTIST: + media_all = await hass.async_add_executor_job(media_library.get_artist, search_id) + helper = {'song': "Track: ", 'playlist': "Playlist: ", 'album': "Album: ", 'artist': "Artist"} + + if('singles' in media_all): + for a in media_all['singles']['results']: + children.append(BrowseMedia( + title = helper.get('song', "") + a['title'], # noqa: E251 + media_class = MediaClass.ALBUM, # noqa: E251 + media_content_type = MediaType.ALBUM, # noqa: E251 + media_content_id = a['browseId'], # noqa: E251 + can_play = True, # noqa: E251 + can_expand = False, # noqa: E251 + thumbnail = find_thumbnail(a) # noqa: E251 + )) + if('albums' in media_all): + for a in media_all['albums']['results']: + children.append(BrowseMedia( + title = helper.get('album', "") + a['title'], # noqa: E251 + media_class = MediaClass.ALBUM, # noqa: E251 + media_content_type = MediaType.ALBUM, # noqa: E251 + media_content_id = f"{a['browseId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = True, # noqa: E251 + thumbnail = find_thumbnail(a) # noqa: E251 + )) + + elif search_type == MOOD_OVERVIEW: + media_all = await hass.async_add_executor_job(lambda: media_library.get_mood_categories()) + header_title = MOOD_TITLE + for cap in media_all: + for e in media_all[cap]: + children.append(BrowseMedia( + title = f"{cap} - {e['title']}", # noqa: E251 + media_class = MediaClass.PLAYLIST, # noqa: E251 + media_content_type = MOOD_PLAYLISTS, # noqa: E251 + media_content_id = e['params'], # noqa: E251 + can_play = False, # noqa: E251 + can_expand = True, # noqa: E251 + thumbnail = "", # noqa: E251 + )) + + elif search_type == MOOD_PLAYLISTS: + media = await hass.async_add_executor_job(lambda: media_library.get_mood_playlists(search_id)) + header_title = MOOD_TITLE + for item in media: + children.append(BrowseMedia( + title = f"{item['title']}", # noqa: E251 + media_class = MediaClass.PLAYLIST, # noqa: E251 + media_content_type = MediaType.PLAYLIST, # noqa: E251 + media_content_id = f"{item['playlistId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = True, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + + elif search_type == CONF_RECEIVERS: + header_title = PLAYER_TITLE + for e, f in ytmusicplayer._friendly_speakersList.items(): + children.append(BrowseMedia( + title = f, # noqa: E251 + media_class = MediaClass.TV_SHOW, # noqa: E251 + media_content_type = CONF_RECEIVERS, # noqa: E251 + media_content_id = e, # noqa: E251 + can_play = True, # noqa: E251 + can_expand = False, # noqa: E251 + thumbnail = "", # noqa: E251 + )) + elif search_type == CUR_PLAYLIST: + header_title = CUR_PLAYLIST_TITLE + sort_list = False + i = 1 + for item in ytmusicplayer._tracks: + item_title = item["title"] + if("artists" in item): + artist = "" + if(isinstance(item["artists"], str)): + artist = item["artists"] + elif(isinstance(item["artists"], list)): + artist = item["artists"][0]["name"] + if(artist): + item_title = artist + " - " + item_title + + children.append(BrowseMedia( + title = item_title, # noqa: E251 + media_class = MediaClass.TRACK, # noqa: E251 + media_content_type = CUR_PLAYLIST_COMMAND, # noqa: E251 + media_content_id = i, # noqa: E251 + can_play = True, # noqa: E251 + can_expand = False, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + i += 1 + + elif search_type == ALBUM_OF_TRACK: + try: + res = await hass.async_add_executor_job(lambda: media_library.get_album(ytmusicplayer._track_album_id)) + sort_list = False + media = res['tracks'] + header_title = res['title'] + + for item in media: + children.append(BrowseMedia( + title = f"{item['title']}", # noqa: E251 + media_class = MediaClass.TRACK, # noqa: E251 + media_content_type = MediaType.TRACK, # noqa: E251 + media_content_id = f"{item['videoId']}", # noqa: E251 + can_play = True, # noqa: E251 + can_expand = False, # noqa: E251 + thumbnail = find_thumbnail(item) # noqa: E251 + )) + except: + pass + + + # ########################################### END ############### + if sort_list: + children.sort(key=lambda x: x.title, reverse=False) + response = BrowseMedia( + media_class = CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS.get(search_type, MediaClass.DIRECTORY), + media_content_id = search_id, + media_content_type = search_type, + title = header_title, + can_play = search_type in PLAYABLE_MEDIA_TYPES and search_id, + can_expand = True, + children = children, + thumbnail = header_thumbnail, + ) + + if search_type == "library_music": + response.children_media_class = MediaClass.MUSIC + elif len(children) > 0: + response.calculate_children_class() + t = (datetime.datetime.now() - p1).total_seconds() + _LOGGER.debug("- Calc / grab time: " + str(t) + " sec") + return response + + + +def library_payload(ytmusicplayer): + # Create response payload to describe contents of a specific library. + # Used by async_browse_media. + library_info = BrowseMedia(media_class=MediaClass.DIRECTORY, media_content_id="library", media_content_type="library", title="Media Library", can_play=False, can_expand=True, children=[]) + + library_info.children.append(BrowseMedia(title=HOME_TITLE, media_class=MediaClass.PLAYLIST, media_content_type=HOME_CAT, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 + library_info.children.append(BrowseMedia(title=LIB_PLAYLIST_TITLE, media_class=MediaClass.PLAYLIST, media_content_type=LIB_PLAYLIST, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 + library_info.children.append(BrowseMedia(title=LIB_ALBUM_TITLE, media_class=MediaClass.ALBUM, media_content_type=LIB_ALBUM, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 + library_info.children.append(BrowseMedia(title=LIB_TRACKS_TITLE, media_class=MediaClass.TRACK, media_content_type=LIB_TRACKS, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 + library_info.children.append(BrowseMedia(title=HISTORY_TITLE, media_class=MediaClass.TRACK, media_content_type=HISTORY, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 + library_info.children.append(BrowseMedia(title=USER_TRACKS_TITLE, media_class=MediaClass.TRACK, media_content_type=USER_TRACKS, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 + library_info.children.append(BrowseMedia(title=USER_ALBUMS_TITLE, media_class=MediaClass.ALBUM, media_content_type=USER_ALBUMS, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 + library_info.children.append(BrowseMedia(title=USER_ARTISTS_TITLE, media_class=MediaClass.ARTIST, media_content_type=USER_ARTISTS, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 + library_info.children.append(BrowseMedia(title=USER_ARTISTS_2_TITLE, media_class=MediaClass.ARTIST, media_content_type=USER_ARTISTS_2, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 + library_info.children.append(BrowseMedia(title=MOOD_TITLE, media_class=MediaClass.PLAYLIST, media_content_type=MOOD_OVERVIEW, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 + library_info.children.append(BrowseMedia(title=PLAYER_TITLE, media_class=MediaClass.TV_SHOW, media_content_type=CONF_RECEIVERS, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 + library_info.children.append(BrowseMedia(title=CUR_PLAYLIST_TITLE, media_class=MediaClass.PLAYLIST, media_content_type=CUR_PLAYLIST, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 + + # add search button if possible + if(ytmusicplayer._search.get("query", "") != ""): + library_info.children.append( + BrowseMedia(title="Results for \"" + str(ytmusicplayer._search.get("query", "No search")) + "\"", media_class=MediaClass.DIRECTORY, media_content_type=SEARCH, media_content_id="", can_play=False, can_expand=True, thumbnail="") + ) + + # add "go to album of track" if possible + if(ytmusicplayer._track_album_id not in ["", None] and ytmusicplayer._track_name not in ["", None]): + library_info.children.append( + BrowseMedia(title="Album of \"" + str(ytmusicplayer._track_name) + "\"", media_class=MediaClass.ALBUM, media_content_type=ALBUM_OF_TRACK, media_content_id="1", can_play=True, can_expand=True, thumbnail=ytmusicplayer._track_album_cover) + ) + + # add "radio of track" if possible + if(ytmusicplayer._attributes['videoId'] != ""): + library_info.children.append( + BrowseMedia(title="Radio of \"" + str(ytmusicplayer._track_name) + "\"", media_class=MediaClass.PLAYLIST, media_content_type=CHANNEL_VID, media_content_id=ytmusicplayer._attributes['videoId'], can_play=True, can_expand=False, thumbnail="") + ) + + return library_info diff --git a/custom_components/ytube_music_player/config_flow.py b/custom_components/ytube_music_player/config_flow.py new file mode 100644 index 00000000..a144e420 --- /dev/null +++ b/custom_components/ytube_music_player/config_flow.py @@ -0,0 +1,288 @@ +"""Provide the config flow.""" +from homeassistant.core import callback +from homeassistant import config_entries +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import selector +import voluptuous as vol +import logging +from .const import * +import os +import os.path +from homeassistant.helpers.storage import STORAGE_DIR +import ytmusicapi +from ytmusicapi.helpers import SUPPORTED_LANGUAGES +import requests +#from ytmusicapi.auth.oauth import OAuthCredentials, RefreshingToken + + +import traceback +import asyncio +from collections import OrderedDict + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class yTubeMusicFlowHandler(config_entries.ConfigFlow): + """Provide the initial setup.""" + + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + VERSION = 1 + + def __init__(self): + """Provide the init function of the config flow.""" + # Called once the flow is started by the user + self._errors = {} + + # entry point from config start + async def async_step_user(self, user_input=None): # pylint: disable=unused-argument + """Call this as first page.""" + self._errors = {} + + user_input = dict() + user_input[CONF_NAME] = DOMAIN + +# OAUTH +# session = requests.Session() +# self.oauth = OAuthCredentials("","",session,"") +# self.code = await self.hass.async_add_executor_job(self.oauth.get_code) +# user_input[CONF_CODE] = self.code +# OAUTH + return self.async_show_form(step_id="oauth", data_schema=vol.Schema(await async_create_form(self.hass,user_input,1)), errors=self._errors) + + # we get here after the user click submit on the oauth screem + # lets check if oauth worked + async def async_step_oauth(self, user_input=None): # pylint: disable=unused-argument + self._errors = {} + if user_input is not None: + user_input[CONF_NAME] = user_input[CONF_NAME].replace(DOMAIN_MP+".","") # make sure to erase "media_player.bla" -> bla + self.data = user_input +# OAUTH +# try: +# self.token = await self.hass.async_add_executor_job(lambda: self.oauth.token_from_code(self.code["device_code"])) +# self.refresh_token = RefreshingToken(credentials=self.oauth, **self.token) +# self.refresh_token.update(self.refresh_token.as_dict()) +# except: +# self._errors["base"] = ERROR_GENERIC +# user_input[CONF_CODE] = self.code +# return self.async_show_form(step_id="oauth", data_schema=vol.Schema(await async_create_form(self.hass,user_input,1)), errors=self._errors) +# # if we get here then Oauth worked, right? +# user_input[CONF_HEADER_PATH] = os.path.join(self.hass.config.path(STORAGE_DIR),DEFAULT_HEADER_FILENAME+user_input[CONF_NAME].replace(' ','_')+'.json') +# OAUTH + self._errors = await async_check_data(self.hass,user_input) + if self._errors == {}: + user_input[CONF_HEADER_PATH] = os.path.join(self.hass.config.path(STORAGE_DIR),DEFAULT_HEADER_FILENAME+user_input[CONF_NAME].replace(' ','_')+'.json') + self.data = user_input + return self.async_show_form(step_id="finish", data_schema=vol.Schema(await async_create_form(self.hass,user_input,2)), errors=self._errors) + return self.async_show_form(step_id="oauth", data_schema=vol.Schema(await async_create_form(self.hass,user_input,1)), errors=self._errors) + + + # will be called by sending the form, until configuration is done + async def async_step_finish(self,user_input=None): + self._errors = {} + if user_input is not None: + self.data.update(user_input) +# OAUTH +# await self.hass.async_add_executor_job(lambda: self.refresh_token.store_token(self.data[CONF_HEADER_PATH])) +# OAUTH + if(self.data[CONF_ADVANCE_CONFIG]): + return self.async_show_form(step_id="adv_finish", data_schema=vol.Schema(await async_create_form(self.hass,user_input,3)), errors=self._errors) + else: + return self.async_create_entry(title="yTubeMusic "+self.data[CONF_NAME].replace(DOMAIN,''), data=self.data) + # we should never get below here + return self.async_show_form(step_id="finish", data_schema=vol.Schema(await async_create_form(self.hass,user_input,2)), errors=self._errors) + + + async def async_step_adv_finish(self,user_input=None): + self._errors = {} + self.data.update(user_input) + return self.async_create_entry(title="yTubeMusic "+self.data[CONF_NAME].replace(DOMAIN,''), data=self.data) + + + # TODO .. what is this good for? + async def async_step_import(self, user_input): # pylint: disable=unused-argument + """Import a config entry. + + Special type of import, we're not actually going to store any data. + Instead, we're going to rely on the values that are in config file. + """ + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + return self.async_create_entry(title="configuration.yaml", data={}) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Call back to start the change flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Change an entity via GUI.""" + + def __init__(self, config_entry): + """Set initial parameter to grab them later on.""" + # store old entry for later + self.config_entry = config_entry + self.data = {} + self.data.update(config_entry.data.items()) + + # will be called by sending the form, until configuration is done + async def async_step_init(self, user_input=None): # pylint: disable=unused-argument + """Call this as first page.""" + self._errors = {} + # sync data and user input + user_input = self.data + return self.async_show_form(step_id="oauth", data_schema=vol.Schema(await async_create_form(self.hass,user_input,1)), errors=self._errors) + + async def async_step_oauth(self, user_input=None): # pylint: disable=unused-argument + self._errors = {} + # sync data and user input again + self.data.update(user_input) + user_input = self.data + + if user_input is not None: + user_input[CONF_NAME] = user_input[CONF_NAME].replace(DOMAIN_MP+".","") # make sure to erase "media_player.bla" -> bla + self.data.update(user_input) + self._errors = await async_check_data(self.hass,user_input) + if self._errors == {}: + user_input[CONF_HEADER_PATH] = os.path.join(self.hass.config.path(STORAGE_DIR),DEFAULT_HEADER_FILENAME+user_input[CONF_NAME].replace(' ','_')+'.json') + self.data.update(user_input) + return self.async_show_form(step_id="finish", data_schema=vol.Schema(await async_create_form(self.hass,user_input,2)), errors=self._errors) + return self.async_show_form(step_id="oauth", data_schema=vol.Schema(await async_create_form(self.hass,user_input,1)), errors=self._errors) + + + # will be called by sending the form, until configuration is done + async def async_step_finish(self,user_input=None): + self._errors = {} + if user_input is not None: + # sync data and user input again + self.data.update(user_input) + user_input = self.data +# OAUTH +# await self.hass.async_add_executor_job(lambda: self.refresh_token.store_token(self.data[CONF_HEADER_PATH])) +# OAUTH + if(self.data[CONF_ADVANCE_CONFIG]): + return self.async_show_form(step_id="adv_finish", data_schema=vol.Schema(await async_create_form(self.hass,user_input,3)), errors=self._errors) + else: + return self.async_create_entry(title="yTubeMusic "+self.data[CONF_NAME].replace(DOMAIN,''), data=self.data) + # we should never get below here + return self.async_show_form(step_id="finish", data_schema=vol.Schema(await async_create_form(self.hass,user_input,2)), errors=self._errors) + + + async def async_step_adv_finish(self,user_input=None): + self._errors = {} + self.data.update(user_input) + self.hass.config_entries.async_update_entry(self.config_entry, data=ensure_config(self.data)) + return self.async_create_entry(title='', data={}) + +async def async_create_form(hass, user_input, page=1): + """Create form for UI setup.""" + user_input = ensure_config(user_input) + data_schema = OrderedDict() + languages = list(SUPPORTED_LANGUAGES) + + if(page == 1): +# data_schema[vol.Required(CONF_CODE+"TT", default="https://www.google.com/device?user_code="+user_input[CONF_CODE]["user_code"])] = str # name of the component without domain + data_schema[vol.Required(CONF_NAME, default=user_input[CONF_NAME])] = str # name of the component without domain + data_schema[vol.Required(CONF_COOKIE, default=user_input[CONF_COOKIE])] = str # configuration of the cookie + if(page == 2): + # Generate a list of excluded entities. + # This method is more reliable because it won't become invalid + # if users modify entity IDs, and it supports multiple instances. + _exclude_entities = [] + if (_ytm := hass.data.get(DOMAIN)) is not None: + for _ytm_player in _ytm.values(): + _exclude_entities.append(_ytm_player[DOMAIN_MP].entity_id) + + data_schema[vol.Required(CONF_RECEIVERS,default=user_input[CONF_RECEIVERS])] = selector({ + "entity": { + "multiple": "true", + "filter": [{"domain": DOMAIN_MP}], + "exclude_entities": _exclude_entities + } + }) + data_schema[vol.Required(CONF_API_LANGUAGE, default=user_input[CONF_API_LANGUAGE])] = selector({ + "select": { + "options": languages, + "mode": "dropdown", + "sort": True + } + }) + data_schema[vol.Required(CONF_HEADER_PATH, default=user_input[CONF_HEADER_PATH])] = str # file path of the header + data_schema[vol.Required(CONF_ADVANCE_CONFIG, default=user_input[CONF_ADVANCE_CONFIG])] = vol.Coerce(bool) # show page 2 + + elif(page == 3): + data_schema[vol.Optional(CONF_SHUFFLE, default=user_input[CONF_SHUFFLE])] = vol.Coerce(bool) # default shuffle, TRUE/FALSE + data_schema[vol.Optional(CONF_SHUFFLE_MODE, default=user_input[CONF_SHUFFLE_MODE])] = selector({ # choose default shuffle mode + "select": { + "options": ALL_SHUFFLE_MODES, + "mode": "dropdown" + } + }) + data_schema[vol.Optional(CONF_LIKE_IN_NAME, default=user_input[CONF_LIKE_IN_NAME])] = vol.Coerce(bool) # default like_in_name, TRUE/FALSE + data_schema[vol.Optional(CONF_DEBUG_AS_ERROR, default=user_input[CONF_DEBUG_AS_ERROR])] = vol.Coerce(bool) # debug_as_error, TRUE/FALSE + data_schema[vol.Optional(CONF_LEGACY_RADIO, default=user_input[CONF_LEGACY_RADIO])] = vol.Coerce(bool) # default radio generation typ + data_schema[vol.Optional(CONF_SORT_BROWSER, default=user_input[CONF_SORT_BROWSER])] = vol.Coerce(bool) # sort browser results + data_schema[vol.Optional(CONF_INIT_EXTRA_SENSOR, default=user_input[CONF_INIT_EXTRA_SENSOR])] = vol.Coerce(bool) # default radio generation typ + data_schema[vol.Optional(CONF_INIT_DROPDOWNS,default=user_input[CONF_INIT_DROPDOWNS])] = selector({ # choose dropdown(s) + "select": { + "options": ALL_DROPDOWNS, + "multiple": "true" + } + }) + # add for the old inputs. + for _old_conf_input in OLD_INPUTS.values(): + if user_input.get(_old_conf_input) is not None: + data_schema[vol.Optional(_old_conf_input, default=user_input[_old_conf_input])] = str + + data_schema[vol.Optional(CONF_TRACK_LIMIT, default=user_input[CONF_TRACK_LIMIT])] = vol.Coerce(int) + data_schema[vol.Optional(CONF_MAX_DATARATE, default=user_input[CONF_MAX_DATARATE])] = vol.Coerce(int) + data_schema[vol.Optional(CONF_BRAND_ID, default=user_input[CONF_BRAND_ID])] = str # brand id + + data_schema[vol.Optional(CONF_PROXY_PATH, default=user_input[CONF_PROXY_PATH])] = str # select of input_boolean -> continuous on/off + data_schema[vol.Optional(CONF_PROXY_URL, default=user_input[CONF_PROXY_URL])] = str # select of input_boolean -> continuous on/off + + return data_schema + + +async def async_check_data(hass, user_input): + """Check validity of the provided date.""" + ret = {} + if(CONF_COOKIE in user_input and CONF_HEADER_PATH in user_input): + # sadly config flow will not allow to have a multiline text field + # we get a looong string that we've to rearrange into multiline for ytmusic + + # so the fields are written like 'identifier': 'value', but some values actually have ':' inside, bummer. + # we'll split after every ': ', and try to parse the key + value + cs = user_input[CONF_COOKIE].split(": ") + key = [] + value = [] + c = "" # reset + remove_keys = {":authority", ":method", ":path", ":scheme"} # ytubemusic api doesn't like the google chrome arguments + for i in range(0,len(cs)-1): # we're grabbing [i] and [i+1], so skip the last and go only to len()-1 + key.append(cs[i][cs[i].rfind(' ')+1:]) # find the last STRING in the current element + value.append(cs[i+1]) # add the next element as value. This will contain the NEXT key which we're erasing later + if(i>0): # once we have more then one value + value[i-1] = value[i-1].replace(' '+key[i],'') # remove the current key from the last value + if(key[i-1] not in remove_keys): + c += key[i-1]+": "+value[i-1]+'\n' # re-join and add missing line break + if(i==len(cs)-2): # add last key value pair + c += key[i]+": "+value[i]+'\n' + + try: + ytmusicapi.setup(filepath = user_input[CONF_HEADER_PATH], headers_raw = c) + except: + ret["base"] = ERROR_GENERIC + formatted_lines = traceback.format_exc().splitlines() + for i in formatted_lines: + if(i.startswith('Exception: ')): + if(i.find('The following entries are missing in your headers: Cookie')>=0): + ret["base"] = ERROR_COOKIE + elif(i.find('The following entries are missing in your headers: X-Goog-AuthUser')>=0): + ret["base"] = ERROR_AUTH_USER + _LOGGER.error(traceback.format_exc()) + return ret + [ret, msg, api] = await async_try_login(hass,user_input[CONF_HEADER_PATH],"") + return ret \ No newline at end of file diff --git a/custom_components/ytube_music_player/const.py b/custom_components/ytube_music_player/const.py new file mode 100644 index 00000000..f72acd9d --- /dev/null +++ b/custom_components/ytube_music_player/const.py @@ -0,0 +1,386 @@ +from homeassistant.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT +import homeassistant.helpers.config_validation as cv +from homeassistant.components.media_player import MediaPlayerState, MediaPlayerEntityFeature +from homeassistant.components.media_player.const import MediaClass, MediaType, RepeatMode +import voluptuous as vol +import logging +import datetime +import traceback +import asyncio +from collections import OrderedDict +from ytmusicapi import YTMusic + + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_NAME, + CONF_USERNAME, + CONF_PASSWORD, + STATE_PLAYING, + STATE_PAUSED, + STATE_ON, + STATE_OFF, + STATE_IDLE, + ATTR_COMMAND, +) + +from homeassistant.components.media_player import ( + MediaPlayerEntity, + PLATFORM_SCHEMA, + SERVICE_TURN_ON, + SERVICE_TURN_OFF, + SERVICE_PLAY_MEDIA, + SERVICE_MEDIA_PAUSE, + SERVICE_VOLUME_UP, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_SET, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as DOMAIN_MP, +) + +# add for old settings +from homeassistant.components.input_boolean import ( + SERVICE_TURN_OFF as IB_OFF, + SERVICE_TURN_ON as IB_ON, + DOMAIN as DOMAIN_IB, +) + +import homeassistant.components.select as select +import homeassistant.components.input_select as input_select # add for old settings +import homeassistant.components.input_boolean as input_boolean # add for old settings + +# Should be equal to the name of your component. +PLATFORMS = {"sensor", "select", "media_player" } +DOMAIN = "ytube_music_player" + +SUPPORT_YTUBEMUSIC_PLAYER = ( + MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.SHUFFLE_SET + | MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SEEK +) + +SERVICE_SEARCH = "search" +SERVICE_ADD_TO_PLAYLIST = "add_to_playlist" +SERVICE_REMOVE_FROM_PLAYLIST = "remove_from_playlist" +SERVICE_LIMIT_COUNT = "limit_count" +SERVICE_RADIO = "start_radio" +ATTR_PARAMETERS = "parameters" +ATTR_QUERY = "query" +ATTR_FILTER = "filter" +ATTR_LIMIT = "limit" +ATTR_SONG_ID = "song_id" +ATTR_PLAYLIST_ID = "playlist_id" +ATTR_RATING = "rating" +ATTR_INTERRUPT = "interrupt" +SERVICE_CALL_METHOD = "call_method" +SERVICE_CALL_RATE_TRACK = "rate_track" +SERVICE_CALL_THUMB_UP = "thumb_up" +SERVICE_CALL_THUMB_DOWN = "thumb_down" +SERVICE_CALL_THUMB_MIDDLE = "thumb_middle" +SERVICE_CALL_TOGGLE_THUMB_UP_MIDDLE = "thumb_toggle_up_middle" +SERVICE_CALL_INTERRUPT_START = "interrupt_start" +SERVICE_CALL_INTERRUPT_RESUME = "interrupt_resume" +SERVICE_CALL_RELOAD_DROPDOWNS = "reload_dropdowns" +SERVICE_CALL_OFF_IS_IDLE = "off_is_idle" +SERVICE_CALL_PAUSED_IS_IDLE = "paused_is_idle" +SERVICE_CALL_IGNORE_PAUSED_ON_MEDIA_CHANGE = "ignore_paused_on_media_change" +SERVICE_CALL_DO_NOT_IGNORE_PAUSED_ON_MEDIA_CHANGE = "do_not_ignore_paused_on_media_change" +SERVICE_CALL_IDLE_IS_IDLE = "idle_is_idle" +SERIVCE_CALL_DEBUG_AS_ERROR = "debug_as_error" +SERVICE_CALL_LIKE_IN_NAME = "like_in_name" +SERVICE_CALL_GOTO_TRACK = "goto_track" +SERVICE_CALL_MOVE_TRACK = "move_track_within_queue" +SERVICE_CALL_APPEND_TRACK = "append_track_to_queue" + +CONF_RECEIVERS = 'speakers' # list of speakers (media_players) +CONF_HEADER_PATH = 'header_path' +CONF_API_LANGUAGE = 'api_language' +CONF_SHUFFLE = 'shuffle' +CONF_SHUFFLE_MODE = 'shuffle_mode' +CONF_COOKIE = 'cookie' +CONF_CODE = 'code' +CONF_BRAND_ID = 'brand_id' +CONF_ADVANCE_CONFIG = 'advance_config' +CONF_LIKE_IN_NAME = 'like_in_name' +CONF_DEBUG_AS_ERROR = 'debug_as_error' +CONF_LEGACY_RADIO = 'legacy_radio' +CONF_SORT_BROWSER = 'sort_browser' +CONF_INIT_EXTRA_SENSOR = 'extra_sensor' +CONF_INIT_DROPDOWNS = 'dropdowns' +ALL_DROPDOWNS = ["playlists","speakers","playmode","radiomode","repeatmode"] +DEFAULT_INIT_DROPDOWNS = ["playlists","speakers","playmode"] +CONF_MAX_DATARATE = 'max_datarate' + +CONF_TRACK_LIMIT = 'track_limit' +CONF_PROXY_URL = 'proxy_url' +CONF_PROXY_PATH = 'proxy_path' + +# add for old settings +CONF_SELECT_SOURCE = 'select_source' +CONF_SELECT_PLAYLIST = 'select_playlist' +CONF_SELECT_SPEAKERS = 'select_speakers' +CONF_SELECT_PLAYMODE = 'select_playmode' +CONF_SELECT_PLAYCONTINUOUS = 'select_playcontinuous' +OLD_INPUTS = { + "playlists": CONF_SELECT_PLAYLIST, + "speakers": CONF_SELECT_SPEAKERS, + "playmode": CONF_SELECT_PLAYMODE, + "radiomode": CONF_SELECT_SOURCE, + "repeatmode": CONF_SELECT_PLAYCONTINUOUS +} +DEFAULT_SELECT_PLAYCONTINUOUS = "" +DEFAULT_SELECT_SOURCE = "" +DEFAULT_SELECT_PLAYLIST = "" +DEFAULT_SELECT_PLAYMODE = "" +DEFAULT_SELECT_SPEAKERS = "" + +DEFAULT_HEADER_FILENAME = 'header_' +DEFAULT_API_LANGUAGE = 'en' +DEFAULT_LIKE_IN_NAME = False +DEFAULT_DEBUG_AS_ERROR = False +DEFAULT_INIT_EXTRA_SENSOR = False +PROXY_FILENAME = "ytube_proxy.mp4" + +DEFAULT_TRACK_LIMIT = 25 +DEFAULT_MAX_DATARATE = 129000 +DEFAULT_LEGACY_RADIO = True +DEFAULT_SORT_BROWSER = True + +ERROR_COOKIE = 'ERROR_COOKIE' +ERROR_AUTH_USER = 'ERROR_AUTH_USER' +ERROR_GENERIC = 'ERROR_GENERIC' +ERROR_CONTENTS = 'ERROR_CONTENTS' +ERROR_FORMAT = 'ERROR_FORMAT' +ERROR_NONE = 'ERROR_NONE' +ERROR_FORBIDDEN = 'ERROR_FORBIDDEN' + +PLAYMODE_SHUFFLE = "Shuffle" +PLAYMODE_RANDOM = "Random" +PLAYMODE_SHUFFLE_RANDOM = "Shuffle Random" +PLAYMODE_DIRECT = "Direct" + +ALL_SHUFFLE_MODES = [PLAYMODE_SHUFFLE, PLAYMODE_RANDOM, PLAYMODE_SHUFFLE_RANDOM, PLAYMODE_DIRECT] +DEFAULT_SHUFFLE_MODE = PLAYMODE_SHUFFLE_RANDOM +DEFAULT_SHUFFLE = True + +SEARCH_ID = "search_id" +SEARCH_TYPE = "search_type" +LIB_PLAYLIST = 'library_playlists' +LIB_PLAYLIST_TITLE = "Library Playlists" + +HOME_TITLE = "Home" +HOME_CAT = "home" +HOME_CAT_2 = "home2" + +LIB_ALBUM = 'library_albums' +LIB_ALBUM_TITLE = "Library Albums" + +LIB_TRACKS = 'library_tracks' +LIB_TRACKS_TITLE = "Library Songs" +ALL_LIB_TRACKS = 'all_library_tracks' +ALL_LIB_TRACKS_TITLE = 'All library tracks' + +HISTORY = 'history' +HISTORY_TITLE = "Last played songs" + +USER_TRACKS = 'user_tracks' +USER_TRACKS_TITLE = "Uploaded songs" + +USER_ALBUMS = 'user_albums' +USER_ALBUMS_TITLE = "Uploaded Albums" +USER_ALBUM = 'user_album' + +USER_ARTISTS = 'user_artists' +USER_ARTISTS_TITLE = "Uploaded Artists" + +USER_ARTISTS_2 = 'user_artists2' +USER_ARTISTS_2_TITLE = "Uploaded Artists -> Album" + +USER_ARTIST = 'user_artist' +USER_ARTIST_TITLE = "Uploaded Artist" + +USER_ARTIST_2 = 'user_artist2' +USER_ARTIST_2_TITLE = "Uploaded Album" + +SEARCH = 'search' +SEARCH_TITLE = "Search results" + +ALBUM_OF_TRACK = 'album_of_track' +ALBUM_OF_TRACK_TITLE = 'Album of current Track' + +PLAYER_TITLE = "Playback device" + +MOOD_OVERVIEW = 'mood_overview' +MOOD_PLAYLISTS = 'mood_playlists' +MOOD_TITLE = 'Moods & Genres' + +CUR_PLAYLIST = 'cur_playlists' +CUR_PLAYLIST_TITLE = "Current Playlists" +CUR_PLAYLIST_COMMAND = "PLAYLIST_GOTO_TRACK" + +CHANNEL = 'channel' +CHANNEL_VID = 'vid_channel' +CHANNEL_VID_NO_INTERRUPT = 'vid_no_interrupt_channel' +STATE_OFF_1X = 'OFF_1X' +BROWSER_LIMIT = 500 + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_RECEIVERS): cv.string, + vol.Optional(CONF_HEADER_PATH, default=DEFAULT_HEADER_FILENAME): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +# Shortcut for the logger +_LOGGER = logging.getLogger(__name__) + + + +async def async_try_login(hass, path, brand_id, language='en'): + ret = {} + api = None + msg = "" + #### try to init object ##### + try: + if(brand_id!=""): + _LOGGER.debug("- using brand ID: "+brand_id) + api = await hass.async_add_executor_job(YTMusic,path,brand_id,None,None,language) + else: + _LOGGER.debug("- login without brand ID and credential at path "+path) + api = await hass.async_add_executor_job(YTMusic,path,None,None,None,language) + except KeyError as err: + _LOGGER.debug("- Key exception") + if(str(err)=="'contents'"): + msg = "Format of cookie is OK, found '__Secure-3PAPISID' and '__Secure-3PSID' but can't retrieve any data with this settings, maybe you didn't copy all data?" + _LOGGER.error(msg) + ret["base"] = ERROR_CONTENTS + elif(str(err)=="'Cookie'"): + msg = "Format of cookie is NOT OK, Field 'Cookie' not found!" + _LOGGER.error(msg) + ret["base"] = ERROR_COOKIE + elif(str(err)=="'__Secure-3PAPISID'" or str(err)=="'__Secure-3PSID'"): + msg = "Format of cookie is NOT OK, likely missing '__Secure-3PAPISID' or '__Secure-3PSID'" + _LOGGER.error(msg) + ret["base"] = ERROR_FORMAT + else: + msg = "Some unknown error occured during the cookies usage, key is: "+str(err) + _LOGGER.error(msg) + _LOGGER.error("please see below") + _LOGGER.error(traceback.format_exc()) + ret["base"] = ERROR_GENERIC + except: + _LOGGER.debug("- Generic exception") + msg = "Format of cookie is NOT OK, missing e.g. AuthUser or Cookie" + _LOGGER.error(msg) + ret["base"] = ERROR_FORMAT + + #### try to grab library data ##### + if(api == None and ret == {}): + msg = "Format of cookie seams OK, but the returned sub API object is None" + _LOGGER.error(msg) + ret["base"] = ERROR_NONE + elif(not(api == None) and ret == {}): + try: + await hass.async_add_executor_job(api.get_library_songs) + except KeyError as err: + if(str(err)=="'contents'"): + msg = "Format of cookie is OK, found '__Secure-3PAPISID' and '__Secure-3PSID' but can't retrieve any data with this settings, maybe you didn't copy all data? Or did you log-out?" + _LOGGER.error(msg) + ret["base"] = ERROR_CONTENTS + except Exception as e: + if hasattr(e, 'args'): + if(len(e.args)>0): + if(isinstance(e.args[0],str)): + if(e.args[0].startswith("Server returned HTTP 403: Forbidden")): + msg = "The entered information has the correct format, but returned an error 403 (access forbidden). You don't have access with this data (anymore?). Please update the cookie" + _LOGGER.error(msg) + ret["base"] = ERROR_FORBIDDEN + else: + msg = "Running get_library_songs resulted in an exception, no idea why.. honestly" + _LOGGER.error(msg) + _LOGGER.error("Please see below") + _LOGGER.error(traceback.format_exc()) + ret["base"] = ERROR_GENERIC + return [ret, msg, api] + +def ensure_config(user_input): + """Make sure that needed Parameter exist and are filled with default if not.""" + out = {} + out[CONF_NAME] = DOMAIN + out[CONF_API_LANGUAGE] = DEFAULT_API_LANGUAGE + out[CONF_RECEIVERS] = '' + out[CONF_SHUFFLE] = DEFAULT_SHUFFLE + out[CONF_SHUFFLE_MODE] = DEFAULT_SHUFFLE_MODE + out[CONF_PROXY_PATH] = "" + out[CONF_PROXY_URL] = "" + out[CONF_BRAND_ID] = "" + out[CONF_COOKIE] = "" + out[CONF_ADVANCE_CONFIG] = False + out[CONF_LIKE_IN_NAME] = DEFAULT_LIKE_IN_NAME + out[CONF_DEBUG_AS_ERROR] = DEFAULT_DEBUG_AS_ERROR + out[CONF_TRACK_LIMIT] = DEFAULT_TRACK_LIMIT + out[CONF_LEGACY_RADIO] = DEFAULT_LEGACY_RADIO + out[CONF_SORT_BROWSER] = DEFAULT_SORT_BROWSER + out[CONF_INIT_EXTRA_SENSOR] = DEFAULT_INIT_EXTRA_SENSOR + out[CONF_INIT_DROPDOWNS] = DEFAULT_INIT_DROPDOWNS + out[CONF_MAX_DATARATE] = DEFAULT_MAX_DATARATE + + if user_input is not None: + # for the old shuffle_mode setting. + out.update(user_input) + if isinstance(_shuffle_mode := out[CONF_SHUFFLE_MODE], int): + if _shuffle_mode >= 1: + out[CONF_SHUFFLE_MODE] = ALL_SHUFFLE_MODES[_shuffle_mode - 1] + else: + out[CONF_SHUFFLE_MODE] = PLAYMODE_DIRECT + _LOGGER.debug(f"shuffle_mode: {_shuffle_mode} is a deprecated value and has been replaced with '{out[CONF_SHUFFLE_MODE]}'.") + + # If old input(s) exists,uncheck the new corresponding select(s). + # If the old input is set to a blank space character, then permanently delete this field. + for dropdown in ALL_DROPDOWNS: + if (_old_conf_input := out.get(OLD_INPUTS[dropdown])) is not None: + if _old_conf_input.replace(" ","") == "": + del out[OLD_INPUTS[dropdown]] + else: + if dropdown in out[CONF_INIT_DROPDOWNS]: + out[CONF_INIT_DROPDOWNS].remove(dropdown) + _LOGGER.debug(f"old {dropdown} input_select: {_old_conf_input} exists,uncheck the corresponding new select.") + return out + + +def find_thumbnail(item): + item_thumbnail = "" + try: + thumbnail_list = "" + if 'thumbnails' in item: + if 'thumbnail' in item['thumbnails']: + thumbnail_list = item['thumbnails']['thumbnail'] + else: + thumbnail_list = item['thumbnails'] + elif 'thumbnail' in item: + thumbnail_list = item['thumbnail'] + + if isinstance(thumbnail_list, list): + if 'url' in thumbnail_list[-1]: + item_thumbnail = thumbnail_list[-1]['url'] + except: + pass + return item_thumbnail diff --git a/custom_components/ytube_music_player/manifest.json b/custom_components/ytube_music_player/manifest.json new file mode 100644 index 00000000..e9c88909 --- /dev/null +++ b/custom_components/ytube_music_player/manifest.json @@ -0,0 +1,21 @@ +{ + "domain": "ytube_music_player", + "name": "YouTube Music Player", + "codeowners": [ + "@KoljaWindeler", + "@mang1985" + ], + "config_flow": true, + "dependencies": [ + "persistent_notification" + ], + "documentation": "https://github.com/KoljaWindeler/ytube_music_player", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/KoljaWindeler/ytube_music_player/issues", + "requirements": [ + "ytmusicapi==1.9.1", + "pytubefix==v8.8.5", + "integrationhelper==0.2.2" + ], + "version": "20241229.01" +} diff --git a/custom_components/ytube_music_player/media_player.py b/custom_components/ytube_music_player/media_player.py new file mode 100644 index 00000000..19a6525a --- /dev/null +++ b/custom_components/ytube_music_player/media_player.py @@ -0,0 +1,2176 @@ + +# Attempting to support yTube Music in Home Assistant # +import logging +import random +import os.path +import datetime +from urllib.request import urlopen, Request +from urllib.parse import unquote +import requests +from typing import Any, Callable, Dict, List, Optional, Tuple + +import voluptuous as vol +from homeassistant.components.media_player import BrowseError +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.core import Event +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.storage import STORAGE_DIR + +from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME +import homeassistant.components.media_player as media_player + +from pytubefix import YouTube # to generate cipher +from pytubefix import request # to generate cipher +from pytubefix import extract # to generate cipher + +import ytmusicapi +from pytubefix.exceptions import RegexMatchError +# use this to work with local version +# and make sure that the local package is also only loading local files +# from .ytmusicapi import YTMusic +from .browse_media import build_item_response, library_payload +from .const import * + + +################### Temp FIX remove me! ############################### +################### Temp FIX remove me! ############################### +import pytubefix, re + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + # Run setup via YAML + _LOGGER.debug("Config via YAML") + if(config is not None): + async_add_entities([yTubeMusicComponent(hass, config, "_yaml")], update_before_add=True) + + +async def async_setup_entry(hass, config, async_add_entities): + # Run setup via Storage + _LOGGER.debug("Config via Storage/UI") + if(len(config.data) > 0): + async_add_entities([yTubeMusicComponent(hass, config, "")], update_before_add=True) + + +class yTubeMusicComponent(MediaPlayerEntity): + def __init__(self, hass, config, name_add): + self.hass = hass + self._attr_unique_id = config.entry_id + self.hass.data[DOMAIN][self._attr_unique_id][DOMAIN_MP] = self + self._debug_log_concat = "" + self._debug_as_error = config.data.get(CONF_DEBUG_AS_ERROR, DEFAULT_DEBUG_AS_ERROR) + self._org_name = config.data.get(CONF_NAME, DOMAIN + name_add) + self._attr_name = self._org_name + self._api_language = config.data.get(CONF_API_LANGUAGE, DEFAULT_API_LANGUAGE) + self._init_extra_sensor = config.data.get(CONF_INIT_EXTRA_SENSOR, DEFAULT_INIT_EXTRA_SENSOR) + self._init_dropdowns = config.data.get(CONF_INIT_DROPDOWNS, DEFAULT_INIT_DROPDOWNS) + self._maxDatarate = config.data.get(CONF_MAX_DATARATE,DEFAULT_MAX_DATARATE) + + # All entities are now automatically generated,will be registered in the async_update_selects method later. + # This should be helpful for multiple accounts. + self._selects = dict() # use a dict to store the dropdown entity_id should be more convenient. + # For old settings. + for k,v in OLD_INPUTS.items(): + if v == CONF_SELECT_PLAYCONTINUOUS: + _domain = input_boolean.DOMAIN + else: + _domain = input_select.DOMAIN + try: + self._selects[k] = config.data.get(v) + except: + pass + if self._selects[k] is not None and self._selects[k].replace(" ","") != "": + self._selects[k] = _domain + "." + self._selects[k].replace(_domain + ".", "") + self.log_me('debug', "Found old {} {}: {},please consider using the new select entities.".format(_domain, k, self._selects[k] )) + + self._like_in_name = config.data.get(CONF_LIKE_IN_NAME, DEFAULT_LIKE_IN_NAME) + + self._attr_shuffle = config.data.get(CONF_SHUFFLE, DEFAULT_SHUFFLE) + self._shuffle_mode = config.data.get(CONF_SHUFFLE_MODE, DEFAULT_SHUFFLE_MODE) + + default_header_file = os.path.join(hass.config.path(STORAGE_DIR), DEFAULT_HEADER_FILENAME) + self._header_file = config.data.get(CONF_HEADER_PATH, default_header_file) + self._speakersList = config.data.get(CONF_RECEIVERS) + self._trackLimit = config.data.get(CONF_TRACK_LIMIT) + self._legacyRadio = config.data.get(CONF_LEGACY_RADIO) + self._sortBrowser = config.data.get(CONF_SORT_BROWSER) + self._friendly_speakersList = dict() + + # proxy settings + self._proxy_url = config.data.get(CONF_PROXY_URL, "") + self._proxy_path = config.data.get(CONF_PROXY_PATH, "") + + + self.log_me('debug', "YtubeMediaPlayer config: ") + self.log_me('debug', "- Header path: " + self._header_file) + self.log_me('debug', "- speakerlist: " + str(self._speakersList)) + self.log_me('debug', "- shuffle: " + str(self._attr_shuffle)) + self.log_me('debug', "- shuffle_mode: " + str(self._shuffle_mode)) + self.log_me('debug', "- like_in_name: " + str(self._like_in_name)) + self.log_me('debug', "- track_limit: " + str(self._trackLimit)) + self.log_me('debug', "- legacy_radio: " + str(self._legacyRadio)) + self.log_me('debug', "- max_dataRate: " + str(self._maxDatarate)) + + self._brand_id = str(config.data.get(CONF_BRAND_ID, "")) + self._api = None + self._js = "" + self._update_needed = False + + self._remote_player = "" + self._untrack_remote_player = None + self._untrack_remote_player_selector = None + self._playlists = [] + self._playlist_to_index = {} + self._tracks = [] + self._trackLimitUser = -1 + self._attributes = {} + self._playing = False + self._state = STATE_OFF + self._track_name = None + self._track_artist = None + self._track_album_name = None + self._track_album_cover = None + self._track_artist_cover = None + self._track_album_id = None + self._media_duration = None + self._media_position = None + self._media_position_updated = None + self._attributes['remote_player_state'] = STATE_OFF + self._attributes['likeStatus'] = "" + self._attributes['current_playlist_title'] = "" + self._attributes['videoId'] = "" + self._attributes['_media_type'] = "" + self._attributes['_media_id'] = "" + self._attributes['current_track'] = 0 + self._attributes['_media_type'] = None + self._attributes['_media_id'] = None + self._next_track_no = 0 + self._allow_next = False + self._last_auto_advance = datetime.datetime.now() + self._started_by = None + self._interrupt_data = None + self._attributes['remote_player_id'] = None + self._volume = 0.0 + self._is_mute = False + self._attr_repeat = RepeatMode.ALL + self._signatureTimestamp = 0 + self._x_to_idle = None # Some Mediaplayer don't transition to 'idle' but to 'off' on track end. This re-routes off to idle + self._ignore_paused_on_media_change = False # RobinR1, OwnTone compatibility + self._ignore_next_remote_pause_state = False # RobinR1, OwnTone compatibility: Some Mediaplayers temporarely switches to 'paused' during media changes (next/prev/seek) + self._search = {"query": "", "filter": None, "limit": 20} + self.reset_attributs() + + # register "call_method" + if(name_add == ""): + platform = entity_platform.current_platform.get() + platform.async_register_entity_service( + SERVICE_CALL_METHOD, + { + vol.Required(ATTR_COMMAND): cv.string, + vol.Optional(ATTR_PARAMETERS): vol.All( + cv.ensure_list, vol.Length(min=1), [cv.string] + ), + }, + "async_call_method", + ) + platform.async_register_entity_service( + SERVICE_SEARCH, + { + vol.Required(ATTR_QUERY): cv.string, + vol.Optional(ATTR_FILTER): cv.string, + vol.Optional(ATTR_LIMIT): vol.Coerce(int) + }, + "async_search", + ) + platform.async_register_entity_service( + SERVICE_ADD_TO_PLAYLIST, + { + vol.Optional(ATTR_SONG_ID): cv.string, + vol.Optional(ATTR_PLAYLIST_ID): cv.string + }, + "async_add_to_playlist", + ) + platform.async_register_entity_service( + SERVICE_REMOVE_FROM_PLAYLIST, + { + vol.Optional(ATTR_SONG_ID): cv.string, + vol.Optional(ATTR_PLAYLIST_ID): cv.string + }, + "async_remove_from_playlist", + ) + platform.async_register_entity_service( + SERVICE_CALL_RATE_TRACK, + { + vol.Required(ATTR_RATING): cv.string, + vol.Optional(ATTR_SONG_ID): cv.string + }, + "async_rate_track", + ) + platform.async_register_entity_service( + SERVICE_LIMIT_COUNT, + { + vol.Required(ATTR_LIMIT): vol.Coerce(int) + }, + "async_limit_count", + ) + platform.async_register_entity_service( + SERVICE_RADIO, + { + vol.Optional(ATTR_INTERRUPT): vol.Coerce(bool) + }, + "async_start_radio", + ) + + # run the api / get_cipher / update select as soon as possible + if hass.is_running: + self._update_needed = True + else: + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, self.async_startup) + + + # user had difficulties during the debug message on, so we'll provide a workaroud to post debug as errors + def log_me(self, type, msg): + # clear buffer of _later + # see if this is just the ending message: + try: + if(isinstance(msg, str)): + if(msg.find('[E]') == 0 and self._debug_log_concat != ""): + name = msg.split('[E]')[1] + if(self._debug_log_concat.find(name) >= 0): + self._debug_log_concat += " [E]" + msg = "" + if(self._debug_log_concat != ""): + if(self._debug_as_error): + _LOGGER.error(self._debug_log_concat) + else: + _LOGGER.debug(self._debug_log_concat) + self._debug_log_concat = "" + except: + self.exc() + # send new message + if(msg != ""): + if(self._debug_as_error or type == 'error'): + _LOGGER.error(msg) + else: + _LOGGER.debug(msg) + + def log_debug_later(self, msg): + # sum up a log + if(msg.find('[S]') == 0): # a new start message + if(self._debug_log_concat != ""): # if there is something in the buffer print it now + self.log_me("", "") + self._debug_log_concat = msg # start with a new buffer + else: # not a new messsage, append it + self._debug_log_concat += " ... " + msg + if self._debug_log_concat.find('[E]') >= 0: # if the end part is in the messeage print it now + self.log_me("", "") + + + def reset_attributs(self): + # reset some common attributs + self._playing = False + self._state = STATE_OFF + self._track_name = None + self._track_artist = None + self._track_album_name = None + self._track_album_cover = None + self._track_artist_cover = None + self._track_album_id = None + self._media_duration = None + self._media_position = None + self._media_position_updated = None + self._app_id = None + self._attributes['remote_player_state'] = STATE_OFF + self._attributes['likeStatus'] = "" + self._attributes['current_playlist_title'] = "" + self._attributes['videoId'] = "" + self._attributes['_media_type'] = "" + self._attributes['_media_id'] = "" + self._attributes['current_track'] = 0 + self._attributes['_media_type'] = None + self._attributes['_media_id'] = None + + self.hass.data[DOMAIN][self._attr_unique_id]['lyrics'] = "" + # After turning off the media_player, keep the playlists and search information available + # as they may be needed forautomations. + # self.hass.data[DOMAIN][self._attr_unique_id]['search'] = "" + self.hass.data[DOMAIN][self._attr_unique_id]['tracks'] = "" + # self.hass.data[DOMAIN][self._attr_unique_id]['playlists'] = "" + self.hass.data[DOMAIN][self._attr_unique_id]['total_tracks'] = 0 + + + async def async_update(self): + # update will be called eventually BEFORE homeassistant is started completly + # therefore we should not use this method for ths init + self.log_debug_later("[S] async_update") + if(self._update_needed): + self._update_needed = False + await self.async_startup(self.hass) + self.log_me('debug', "[E] async_update") + + # either called once homeassistant started (component was configured before startup) + # or call from update(), if the component was configured AFTER homeassistant was started + async def async_startup(self, hass): + self.log_me('debug', "[S] async_startup") + try: + await self.async_get_cipher('BB2mjBuAtiQ') + except: + self.log_me('error', "async_get_cipher failed") + try: + await self.async_check_api() + except: + self.log_me('error', "async_check_api failed") + try: + await self.async_update_selects() + except: + self.log_me('error', "async_update_selects failed") + try: + await self.async_update_playmode() + except: + self.log_me('error', "async_update_playmode failed") + self.log_me('debug', "[E] async_startup") + + async def async_check_api(self): + self.log_debug_later("[S] async_check_api") + if(self._api is None): + self.log_debug_later("- no valid API, try to login") + if(os.path.exists(self._header_file)): + [ret, msg, self._api] = await async_try_login(self.hass, self._header_file, self._brand_id, self._api_language) + if(msg != ""): + self._api = None + out = "Issue during login: " + msg + data = {"title": "yTubeMediaPlayer error", "message": out} + await self.hass.services.async_call("persistent_notification", "create", data) + self.log_me('debug', "[E] (fail) async_check_api") + return False + else: + self._signatureTimestamp = await self.hass.async_add_executor_job(self._api.get_signatureTimestamp) + try: + self.log_debug_later("YouTube Api initialized ok, version: " + str(ytmusicapi.__version__)) + except: + self.log_debug_later("YouTube Api initialized ok") + else: + out = "can't find header file at " + self._header_file + _LOGGER.error(out) + data = {"title": "yTubeMediaPlayer error", "message": out} + await self.hass.services.async_call("persistent_notification", "create", data) + self.log_me('debug', "[E] (fail) async_check_api") + return False + self.log_me('debug', "[E] async_check_api") + return True + + @property + def device_info(self): + return { + 'identifiers': {(DOMAIN, self._attr_unique_id)}, + 'name': self._attr_name, + 'manufacturer': "Google Inc.", + 'model': DOMAIN + } + + @property + def name(self): + # Return the name of the player. + return self._attr_name + + @property + def icon(self): + return 'mdi:music-circle' + + @property + def supported_features(self) -> media_player.MediaPlayerEntityFeature: + # Flag media player features that are supported. + return SUPPORT_YTUBEMUSIC_PLAYER + + @property + def should_poll(self): + # No polling needed. + return False + + @property + def state(self): + # Return the state of the device. + return self._state + + @property + def extra_state_attributes(self): + # Return the device state attributes. + return self._attributes + + @property + def is_volume_muted(self): + # Return True if device is muted + return self._is_mute + + @property + def is_on(self): + # Return True if device is on. + return self._playing + + @property + def media_content_type(self): + # Content type of current playing media. + return MediaType.MUSIC + + @property + def media_title(self): + # Title of current playing media. + return self._track_name + + @property + def media_artist(self): + # Artist of current playing media + return self._track_artist + + @property + def media_album_name(self): + # Album name of current playing media + return self._track_album_name + + @property + def media_image_url(self): + # Image url of current playing media. + return self._track_album_cover + + @property + def media_image_remotely_accessible(self): + # True returns: entity_picture: http://lh3.googleusercontent.com/Ndilu... + # False returns: entity_picture: /api/media_player_proxy/media_player.gmusic_player?token=4454... + return True + + @property + def media_position(self): + # Position of current playing media in seconds. + return self._media_position + + + @property + def media_position_updated_at(self): + # When was the position of the current playing media valid. + # Returns value from homeassistant.util.dt.utcnow(). + # + return self._media_position_updated + + + @property + def media_duration(self): + # Duration of current playing media in seconds. + return self._media_duration + + + @property + def shuffle(self): + # Boolean if shuffling is enabled. + return self._attr_shuffle + + @property + def repeat(self): + # Return current repeat mode. + return self._attr_repeat + + async def async_set_repeat(self, repeat: str): + if self.repeat != repeat: + self.log_me('debug', f"[S] set_repeat: {repeat}") + # Set repeat mode. + self._attr_repeat = repeat + if(self._selects['repeatmode'] is not None): + if repeat == RepeatMode.ALL: + ib_repeat = STATE_ON + else: + ib_repeat = STATE_OFF + if (_state := self.hass.states.get(self._selects['repeatmode']).state) != repeat: + if input_boolean.DOMAIN in self._selects['repeatmode']: + if _state != ib_repeat: + data = {ATTR_ENTITY_ID: self._selects['repeatmode']} + if ib_repeat == STATE_ON: + await self.hass.services.async_call(input_boolean.DOMAIN, IB_ON, data) + else: + await self.hass.services.async_call(input_boolean.DOMAIN, IB_OFF, data) + else: + data = {select.ATTR_OPTION: repeat, ATTR_ENTITY_ID: self._selects['repeatmode']} + await self.hass.services.async_call(select.DOMAIN, select.SERVICE_SELECT_OPTION, data) + + self.log_me('debug', f"[E] set_repeat: {repeat}") + self.async_schedule_update_ha_state() + + @property + def volume_level(self): + # Volume level of the media player (0..1). + return self._volume + + + async def async_turn_on(self, *args, **kwargs): + # Turn on the selected media_player from select + self.log_me('debug', "[S] TURNON") + self._started_by = "UI" + + # exit if we don't konw what to play (the select_playlist will be set to "" if the config provided a value but the entity_id is not in homeassistant) + if self._selects['playlists'] is None: + self.log_me('debug', "no or wrong playlist select field in the config, exiting") + msg = "You have no playlist entity_id in your config, or that entity_id is not in homeassistant. I don't know what to play and will exit. Either use the media_browser or add the playlist dropdown" + data = {"title": "yTubeMediaPlayer error", "message": msg} + await self.hass.services.async_call("persistent_notification", "create", data) + await self.async_turn_off_media_player() + self.log_me('debug', "[E] (fail) TURNON") + return + + # set UI to correct playlist, or grab playlist if none was submitted + playlist = self.hass.states.get(self._selects['playlists']).state + + # exit if we don't have any playlists from the account + if(len(self._playlists) == 0): + _LOGGER.error("playlists empty") + await self.async_turn_off_media_player() + self.log_me('debug', "[E] (fail) TURNON") + return + + # load ID for playlist name + idx = self._playlist_to_index.get(playlist) + if idx is None: + _LOGGER.error("playlist to index is none!") + await self.async_turn_off_media_player() + self.log_me('debug', "[E] (fail) TURNON") + return + + # playlist or playlist_radio? + if self._selects['radiomode'] is not None: + _source = self.hass.states.get(self._selects['radiomode']) + if _source is None: + _LOGGER.error("- (%s) is not a valid select entity.", self._selects['radiomode']) + self.log_me('debug', "[E] (fail) TURNON") + return + if(_source.state == "Playlist"): + self._attributes['_media_type'] = MediaType.PLAYLIST + else: + self._attributes['_media_type'] = CHANNEL + else: + self._attributes['_media_type'] = MediaType.PLAYLIST + + # store id and start play_media + self._attributes['_media_id'] = self._playlists[idx]['playlistId'] + self.log_me('debug', "[E] TURNON") + return await self.async_play_media(media_type=self._attributes['_media_type'], media_id=self._attributes['_media_id']) + + async def async_prepare_play(self): + self.log_me('debug', "[S] async_prepare_play") + if(not await self.async_check_api()): + self.log_me('debug', "[E] (fail) async_prepare_play") + return False + + # get _remote_player + if not await self.async_update_remote_player(): + self.log_me('debug', "[E] (fail) async_prepare_play") + return False + _player = self.hass.states.get(self._remote_player) + + # subscribe to changes + if self._selects['playmode'] is not None: + async_track_state_change_event(self.hass, self._selects['playmode'], self.async_update_playmode) + if self._selects['repeatmode'] is not None: + async_track_state_change_event(self.hass, self._selects['repeatmode'], self.async_update_playmode) + if self._selects['speakers'] is not None: + async_track_state_change_event(self.hass, self._selects['speakers'], self.async_select_source_helper) + + # make sure that the player, is on and idle + try: + if self._playing is True: + await self.async_media_stop() + elif self._playing is False and self._state == STATE_OFF: + if _player.state == STATE_OFF: + await self.async_turn_on_media_player() + else: + self.log_me('debug', "self._state is: (" + self._state + ").") + if(self._state == STATE_PLAYING): + await self.async_media_stop() + except: + _LOGGER.error("We hit an error during prepare play, likely related to issue 52") + _LOGGER.error("Player: " + str(_player) + ".") + _LOGGER.error("remote_player: " + str(self._remote_player) + ".") + self.exc() + + + # update cipher + await self.async_get_cipher('BB2mjBuAtiQ') + + # display imidiatly a loading state to provide feedback to the user + self._allow_next = False + self._track_album_name = "" + self._track_artist = "" + self._track_artist_cover = None + self._track_album_cover = None + self._track_name = "loading..." + self._state = STATE_PLAYING # a bit early otherwise no info will be shown + self.async_schedule_update_ha_state() + self.log_me('debug', "[E] async_prepare_play") + return True + + async def async_turn_on_media_player(self, data=None): + self.log_debug_later("[S] async_turn_on_media_player") + # Fire the on action. + if data is None: + data = {ATTR_ENTITY_ID: self._remote_player} + self._state = STATE_IDLE + self.async_schedule_update_ha_state() + self.log_me('debug', "[E] async_turn_on_media_player") + await self.hass.services.async_call(DOMAIN_MP, 'turn_on', data) + + + async def async_turn_off(self, entity_id=None, old_state=None, new_state=None, **kwargs): + # Turn off the selected media_player + self.log_me('debug', "turn_off") + self._playing = False + await self.async_turn_off_media_player() + + async def async_turn_off_media_player(self, data=None): + self.log_debug_later("[S] async_turn_off_media_player") + # Fire the off action. + self.reset_attributs() + if(self._like_in_name): + self._attr_name = self._org_name + self.async_schedule_update_ha_state() + if(self._remote_player == ""): + if(not(await self.async_update_remote_player())): + self.log_me('debug', "[E] (fail) async_turn_off_media_player") + return + if(data != 'skip_remote_player'): + data = {ATTR_ENTITY_ID: self._remote_player} + await self.hass.services.async_call(DOMAIN_MP, 'media_stop', data) + await self.hass.services.async_call(DOMAIN_MP, 'turn_off', data) + + # unsubscribe from remote media_player + if(self._untrack_remote_player is not None): + try: + self._untrack_remote_player() + except: + pass + self._untrack_remote_player = None + + self.log_me('debug', "[E] async_turn_off_media_player") + + + async def async_update_remote_player(self, remote_player=""): + self.log_debug_later("[S] async_update_remote_player(Input " + str(remote_player) + "/ current " + str(self._remote_player) + ") ") + if(remote_player == self._remote_player and remote_player != ""): + self.log_me('debug', " no change [E]") + return + + + old_remote_player = self._remote_player + # sanitize player, remove domain + remote_player = remote_player.replace(DOMAIN_MP + ".", "") + + if(remote_player != ""): + # make sure that the entity ID is complete + remote_player = DOMAIN_MP + "." + remote_player + # sets the current media_player from speaker select + elif(self._selects['speakers'] is not None and await self.async_check_entity_exists(self._selects['speakers'], unavailable_is_ok=False)): # drop down for player does exist .. double check!! + media_player_select = self.hass.states.get(self._selects['speakers']) # Example: self.hass.states.get(select.gmusic_player_speakers) + if media_player_select is None: + self.log_me('error', "(" + self._selects['speakers'] + ") is not a valid select entity to get the player.") + else: + # since we can't be sure if the MediaPlayer Domain is in the field value, add it and remove it :D + remote_player = DOMAIN_MP + "." + media_player_select.state.replace(DOMAIN_MP + ".", "") + + # ok lets check if we have a player or post an error + if(await self.async_check_entity_exists(remote_player)): + self._remote_player = remote_player + self._attributes['remote_player_id'] = self._remote_player + elif(await self.async_check_entity_exists(self._remote_player)): + self._attributes['remote_player_id'] = self._remote_player + else: + self._track_name = "Please select player first" + self.async_schedule_update_ha_state() + msg = "Please select a player before start playing, e.g. via the 'media_player.select_source' method or in the settings/config_flow" + data = {"title": "yTubeMediaPlayer error", "message": msg} + await self.hass.services.async_call("persistent_notification", "create", data) + self.log_me('error', "No player selected or the selected player isn't available (" + str(remote_player) + "/" + str(self._remote_player) + "), you will not be able to play music, please set the default player in the settings/config_flow or call media_player.select_source") + self.log_me('debug', "[E] (fail) async_update_remote_player") + return False + + # unsubscribe / resubscribe to the player, because the old subscrition was for the old player + if self._remote_player != old_remote_player: + if(self._untrack_remote_player is not None): + try: + self._untrack_remote_player() + except: + pass + self._untrack_remote_player = None + if(self._untrack_remote_player is None): + self._untrack_remote_player = async_track_state_change_event(self.hass, self._remote_player, self.async_sync_player) + self.log_me('debug', "[E] async_update_remote_player") + return True + + + async def async_get_cipher(self, videoId): + self.log_debug_later("[S] async_get_cipher") + embed_url = "https://www.youtube.com/embed/" + videoId + # this is why we need pytubefix as include + embed_html = await self.hass.async_add_executor_job(request.get, embed_url) + js_url = extract.js_url(embed_html) + self._js = await self.hass.async_add_executor_job(request.get, js_url) + self._cipher = pytubefix.cipher.Cipher(js=self._js, js_url=js_url) + # this is why we need pytubefix as include + self.log_me('debug', "[E] async_get_cipher") + + async def async_sync_player(self, event=None): + self.log_debug_later("[S] async_sync_player") + if isinstance(event,Event): + _event_data = event.data + entity_id = _event_data.get('entity_id') + old_state = _event_data.get('old_state') + new_state = _event_data.get('new_state') + else: + entity_id = None + old_state = None + new_state = None + self.log_me('debug', f"event: {event}") + + if(entity_id is not None and old_state is not None) and new_state is not None: + self.log_debug_later(entity_id + ": " + old_state.state + " -> " + new_state.state) + if(entity_id.lower() != self._remote_player.lower()): + self.log_me('debug', "- ignoring player " + str(entity_id) + " the player of interest is " + str(self._remote_player)) + return + else: + self.log_debug_later(self._remote_player) + + # Perform actions based on the state of the selected (Speakers) media_player # + if not self._playing: + self.log_debug_later("not playing [E]") + return + # _player = The selected speakers # + _player = self.hass.states.get(self._remote_player) + + # Only update the duration and especially the position if we're not in pause + # else the mini-media-player will advance during our pause state + if(self._state != STATE_PAUSED): + if('media_duration' in _player.attributes): + self._media_duration = _player.attributes['media_duration'] + if('media_position' in _player.attributes): + self._media_position = _player.attributes['media_position'] + if('media_position_updated_at' in _player.attributes): + if(isinstance(_player.attributes['media_position_updated_at'],datetime.datetime)): + self._media_position_updated = _player.attributes['media_position_updated_at'] + else: + self._media_position_updated = datetime.datetime.now(datetime.timezone.utc) + else: + self._media_position_updated = datetime.datetime.now(datetime.timezone.utc) + + # Workaround for chromecast sometimes not playing first song in a playlist + if(old_state!=None and new_state!=None): + try: + if(old_state.state == STATE_IDLE and new_state.state == STATE_PAUSED): + if(self._state == STATE_PLAYING): + self.log_me('error','chromecast in pause should be playings, '+str(old_state)) + await self.async_get_track() + except: + pass + + # detect app switch an turn off if so + if('app_id' in _player.attributes): + if(self._app_id == None): + self._app_id = _player.attributes['app_id'] + self.log_me('debug', "detected app _id, "+str(self._app_id)) + elif (_player.attributes['app_id'] != self._app_id): + self.log_me('debug', "detected different app _id, shuttiung down without interruption") + await self.async_turn_off_media_player('skip_remote_player') + return + + # entity_id of selected speakers. # + self._attributes['remote_player_id'] = _player.entity_id + + # _player state - Example [playing -or- idle]. # + self._attributes['remote_player_state'] = _player.state + + # unlock allow next, some player fail because their media_position is 'strange' catch # + found_position = False + try: + if 'media_position' in _player.attributes: + found_position = True + if(isinstance(_player.attributes['media_position'], int)): + if _player.state == 'playing' and _player.attributes['media_position'] > 0: + self._allow_next = True + except: + found_position = False + if not(found_position) and _player.state == 'playing': # fix for browser mod media_player not providing the 'media_position' + self._allow_next = True + + # auto next .. best cast: we have an old and a new state # + if(old_state is not None and new_state is not None): + # chromecast quite frequently change from playing to idle twice, so we need some kind of time guard + if(old_state.state == STATE_PLAYING and new_state.state == STATE_IDLE and (datetime.datetime.now() - self._last_auto_advance).total_seconds() > 10): + self._allow_next = False + # add track to history + try: + response = await self.hass.async_add_executor_job(lambda: self._api.get_song(self._attributes['videoId'], self._signatureTimestamp)) + await self.hass.async_add_executor_job(lambda: self._api.add_history_item(response)) + except: + self.log_me('debug', "adding "+self._attributes['videoId']+" to history failed") + + await self.async_get_track(auto_advance=True) + # turn this player off when the remote_player was shut down + elif((old_state.state == STATE_PLAYING or old_state.state == STATE_IDLE or old_state.state == STATE_PAUSED) and new_state.state == STATE_OFF): + if(self._x_to_idle == STATE_OFF or self._x_to_idle == STATE_OFF_1X): # workaround for MPD (changes to OFF at the end of a track) + self._allow_next = False + # add track to history + try: + response = await self.hass.async_add_executor_job(lambda: self._api.get_song(self._attributes['videoId'], self._signatureTimestamp)) + await self.hass.async_add_executor_job(lambda: self._api.add_history_item(response)) + except: + self.log_me('debug', "adding "+self._attributes['videoId']+" to history failed") + await self.async_get_track(auto_advance=True) + if(self._x_to_idle == STATE_OFF_1X): + self._x_to_idle = None + else: + self._state = STATE_OFF + self.log_me('debug', "media player got turned off") + await self.async_turn_off() + # workaround for SONOS (changes to PAUSED at the end of a track) + elif(old_state.state == STATE_PLAYING and new_state.state == STATE_PAUSED and # noqa: W504 + (datetime.datetime.now() - self._last_auto_advance).total_seconds() > 10 and # noqa: W504 + self._x_to_idle == STATE_PAUSED): + # add track to history + try: + response = await self.hass.async_add_executor_job(lambda: self._api.get_song(self._attributes['videoId'], self._signatureTimestamp)) + await self.hass.async_add_executor_job(lambda: self._api.add_history_item(response)) + except: + self.log_me('debug', "adding "+self._attributes['videoId']+" to history failed") + self._allow_next = False + await self.async_get_track(auto_advance=True) + # set this player in to pause state when the remote player does, or ignore when assumed it is a temporary state (as some players do while seeking/skipping track) + elif(old_state.state == STATE_PLAYING and new_state.state == STATE_PAUSED): + self.log_me('debug', "Remote Player changed from PLAYING to PAUSED.") + if(self._ignore_paused_on_media_change and self._ignore_next_remote_pause_state): # RobinR1, OwnTone compatibility + self.log_me('debug', "Ignoring state change") # RobinR1, OwnTone compatibility + self._ignore_next_remote_pause_state = False # RobinR1, OwnTone compatibility + return # RobinR1, OwnTone compatibility + else: # RobinR1, OwnTone compatibility + return await self.async_media_pause() + # resume playback when the player does + elif(old_state.state == STATE_PAUSED and new_state.state == STATE_PLAYING and self._state == STATE_PAUSED): + return await self.async_media_play() + # player changes itsself from pause -> idle (while we where in pause state) + elif(old_state.state == STATE_PAUSED and new_state.state == STATE_IDLE and self._state == STATE_PAUSED): + self.log_me('debug', "Remote Player changed from PAUSED to IDLE withouth our interaction, so likely another source is using the player now. I'll step back and swich myself off") + await self.async_turn_off_media_player('skip_remote_player') + return + # no states, lets rely on stuff like _allow_next + elif _player.state == 'idle': + if self._allow_next: + if (datetime.datetime.now() - self._last_auto_advance).total_seconds() > 10: + self._allow_next = False + await self.async_get_track(auto_advance=True) + + + # Set new volume if it has been changed on the _player # + if 'volume_level' in _player.attributes: + self._volume = round(_player.attributes['volume_level'], 2) + self.async_schedule_update_ha_state() + self.log_me('debug', "[E] async_sync_player") + + async def async_ytubemusic_play_media(self, event): + self.log_me('debug', "[S] async_ytubemusic_play_media") + _speak = event.data.get('speakers') + _source = event.data.get('source') + _media = event.data.get('name') + + if event.data['shuffle_mode']: + self._shuffle_mode = event.data.get('shuffle_mode') + _LOGGER.info("SHUFFLE_MODE: %s", self._shuffle_mode) + + if event.data['shuffle']: + self.async_set_shuffle(event.data.get('shuffle')) + _LOGGER.info("- SHUFFLE: %s", self._attr_shuffle) + + self.log_me('debug', "- Speakers: (%s) | Source: (%s) | Name: (%s)", _speak, _source, _media) + await self.async_play_media(_source, _media, _speak) + self.log_me('debug', "[E] async_ytubemusic_play_media") + + + def extract_info(self, _track): + # self.log_me('debug', "extract_info") + # If available, get track information. # + info = dict() + info['track_album_name'] = "" + info['track_artist_cover'] = "" + info['track_name'] = "" + info['track_artist'] = "" + info['track_album_cover'] = "" + info['track_album_id'] = "" + + try: + if 'title' in _track: + info['track_name'] = _track['title'] + except: + pass + + try: + if 'byline' in _track: + info['track_artist'] = _track['byline'] + elif 'artists' in _track: + info['track_artist'] = "" + if(isinstance(_track["artists"], str)): + info['track_artist'] = _track["artists"] + elif(isinstance(_track["artists"], list)): + for t in _track['artists']: + if 'name' in t: + name = t['name'] + else: + name = t + if(info['track_artist'] == ""): + info['track_artist'] = name + else: + info['track_artist'] += " / " + name + elif 'author' in _track: + info['track_artist'] = _track['author'] # use 'author' if still no artist info. + except: + pass + + try: + _album_art_ref = None + if 'thumbnail' in _track: + _album_art_ref = _track['thumbnail'] # returns a list, + if 'thumbnails' in _album_art_ref: + _album_art_ref = _album_art_ref['thumbnails'] + elif 'thumbnails' in _track: + _album_art_ref = _track['thumbnails'] # returns a list + + if isinstance(_album_art_ref, list): + th_width = 0 + for th_data in _album_art_ref: + if('width' in th_data and 'url' in th_data): + if(th_data['width'] > th_width): + th_width = th_data['width'] + info['track_album_cover'] = th_data['url'] + except: + pass + + try: + if 'album' in _track: + info['track_album_name'] = _track['album']['name'] # fix missing album info. + if 'id' in _track['album']: + info['track_album_id'] = _track['album']['id'] + except: + pass + + # make sure all extracted infos are actually strings + for key in info: + if(info[key] is None): + info[key] = "" + return info + + async def async_select_source_helper(self, event=None): + self.log_me('debug', "[S] async_select_source_helper") + # redirect call, obviously we got called by status change, so we can call it without argument and let it pick + source_entity_id = None + source = self.hass.states.get(self._selects['speakers']).state + # get entity id from friendly_name + for e, f in self._friendly_speakersList.items(): + if(f == source): + source_entity_id = e + break + if(source_entity_id is None): + self.log_me('debug', "- Couldn't find " + source + " in dropdown list, giving up") + return + else: + self.log_me('debug', 'Translated friendly name ' + source + ' to entity id ' + source_entity_id) + self.log_me('debug', "[E] async_select_source_helper") + return await self.async_select_source(source_entity_id) + + async def async_select_source(self, source=None): + self.log_me('debug', "[S] async_select_source(" + str(source) + ")") + # source should just be the NAME without DOMAIN, to select it in the dropdown + if(isinstance(source, str)): + source = source.replace(DOMAIN_MP + ".", "") + # shutdown old player if we're currently playimg + was_playing = self._playing + if(self._playing): + self.log_me('debug', "- was playing") + old_player = self.hass.states.get(self._remote_player) + await self.async_media_stop(player=self._remote_player) # important to mention the player here explictly. We're going to change it and stuff runs async + # set player + if(source is not None): + # set entity_id + await self.async_update_remote_player(remote_player=DOMAIN_MP + "." + source) + self.log_me('debug', "- Choosing " + self._remote_player + " as player") + # try to set drop down + if self._selects['speakers'] is not None: + if(not await self.async_check_entity_exists(self._selects['speakers'], unavailable_is_ok=False)): + self.log_me('debug', "- Drop down for media player: " + str(self._selects['speakers']) + " not found") + elif source in self._friendly_speakersList: + # untrack player field change (to avoid self call) + if(self._untrack_remote_player_selector is not None): + try: + self._untrack_remote_player_selector() + self._untrack_remote_player_selector = None + self.log_me('debug', "- untrack passed") + except: + self.log_me('debug', "- untrack failed") + pass + if self.hass.states.get(self._selects['speakers']).state != self._friendly_speakersList[source]: + data = {select.ATTR_OPTION: self._friendly_speakersList[source], ATTR_ENTITY_ID: self._selects['speakers']} + await self.hass.services.async_call(select.DOMAIN, select.SERVICE_SELECT_OPTION, data) + # resubscribe with 3 sec delay so the UI can settle, directly call it will trigger the change from above + async_call_later(self.hass, 3, self.async_track_select_mediaplayer_helper) + else: + self.log_me('debug', "- Selected player '" + source + "' not found in options for Drop down, skipping") + else: + # load from dropdown, if that fails, exit + if(not await self.async_update_remote_player()): + _LOGGER.error("- async_update_remote_player failed") + return + # if playing, switch player + if(was_playing): + # don't call "_play" here, as that resets the playlist position + await self.async_get_track() + # seek, if possible + new_player = self.hass.states.get(self._remote_player) + if (all(a in old_player.attributes for a in ('media_position', 'media_position_updated_at', 'media_duration')) and 'supported_features' in new_player.attributes): + if(new_player.attributes['supported_features'] | MediaPlayerEntityFeature.SEEK): + now = datetime.datetime.now(datetime.timezone.utc) + delay = now - old_player.attributes['media_position_updated_at'] + pos = delay.total_seconds() + old_player.attributes['media_position'] + if pos < old_player.attributes['media_duration']: + data = {'seek_position': pos, ATTR_ENTITY_ID: self._remote_player} + await self.hass.services.async_call(DOMAIN_MP, media_player.SERVICE_MEDIA_SEEK, data) + self.async_schedule_update_ha_state() + self.log_me('debug', "[E] async_select_source") + + + async def async_update_selects(self, now=None): + self.log_me('debug', "[S] async_update_selects") + # -- Register dropdown(s). -- # + for dropdown in self._init_dropdowns: + if not await self.async_check_entity_exists(self._selects[dropdown], unavailable_is_ok=False): + entity_id = self.hass.data[DOMAIN][self._attr_unique_id][f'select_{dropdown}'].entity_id + if await self.async_check_entity_exists(entity_id, unavailable_is_ok=False): + self._selects[dropdown] = entity_id + self.log_me('debug', f"- {dropdown} select: {str(entity_id)} registered") + + # track changes + if(self._untrack_remote_player_selector is not None): + try: + self._untrack_remote_player_selector() + except: + self.log_me('error', 'untrack failed') + if self._selects['speakers'] is not None: + self._untrack_remote_player_selector = async_track_state_change_event(self.hass, self._selects['speakers'], self.async_select_source_helper) + if self._selects['playmode'] is not None: + async_track_state_change_event(self.hass, self._selects['playmode'], self.async_update_playmode) + if self._selects['repeatmode'] is not None: + async_track_state_change_event(self.hass, self._selects['repeatmode'], self.async_update_playmode) + # ----------- speaker -----# + try: + if(isinstance(self._speakersList, str)): + speakersList = [self._speakersList] + else: + speakersList = list(self._speakersList) + for i in range(0, len(speakersList)): + speakersList[i] = speakersList[i].replace(DOMAIN_MP + ".", "") + except: + speakersList = list() + + # generate the speaker list in any case (will be needed for the media_browser) + if(len(speakersList) <= 1): # Perhaps this behavior is no longer necessary? + all_entities = await self.hass.async_add_executor_job(self.hass.states.all) + for e in all_entities: + if(e.entity_id.startswith(DOMAIN_MP) and not(e.entity_id.startswith(DOMAIN_MP + "." + DOMAIN))): + speakersList.append(e.entity_id.replace(DOMAIN_MP + ".", "")) + # create friendly speakerlist based on the current speakerLlist + self._friendly_speakersList = dict() + for a in speakersList: + state = self.hass.states.get(DOMAIN_MP + "." + a) + friendly_name = state.attributes.get(ATTR_FRIENDLY_NAME) + if(friendly_name is None): + friendly_name = a + self._friendly_speakersList.update({a: friendly_name}) + friendly_speakersList = list(self._friendly_speakersList.values()) + if self._selects['speakers'] is not None: + if input_select.DOMAIN in self._selects['speakers']: + _select = input_select + else: + _select = select + data = {_select.ATTR_OPTIONS: friendly_speakersList, ATTR_ENTITY_ID: self._selects['speakers']} + if _select == input_select: + await self.hass.services.async_call(input_select.DOMAIN, input_select.SERVICE_SET_OPTIONS, data) + else: + await self.hass.data[DOMAIN][self._attr_unique_id]['select_speakers'].async_update(friendly_speakersList) # update speaker select + + data = {_select.ATTR_OPTION: friendly_speakersList[0], ATTR_ENTITY_ID: self._selects['speakers']} # select the first one in the list as the default player + await self.hass.services.async_call(_select.DOMAIN, _select.SERVICE_SELECT_OPTION, data) + else: + # we need to set the default player here, as there is no selct field. without a select field we don't get updates from the field and will never set the default player + await self.async_select_source(speakersList[0]) + + # finally call update playlist to fill the list .. if it exists + await self.async_update_playlists() + self.log_me('debug', "[E] async_update_selects") + + async def async_check_entity_exists(self, e, unavailable_is_ok=True): + try: + r = self.hass.states.get(e) + if(r is None): + return False + if(r.state == "unavailable"): # needed, some dropdown field will report as "unavailable" although they don't exist + if(not(unavailable_is_ok)): + return False + return True + except: + return False + + async def async_update_playlists(self, now=None): + self.log_me('debug', "[S] async_update_playlists") + # Sync playlists from Google Music library # + if(self._api is None): + self.log_me('debug', "- no api, exit") + return + if self._selects['playlists'] is None: + self.log_me('debug', "- no playlist select field, exit") + return + + self._playlist_to_index = {} + playlists_to_extra = {} + try: + try: + self._playlists = await self.hass.async_add_executor_job(lambda: self._api.get_library_playlists(limit=self._trackLimit)) + self._playlists = self._playlists[:self._trackLimit] # limit function doesn't really work ... loads at least 25 + self.log_me('debug', " - " + str(len(self._playlists)) + " Playlists loaded") + except: + self._api = None + self.exc() + return + idx = -1 + for playlist in self._playlists: + idx = idx + 1 + name = playlist.get('title', '') + if len(name) < 1: + continue + self._playlist_to_index[name] = idx + # the "your likes" playlist won't return a count of tracks + if not('count' in playlist): + try: + extra_info = await self.hass.async_add_executor_job(self._api.get_playlist, playlist['playlistId']) + if('trackCount' in extra_info): + self._playlists[idx]['count'] = int(extra_info['trackCount']) + else: + self._playlists[idx]['count'] = 25 + except: + if('playlistId' in playlist): + self.log_me('debug', "- Failed to get_playlist count for playlist ID '" + str(playlist['playlistId']) + "' setting it to 25") + else: + self.log_me('debug', "- Failed to get_playlist, no playlist ID") + self.exc() + self._playlists[idx]['count'] = 25 + playlists_to_extra[playlist['title']] = playlist['playlistId'] + + if(len(self._playlists) == 0): + self._playlist_to_index["No playlists found"] = 0 + + # sort with case-ignore + playlists = sorted(list(self._playlist_to_index.keys()), key=str.casefold) + await self.async_update_extra_sensor('playlists', playlists_to_extra) # update extra sensor + if self._selects['playlists'] is not None: # update playlist select + if input_select.DOMAIN in self._selects['playlists']: + data = {input_select.ATTR_OPTIONS: list(playlists), ATTR_ENTITY_ID: self._selects["playlists"]} + await self.hass.services.async_call(input_select.DOMAIN, input_select.SERVICE_SET_OPTIONS, data) + else: + await self.hass.data[DOMAIN][self._attr_unique_id]['select_playlists'].async_update() + except: + self.exc() + msg = "Caught error while loading playlist. please log for details" + data = {"title": "yTubeMediaPlayer error", "message": msg} + await self.hass.services.async_call("persistent_notification", "create", data) + self.log_me('debug', "[E] async_update_playlists") + + + async def _tracks_to_attribute(self): + self.log_debug_later("[S] _tracks_to_attribute") + await self.async_update_extra_sensor('total_tracks', len(self._tracks)) + track_attributes = [] + for track in self._tracks: + info = self.extract_info(track) + track_attributes.append(info['track_artist'] + " - " + info['track_name']) + await self.async_update_extra_sensor('tracks', track_attributes) # update extra sensor + + # fire event to let media card know to update + event_data = { + "device_id": self._attr_unique_id, + "entity_id": self.entity_id, + "type": "reload_playlist", + } + self.hass.bus.async_fire(DOMAIN+"_event", event_data) + self.log_me('debug', "[E] _tracks_to_attribute") + + async def async_update_extra_sensor(self, attribute, value): + # update extra sensor + self.log_debug_later("[S] async_update_extra_sensor") + self.hass.data[DOMAIN][self._attr_unique_id][attribute] = value + if(self._init_extra_sensor): + try: + await self.hass.data[DOMAIN][self._attr_unique_id]['extra_sensor'].async_update() + except: + self.log_me('debug', "Update failed") + pass + self.log_me('debug', "[E] async_update_extra_sensor") + + async def async_update_playmode(self, event=None): + # called from HA when th user changes the input entry, will read selection to membervar + # Changing the shuffle_mode while music is playing will no longer cause interruptions. + # Only when shuffle_mode=true the next song will be random. + # By default, when shuffle_mode = "Shuffle", shuffle=False. However, + # the _attr_shuffle variable can be changed during playback without interrupting the music. + self.log_me('debug', "[S] async_update_playmode") + try: + if self._selects['repeatmode'] is not None: + if (_state := self.hass.states.get(self._selects['repeatmode']).state) == STATE_ON: + _state = RepeatMode.ALL + await self.async_set_repeat(_state) + except: + self.log_me('debug', "- Selection field " + self._selects['repeatmode'] + " not found, skipping") + + try: + if self._selects['playmode'] is not None: + if (_playmode := self.hass.states.get(self._selects['playmode']).state) is not None: + if _playmode in (PLAYMODE_SHUFFLE,PLAYMODE_DIRECT): + shuffle = False + else: + shuffle = True + self._shuffle_mode = _playmode + await self.async_set_shuffle(shuffle) # The non-existent set_shuffle method was incorrectly called previously. + self.log_me('debug', f"- Playmode: {_playmode}") + except: + self.log_me('debug', "- Selection field " + self._selects['playmode'] + " not found, skipping") + + self.log_me('debug', "[E] async_update_playmode") + + + async def async_play(self): + self.log_me('debug', f"_play,shuffle:{self.shuffle},shuffle_mode:{self._shuffle_mode}") + self._next_track_no = 0 + if self.shuffle: + await self.async_get_track(keep_track_no=False) + else: + await self.async_get_track() + + async def async_get_track(self, event=None, retry=3, auto_advance=False, keep_track_no=True): + self.log_me('debug', "[S] async_get_track") + # Get a track and play it from the track_queue. + # grab next track from prefetched lis + _track = None + # get next track nr (randomly or by increasing). + if auto_advance: # auto_advance=true means that the call is coming from automatic playback of the next track. + if self.repeat == RepeatMode.ONE: + self.log_me('debug', "Single track loop.") + elif self.shuffle: + self._next_track_no = random.randrange(len(self._tracks)) - 1 + self.log_me('debug', "Random track.") + else: + self._next_track_no = self._next_track_no + 1 + self.log_me('debug', "- Playing track nr " + str(self._next_track_no + 1) + " / " + str(len(self._tracks))) # technically + 1 is wrong, but is still less confusing + if self._next_track_no >= len(self._tracks): + # we've reached the end of the playlist + if(self.repeat == RepeatMode.ALL): + # call PLAY_MEDIA with the same arguments + # return await self.async_play_media(media_type=self._attributes['_media_type'], media_id=self._attributes['_media_id']) + self._next_track_no = 0 # This maybe better. + else: + _LOGGER.info("- End of playlist and repeat mode is off") + await self.async_turn_off_media_player() + return + elif keep_track_no: + self.log_me('debug', "The track_no has already been specified,do not change it.") + elif self.shuffle: + self._next_track_no = random.randrange(len(self._tracks)) - 1 + self.log_me('debug', f"auto_advance={auto_advance},keep_track_no={keep_track_no},repeat={self.repeat},shuffle_mode={self._shuffle_mode}") + self.log_me('debug', "Press the next/pref button and shuffle is true, play random track.") + else: + self.log_me('debug', f"Uncaught Situation,auto_advance={auto_advance},keep_track_no={keep_track_no},repeat={self.repeat},shuffle_mode={self._shuffle_mode}") + + + # get track from array of _trackS + try: + _track = self._tracks[self._next_track_no] + except: + _LOGGER.error("- Out of range! Number of tracks in track_queue == (%s)", len(self._tracks)) + self._api = None + await self.async_turn_off_media_player() + return + if _track is None: + _LOGGER.error("- _track is None!") + await self.async_turn_off_media_player() + return + + # make sure there is a videoId + if not('videoId' in _track): + _LOGGER.error("- Failed to get ID for track: (%s)", _track) + _LOGGER.error(_track) + if retry < 1: + await self.async_turn_off_media_player() + return + return await self.async_get_track(retry=retry - 1, auto_advance=auto_advance, keep_track_no=keep_track_no) + + # updates attributes + self._attributes['current_track'] = self._next_track_no + self._attributes['videoId'] = _track['videoId'] + if('likeStatus' in _track): + self._attributes['likeStatus'] = _track['likeStatus'] + if(self._like_in_name): + self._attr_name = self._org_name + " - " + str(_track['likeStatus']) + else: + self._attributes['likeStatus'] = "" + if(self._like_in_name): + self._attr_name = self._org_name + # this will quickly update the information although the thumbnail might not super great, we'll update that later + info = self.extract_info(_track) + self._track_album_name = info['track_album_name'] + self._track_artist_cover = info['track_artist_cover'] + self._track_name = info['track_name'] + self._track_artist = info['track_artist'] + self._track_album_cover = info['track_album_cover'] + self._track_album_id = info['track_album_id'] + self.async_schedule_update_ha_state() + + # Get the stream URL and play on media_player + _url = await self.async_get_url(_track['videoId']) + if(_url == ""): + if retry < 1: + self.log_me('debug', "- get track failed to return URL, turning off") + await self.async_turn_off_media_player() + return + else: + _LOGGER.error("- Retry with: (%i)", retry) + return await self.async_get_track(retry=retry - 1, auto_advance=auto_advance, keep_track_no=keep_track_no) + + # proxy playback, needed e.g. for sonos + try: + if(self._proxy_url != "" and self._proxy_path != "" and self._proxy_url != " " and self._proxy_path != " "): + p1 = datetime.datetime.now() + _proxy_url = await self.hass.async_add_executor_job(lambda: urlopen(Request(_url, headers={'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/35.0.1916.47 Safari/537.36'}))) + _proxy_file = open(os.path.join(self._proxy_path, PROXY_FILENAME), 'wb') + _proxy_url_content = await self.hass.async_add_executor_job(_proxy_url.read) + await self.hass.async_add_executor_job(_proxy_file.write, _proxy_url_content) + if(self._proxy_url.endswith('/')): + self._proxy_url = self._proxy_url[:-1] + _url = self._proxy_url + "/" + PROXY_FILENAME + t = (datetime.datetime.now() - p1).total_seconds() + self.log_me('debug', "- proxy loading time: " + str(t) + " sec") + except: + _LOGGER.error("The proxy method hit an error, turning off") + self.exc() + await self.async_turn_off_media_player() + return + + # start playback + self._state = STATE_PLAYING + self._playing = True + self.async_schedule_update_ha_state() + self._last_auto_advance = datetime.datetime.now() # avoid auto_advance + data = { + ATTR_MEDIA_CONTENT_ID: _url, + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_ENTITY_ID: self._remote_player, + "extra": { + "metadata": { + "metadataType": 3, + "title": self._track_name, + "artist": self._track_artist, + "images": [ + { + "url": self._track_album_cover, + } + ] + } + } + } + self.log_me('debug', "- forwarding url to player " + str(self._remote_player)) + await self.hass.services.async_call(DOMAIN_MP, SERVICE_PLAY_MEDIA, data) + + # get lyrics and more info after playback started + await self.async_update_extra_sensor('lyrics', 'No lyrics available') + + + try: + l_id = await self.hass.async_add_executor_job(self._api.get_watch_playlist, _track['videoId']) + if 'lyrics' in l_id: + if(l_id['lyrics'] is not None): + lyrics = await self.hass.async_add_executor_job(self._api.get_lyrics, l_id['lyrics']) + await self.async_update_extra_sensor('lyrics', lyrics['lyrics']) + # the nice thing about this 'get_watch_playlist' is that one gets also extra info about the current track + # like a better thumbnail. The original thumbnail from get_playlist has poor quality. + for vid in l_id['tracks']: + if(('videoId' in vid) and (vid['videoId'] == _track['videoId'])): + info = self.extract_info(vid) + if(self._track_album_cover != info['track_album_cover']): + self._track_album_cover = info['track_album_cover'] + self.async_schedule_update_ha_state() + break + except: + pass + async_call_later(self.hass, 15, self.async_sync_player) + self.log_me('debug', "[E] async_get_track") + + + async def async_get_url(self, videoId=None, retry=60): + self.log_me('debug', "[S] async_get_url") + if(videoId is None): + self.log_me('debug', "videoId was None") + return "" + _url = await self.async_get_url_self(videoId,retry) + + # check url + if(_url != ""): + r = await self.hass.async_add_executor_job(requests.head, _url) + if(r.status_code == 403): + self.log_me('error', "- self decoded url return 403 status code, attempt "+str(retry)+"/60") + _url = "" + + if(_url == ""): + _url = await self.async_get_url_pytube(videoId) + + # check url + if(_url != ""): + r = await self.hass.async_add_executor_job(requests.head, _url) + if(r.status_code == 403 or r.status_code == 410): + self.log_me('error', "- self decoded url return 403 status code, attempt "+str(retry)+"/60") + _url = "" + + if(retry>0): + self._api = None + self.log_me('debug', "- relogin to fresh cookie and try again") + self.async_check_api() + return await self.async_get_url(videoId, retry-1) + else: + self.log_me('debug', "- giving up, maybe pyTube can help") + _url = "" + + self.log_me('debug', "[E] async_get_url") + return _url + + + async def async_get_url_self(self, videoId=None, retry=60): + self.log_me('debug', "[S] async_get_url_self") + _url = "" + await self.async_check_api() + try: + stop = False + self.log_me('debug', "- try to find URL on our own") + try: + response = await self.hass.async_add_executor_job(lambda: self._api.get_song(videoId, self._signatureTimestamp)) + except: + self._api = None + self.log_me('error', 'self.get_song(videoId=' + str(videoId) + ',signatureTimestamp=' + str(self._signatureTimestamp) + ')') + self.exc() + return + streamingData = [] + if 'streamingData' in response: + if('adaptiveFormats' in response['streamingData']): + streamingData += response['streamingData']['adaptiveFormats'] + if('formats' in response['streamingData']): # backup, not sure if that is ever needed, or if adaptiveFormats are always present + streamingData += response['streamingData']['formats'] + if(len(streamingData) == 0): + self.log_me('error', 'No adaptiveFormat and no formats found') + self.log_me('error', 'self.get_song(videoId=' + str(videoId) + ',signatureTimestamp=' + str(self._signatureTimestamp) + ')') + stop = True + else: + stop = True + + if(not(stop)): + streamId = 0 + found_quality = -1 + quality_mapper = {'AUDIO_QUALITY_LOW': 1, 'AUDIO_QUALITY_MEDIUM': 2, 'AUDIO_QUALITY_HIGH': 3} + # try to find valid audio streams + valid_streams = [] + for i, stream in enumerate(streamingData): + #self.log_me('debug', 'found stream') + #self.log_me('error',stream) + if('audioQuality' in stream): + # self.log_me('error', '- found stream with audioQuality ' + stream['audioQuality'] + ' (' + str(i) + ')') + # store only stream with better quality, accept 0 once + stream['audioQuality'] = quality_mapper.get(stream['audioQuality'], 0) + valid_streams.append(stream) + elif(found_quality == -1): # only search for mimetype if we didn't find a quality stream before + if('mimeType' in stream): + if(stream['mimeType'].startswith('audio/mp4')): + self.log_me('debug', '- found audio/mp4 audiostream (' + str(i) + ')') + stream['audioQuality'] = quality_mapper.get(stream['audioQuality'], 0) + valid_streams.append(stream) + elif(stream['mimeType'].startswith('audio')): + self.log_me('debug', '- found audio audiostream (' + str(i) + ')') + stream['audioQuality'] = quality_mapper.get(stream['audioQuality'], 0) + valid_streams.append(stream) + + # try to find best audio only stream, but accept lower quality if we have to + valid_streams.sort(key=lambda x: x['bitrate'], reverse=True) + self.log_me('debug', "found "+str(len(valid_streams))+" streams") + # remove all streams with too high bitrates + if(self._maxDatarate>0): + for stream in valid_streams: + if(stream['bitrate']>self._maxDatarate): + if(len(valid_streams)>1): + valid_streams.remove(stream) + self.log_me('debug', '- removed stream with too high bitrate of '+str(stream['bitrate'])) + else: + self.log_me('debug', '- preseved stream, too high quality but last available stream') + + if(retry<20): + streamId = min(3,len(valid_streams)) + elif(retry<30): + streamId = min(2,len(valid_streams)) + elif(retry<40): + streamId = min(1,len(valid_streams)) + else: + streamId = 0 + + self.log_me('debug', 'Using stream '+str(streamId)+"/"+str(len(valid_streams))+", bitrate:"+str(valid_streams[streamId]['bitrate'])) + # self.log_me('debug', '- using stream ' + str(streamId)) + if(valid_streams[streamId].get('url') is None): + sigCipher_ch = valid_streams[streamId]['signatureCipher'] + sigCipher_ex = sigCipher_ch.split('&') + res = dict({'s': '', 'url': ''}) + for sig in sigCipher_ex: + for key in res: + if(sig.find(key + "=") >= 0): + res[key] = unquote(sig[len(key + "="):]) + # I'm just not sure if the original video from the init will stay online forever + # in case it's down the player might not load and thus we won't have a javascript loaded + # so if that happens: we try with this url, might work better (at least the file should be online) + # the only trouble i could see is that this video is private and thus also won't load the player .. + if(self._js == "" or retry<60): + self.log_me('debug', "- reloading cipher from current video") + await self.async_get_cipher(videoId) + + #stream_manifest = extract.apply_descrambler(self.streaming_data) + ##try: + #extract.apply_signature(stream_manifest, self.vid_info, self.js) + ##except exceptions.ExtractError: + ## extract.apply_signature(stream_manifest, self.vid_info, self.js) + if "signature" in res['url'] or ("s" not in res and ("&sig=" in res['url'] or "&lsig=" in res['url'])): + # For certain videos, YouTube will just provide them pre-signed, in + # which case there's no real magic to download them and we can skip + # the whole signature descrambling entirely. + self.log_me('error',"signature found, skip decipher") + _url = res['url'] + else: + self.log_me('error',"signature not found, decoding") + signature = self._cipher.get_signature(ciphered_signature=res['s']) + _url = res['url'] + "&sig=" + signature + self.log_me('debug', "- self decoded URL via cipher") + + else: + _url = valid_streams[streamId]['url'] + self.log_me('debug', "- found URL in api data") + + except Exception: + _LOGGER.error("- Failed to get own(!) URL for track, further details below. Will not try YouTube method") + _LOGGER.error(traceback.format_exc()) + _LOGGER.error(videoId) + self.log_me('debug', "[E] async_get_url") + return _url + + async def async_get_url_pytube(self, videoId=None): + # backup: run youtube stack, only if we failed + self.log_me('debug', "[S] async_get_url_pytube") + _url = "" + try: + streamingData = await self.hass.async_add_executor_job(lambda: YouTube("https://www.youtube.com/watch?v=" + videoId)) + streams = await self.hass.async_add_executor_job(lambda: streamingData.streams) + streams_audio = streams.filter(only_audio=True) + if(len(streams_audio) > 0): + _url = streams_audio.order_by('abr').last().url + else: + _url = streams.order_by('abr').last().url + + except Exception as err: + # _LOGGER.error(traceback.format_exc()) + _LOGGER.error("- Failed to get URL with YouTube methode") + _LOGGER.error(err) + return "" + self.log_me('debug', "[E] async_get_url_pytube") + return _url + + + async def async_play_media(self, media_type, media_id, _player=None, **kwargs): + self.log_me('debug', "[S] play_media, media_type: " + str(media_type) + ", media_id: " + str(media_id)) + + self._started_by = "Browser" + self._attributes['_media_type'] = media_type + self._attributes['_media_id'] = media_id + + if(not(media_type in [CONF_RECEIVERS,CHANNEL_VID_NO_INTERRUPT])): # don't to this for the speaker configuration (it will fail) and also skip it for the vid no interrupt + if(not(await self.async_prepare_play())): + return + + # Update player if we got an input + if _player is not None: + await self.async_update_remote_player(remote_player=_player) + if self._selects['speakers'] is not None: + if input_select.DOMAIN in self._selects['speakers']: + _select = input_select + else: + _select = select + data = {_select.ATTR_OPTION: _player, ATTR_ENTITY_ID: self._selects['speakers']} + await self.hass.services.async_call(_select.DOMAIN, _select.SERVICE_SELECT_OPTION, data) + + # load Tracks depending on input + try: + crash_extra = '' + self._attributes['current_playlist_title'] = "" + if(media_type == MediaType.PLAYLIST): + crash_extra = 'get_playlist(playlistId=' + str(media_id) + ')' + if(media_id == ALL_LIB_TRACKS): + self._tracks = await self.hass.async_add_executor_job(lambda: self._api.get_library_songs(limit=self._trackLimit)) + self._attributes['current_playlist_title'] = ALL_LIB_TRACKS_TITLE + else: + playlist_info = await self.hass.async_add_executor_job(lambda: self._api.get_playlist(media_id, limit=self._trackLimit)) + self._tracks = playlist_info['tracks'][:self._trackLimit] # limit function doesn't really work ... seems like + self._attributes['current_playlist_title'] = str(playlist_info['title']) + elif(media_type == MediaType.ALBUM): + crash_extra = 'get_album(browseId=' + str(media_id) + ')' + if media_id[:7] == "OLAK5uy": #Sharing over Android app sends 'bad' album id. Checking and converting. + media_id = await self.hass.async_add_executor_job(self._api.get_album_browse_id, media_id) + self._tracks = await self.hass.async_add_executor_job(self._api.get_album, media_id) # no limit needed + thumbnail = find_thumbnail(self._tracks) + self._tracks = self._tracks['tracks'][:self._trackLimit] # limit function doesn't really work ... seems like + for i in range(0, len(self._tracks)): + self._tracks[i].update({'album': {'id': media_id}}) + self._tracks[i].update({'thumbnails': [{'url': thumbnail}]}) + elif(media_type == MediaType.TRACK): + crash_extra = 'get_song(videoId=' + str(media_id) + ',signatureTimestamp=' + str(self._signatureTimestamp) + ')' + self._tracks = [await self.hass.async_add_executor_job(lambda: self._api.get_song(media_id, self._signatureTimestamp))] # no limit needed + self._tracks[0] = self._tracks[0]['videoDetails'] + elif(media_id == HISTORY): + crash_extra = 'get_history()' + self._tracks = await self.hass.async_add_executor_job(self._api.get_history) # no limit needed + elif(media_id == USER_TRACKS): + crash_extra = 'get_library_upload_songs(limit=999)' + self._tracks = await self.hass.async_add_executor_job(self._api.get_library_upload_songs, self._trackLimit) + self._tracks = self._tracks[:self._trackLimit] # limit function doesn't really work ... seems like + elif(media_type == CHANNEL): + if(self._legacyRadio): + # get original playlist from the media_id + crash_extra = 'get_playlist(playlistId=' + str(media_id) + ',limit=' + str(self._trackLimit) + ')' + self._tracks = await self.hass.async_add_executor_job(lambda: self._api.get_playlist(media_id, limit=self._trackLimit)) + self._tracks = self._tracks['tracks'] + # select on track randomly + if(isinstance(self._tracks, list)): + if(len(self._tracks) > 0): + if(len(self._tracks) > 1): + r_track = self._tracks[random.randrange(0, len(self._tracks) - 1)] + info = self.extract_info(r_track) + self._attributes['_radio_based'] = info['track_artist'] + " - " + info['track_name'] + else: + r_track = self._tracks[0] + # get a 'channel' based on that random track + crash_extra += ' ... get_watch_playlist(videoId=' + str(r_track['videoId']) + ',limit=' + str(self._trackLimit) + ')' + self._tracks = await self.hass.async_add_executor_job(lambda: self._api.get_watch_playlist(r_track['videoId'], limit=self._trackLimit)) + self._tracks = self._tracks['tracks'][:self._trackLimit] # limit function doesn't really work ... seems like + else: + crash_extra = 'get_watch_playlist(playlistId=RDAMPL' + str(media_id) + ')' + self._tracks = await self.hass.async_add_executor_job(lambda: self._api.get_watch_playlist(playlistId="RDAMPL" + str(media_id), limit=self._trackLimit)) + self._tracks = self._tracks['tracks'][:self._trackLimit] # limit function doesn't really work ... seems like + self._started_by = "UI" # technically wrong, but this will enable auto-reload playlist once all tracks are played + playlist_info = await self.hass.async_add_executor_job(lambda: self._api.get_playlist(media_id, limit=self._trackLimit)) + self._attributes['current_playlist_title'] = "Radio of " + str(playlist_info['title']) + elif(media_type == CHANNEL_VID or media_type==CHANNEL_VID_NO_INTERRUPT): + crash_extra = 'get_watch_playlist(videoId=RDAMVM' + str(media_id) + ')' + self._tracks = await self.hass.async_add_executor_job(lambda: self._api.get_watch_playlist(videoId=str(media_id), limit=self._trackLimit)) + self._tracks = self._tracks['tracks'][:self._trackLimit] # limit function doesn't really work ... seems like + self._started_by = "UI" # technically wrong, but this will enable auto-reload playlist once all tracks are played + video_info = await self.hass.async_add_executor_job(lambda: self._api.get_song(media_id, self._signatureTimestamp)) # no limit needed + title = "unknown title" + if("videoDetails" in video_info): + if("title" in video_info["videoDetails"]): + title = video_info['videoDetails']['title'] + self._attributes['current_playlist_title'] = "Radio of " + str(title) + elif(media_type == USER_ALBUM): + crash_extra = 'get_library_upload_album(browseId=' + str(media_id) + ')' + self._tracks = await self.hass.async_add_executor_job(lambda: self._api.get_library_upload_album(media_id)) + self._tracks = self._tracks['tracks'][:self._trackLimit] # limit function here not supported + elif(media_type in (USER_ARTIST, USER_ARTIST_2)): # Artist -> Track or Artist [-> Album ->] Track + crash_extra = 'get_library_upload_artist(browseId=' + str(media_id) + ')' + self._tracks = await self.hass.async_add_executor_job(lambda: self._api.get_library_upload_artist(media_id, limit=self._trackLimit)) + self._tracks = self._tracks[:self._trackLimit] # limit function doesn't really work ... seems like + elif(media_type == CONF_RECEIVERS): # a bit funky, but this enables us to select the player via the media browser .. + await self.async_select_source(media_id) + elif(media_type == CUR_PLAYLIST_COMMAND): # a bit funky, but this enables us to just in the current playlist + await self.async_call_method(SERVICE_CALL_GOTO_TRACK, media_id) + return # INSTANT leave after this call to prevent any further shuffeling etc + else: + self.log_me('debug', "- error during fetching play_media, turning off") + await self.async_turn_off() + except: + self._api = None + self.log_me('debug', crash_extra) + self.exc() + await self.async_turn_off_media_player() + return + self.log_me('debug', crash_extra) + + # mode "Shuffle" and "Shuffle Random" shuffle the playlist after generation, but only if shuffle is active + if(isinstance(self._tracks, list)): + if(self._attr_shuffle): + if self._shuffle_mode in (PLAYMODE_SHUFFLE,PLAYMODE_SHUFFLE_RANDOM) and len(self._tracks) > 1: + random.shuffle(self._tracks) + self.log_me('debug', "- shuffle new tracklist") + if(len(self._tracks) == 0): + _LOGGER.error("tracklist with 0 tracks loaded, existing") + await self.async_turn_off() + return + else: + self.log_me('error', "Tracklist not a list .. turning off") + await self.async_turn_off() + return + + # limit list now + if(self._trackLimitUser > 0): + self.log_me('debug', "Limiting playlist from " + str(len(self._tracks)) + " to " + str(self._trackLimitUser) + " items") + self._tracks = self._tracks[:self._trackLimitUser] + await self._tracks_to_attribute() + + # grab track from tracks[] and forward to remote player + if(media_type != CHANNEL_VID_NO_INTERRUPT): + await self.async_play() + self.log_me('debug', "[E] play_media") + + + async def async_media_play(self, entity_id=None, old_state=None, new_state=None, **kwargs): + self.log_me('debug', "[S] media_play") + + # Send play command. + if self._state == STATE_PAUSED: + self._state = STATE_PLAYING + self.async_schedule_update_ha_state() + data = {ATTR_ENTITY_ID: self._remote_player} + await self.hass.services.async_call(DOMAIN_MP, 'media_play', data) + else: + await self.async_play() + self.log_me('debug', "[E] media_play") + + + async def async_media_pause(self, **kwargs): + self.log_me('debug', "media_pause") + # Send media pause command to media player + self._state = STATE_PAUSED + self._media_position = None # set it to none, otherwise player like mini-media-player will continue + # _LOGGER.error(" PAUSE ") + self.async_schedule_update_ha_state() + data = {ATTR_ENTITY_ID: self._remote_player} + await self.hass.services.async_call(DOMAIN_MP, 'media_pause', data) + + async def async_media_play_pause(self, **kwargs): + self.log_me('debug', "media_play_pause") + # Simulate play pause media player. + if self._state == STATE_PLAYING: + self._allow_next = False + await self.async_media_pause() + elif(self._state == STATE_PAUSED): + self._allow_next = False + await self.async_media_play() + + async def async_media_previous_track(self, **kwargs): + # Send the previous track command. + if self._playing: + self._allow_next = False + self._ignore_next_remote_pause_state = True # RobinR1, OwnTone compatibility + if self.shuffle: + await self.async_get_track(keep_track_no=False) + else: + self._next_track_no = max(self._next_track_no - 1, 0) + await self.async_get_track() + + async def async_media_next_track(self, **kwargs): + # Send next track command. + if self._playing: + self._allow_next = False + self._ignore_next_remote_pause_state = True # RobinR1, OwnTone compatibility + if self.shuffle: + await self.async_get_track(keep_track_no=False) + else: + self._next_track_no = self._next_track_no + 1 + if self._next_track_no >= len(self._tracks): + self._next_track_no = 0 + await self.async_get_track() + + async def async_media_stop(self, **kwargs): + # Send stop command. + self.log_me('debug', "async_media_stop") + self._state = STATE_IDLE + self._playing = False + self._track_artist = None + self._track_album_name = None + self._track_name = None + self._track_album_cover = None + self.async_schedule_update_ha_state() + if('player' in kwargs): + self.log_me('debug', "- player found") + data = {ATTR_ENTITY_ID: kwargs.get('player')} + else: + data = {ATTR_ENTITY_ID: self._remote_player} + await self.hass.services.async_call(DOMAIN_MP, 'media_stop', data) + self.log_me('debug', "- async_media_stop -> " + self._remote_player) + + async def async_media_seek(self, position): + # Seek the media to a specific location. + self.log_me('debug', "seek: " + str(position)) + self._ignore_next_remote_pause_state = True # RobinR1, OwnTone compatibility + data = {ATTR_ENTITY_ID: self._remote_player, 'seek_position': position} + await self.hass.services.async_call(DOMAIN_MP, 'media_seek', data) + + async def async_set_shuffle(self, shuffle): + # Only implement the function which the HA media_player entity built-in, without affecting the _shuffle_mode and dropdown options. + # if shuffle is true,the next song should be random. + # shuffle_mode="Shuffle" means that when starting a playlist, the original order is randomized, but it does not imply shuffle=true. + if self.shuffle != shuffle: + self.log_me('debug', f"set_shuffle: {str(shuffle)}") + self._attr_shuffle = shuffle # True / False + self.async_schedule_update_ha_state() + + + async def async_set_volume_level(self, volume): + # Set volume level. + self._volume = round(volume, 2) + data = {ATTR_ENTITY_ID: self._remote_player, 'volume_level': self._volume} + await self.hass.services.async_call(DOMAIN_MP, 'volume_set', data) + self.async_schedule_update_ha_state() + + async def async_volume_up(self, **kwargs): + # Volume up the media player. + newvolume = min(self._volume + 0.05, 1) + await self.async_set_volume_level(newvolume) + + async def async_volume_down(self, **kwargs): + # Volume down media player. + newvolume = max(self._volume - 0.05, 0.01) + await self.async_set_volume_level(newvolume) + + async def async_mute_volume(self, mute): + # Send mute command. + if self._is_mute is False: + self._is_mute = True + else: + self._is_mute = False + self.async_schedule_update_ha_state() + data = {ATTR_ENTITY_ID: self._remote_player, "is_volume_muted": self._is_mute} + await self.hass.services.async_call(DOMAIN_MP, 'volume_mute', data) + + async def async_call_method(self, command=None, parameters=None): + self.log_me('debug', 'START async_call_method') + all_params = [] + if parameters: + for parameter in parameters: + all_params.append(parameter) + self.log_me('debug', command) + self.log_me('debug', parameters) + if(command == SERVICE_CALL_RATE_TRACK): + if(len(all_params) >= 1): + await self.async_rate_track(rating=all_params[0]) + elif(command == SERVICE_CALL_INTERRUPT_START): + if(self._state not in (STATE_PLAYING, STATE_PAUSED)): + self._interrupt_data = None + return + await self.async_update_remote_player() + # _LOGGER.error(self._remote_player) + t = self.hass.states.get(self._remote_player) + # _LOGGER.error(t) + self._interrupt_data = dict() + if(all(a in t.attributes for a in ('media_position', 'media_position_updated_at', 'media_duration'))): + now = datetime.datetime.now(datetime.timezone.utc) + delay = now - t.attributes['media_position_updated_at'] + pos = delay.total_seconds() + t.attributes['media_position'] + if pos < t.attributes['media_duration']: + self._interrupt_data['pos'] = pos + # _LOGGER.error(self._interrupt_data) + # _LOGGER.error(self._remote_player) + self._interrupt_data['player'] = self._remote_player + # _LOGGER.error(self._interrupt_data) + await self.async_media_stop(player=self._remote_player) + if(self._untrack_remote_player is not None): + try: + # _LOGGER.error("calling untrack") + self._untrack_remote_player() + except: + # _LOGGER.error("untrack failed!!") + pass + self._untrack_remote_player = None + + elif(command == SERVICE_CALL_INTERRUPT_RESUME): + if(self._interrupt_data is None): + return + if('player' in self._interrupt_data): + await self.async_update_remote_player(remote_player=self._interrupt_data['player']) + self._untrack_remote_player = async_track_state_change_event(self.hass, self._remote_player, self.async_sync_player) + self._interrupt_data['player'] = None + await self.async_get_track() + if('pos' in self._interrupt_data): + player = self.hass.states.get(self._remote_player) + if(player.attributes['supported_features'] | MediaPlayerEntityFeature.SEEK): + data = {'seek_position': self._interrupt_data['pos'], ATTR_ENTITY_ID: self._remote_player} + await self.hass.services.async_call(DOMAIN_MP, media_player.SERVICE_MEDIA_SEEK, data) + self._interrupt_data['pos'] = None + elif(command == SERVICE_CALL_RELOAD_DROPDOWNS): + await self.async_update_selects() + elif(command == SERVICE_CALL_OFF_IS_IDLE): # needed for the MPD and OwnTone server but for nobody else + self._x_to_idle = STATE_OFF + self.log_me('debug', "Setting x_is_idle to State Off") + elif(command == SERVICE_CALL_PAUSED_IS_IDLE): # needed for the Sonos but for nobody else + self._x_to_idle = STATE_PAUSED + self.log_me('debug', "Setting x_is_idle to State Paused") + elif(command == SERVICE_CALL_IGNORE_PAUSED_ON_MEDIA_CHANGE): # RobinR1, OwnTone compatibility + self._ignore_paused_on_media_change = True # RobinR1, OwnTone compatibility + self.log_me('debug', "Setting to ignore remote player Paused state on Next/Prev track and Seek") + elif(command == SERVICE_CALL_DO_NOT_IGNORE_PAUSED_ON_MEDIA_CHANGE): # RobinR1, OwnTone compatibility + self._ignore_paused_on_media_change = False # RobinR1, OwnTone compatibility + self.log_me('debug', "Setting to NOT ignore remote player Paused state on Next/Prev track and Seek") + elif(command == SERVICE_CALL_IDLE_IS_IDLE): # reset idle detection to default behaviour + self._x_to_idle = None + self.log_me('debug', "Resetting x_is_idle") + elif(command == SERIVCE_CALL_DEBUG_AS_ERROR): + self._debug_as_error = True + self.log_me('debug', "Posting debug messages as error until restart") + elif(command == SERVICE_CALL_LIKE_IN_NAME): + self._like_in_name = True + self._attr_name = self._org_name + " - " + str(self._attributes['likeStatus']) + self.log_me('debug', "Showing like status in name until restart") + elif(command == SERVICE_CALL_GOTO_TRACK): + self.log_me('debug', "Going to Track " + str(parameters[0]) + ".") + self._next_track_no = min(max(int(parameters[0]) - 1, 0), len(self._tracks) - 1) + prev_shuffle = self._attr_shuffle # store current shuffle setting + self._attr_shuffle = False # set false, otherwise async_get_track will override next_track + await self.async_get_track() + self._attr_shuffle = prev_shuffle # restore + elif(command == SERVICE_CALL_APPEND_TRACK): + self.log_me('debug', "Adding track " + str(parameters[0]) + " at position " + str(parameters[1])) + if(len(parameters)==2 and parameters[1].isnumeric()): + add_track = await self.hass.async_add_executor_job(lambda: self._api.get_song(parameters[0], self._signatureTimestamp)) # no limit needed + else: + self.log_me('debug', str(parameters[1]) + " is not numeric, or not exactly 2 parameters given") + # how to check + # I don't know why, but the format of get_song is very differnt, so we fix at least author and thumbnail to make it lookalike + add_track['videoDetails']['artists'] = [{'name': add_track['videoDetails']['author'], 'id': ''}] + add_track['videoDetails']['thumbnails'] = add_track['videoDetails']['thumbnail']['thumbnails'] + self._tracks.insert(int(parameters[1]),add_track['videoDetails']) + + await self._tracks_to_attribute() + elif(command == SERVICE_CALL_MOVE_TRACK): + self.log_me('debug', "Moving track " + str(parameters[0]) + " to position " + str(parameters[1])) + if(parameters[0].isnumeric() and (parameters[1].isnumeric() or parameters[1]=="-1")): + add_track = self._tracks[int(parameters[0])] + self._tracks.remove(add_track) + if(parameters[1].isnumeric()): + self._tracks.insert(int(parameters[1]),add_track) + await self._tracks_to_attribute() + else: + self.log_me('debug', str(parameters[0]) + " or " + str(parameters[1]) + " is not numeric, not moving tracks") + else: + self.log_me('error', "Command " + str(command) + " not implimented") + self.log_me('debug', "[E] async_call_method") + + + async def async_search(self, query="", filter=None, limit=20): + self.log_debug_later("[S] async_search") + if(filter is None or filter in {'albums', 'playlists', 'songs', 'artists'}): + # store data for media_browser + self._search['query'] = query + self._search['filter'] = filter + self._search['limit'] = limit + + if(self._init_extra_sensor): + search_results = list() + # execute search and store informtion for the extra sensor + media_all = await self.hass.async_add_executor_job(lambda: self._api.search(query=query, filter=filter, limit=limit)) + self.log_me('debug',media_all) + supported_media = [['song', 'videoId'], ['playlist', 'browseId'], ['album', 'browseId'], ['artist','browseId']] + for media_type in supported_media: + for result in media_all: + if result['resultType'] == media_type[0]: + if not('title' in result): + if 'artist' in result: + result['title'] = result['artist'] + elif 'artists' in result: # handle top result + result['title'] = result['artists'][0]['name'] + result['browseId'] = result['artists'][0]['id'] + if ('videoId' in result) or ('browseId' in result): + search_results.append({'type': media_type[0], 'title': result['title'], 'id': result[media_type[1]], 'thumbnail': result['thumbnails'][-1]['url']}) + + try: + await self.async_update_extra_sensor('search', search_results) + except: + pass + + else: + data = {"title": "yTubeMediaPlayer error", "message": "Please use a valid filter: 'albums', 'playlists', 'songs'"} + await self.hass.services.async_call("persistent_notification", "create", data) + self.log_me('debug', "[E] async_search") + + + async def async_add_to_playlist(self, song_id="", playlist_id=""): + await self.async_modify_playlist(song_id,playlist_id,mode="add") + + async def async_remove_from_playlist(self, song_id="", playlist_id=""): + await self.async_modify_playlist(song_id,playlist_id,mode="remove") + + async def async_modify_playlist(self, song_id="", playlist_id="", mode="add"): + self.log_debug_later("[S] async_modify_playlist") + if(song_id == ""): + if(self._attributes['videoId'] != ""): + song_id = self._attributes['videoId'] + else: + self.log_me('error', "no song_id given, but also currently not playing, so I don't know what to add/remove") + if(song_id != "" and playlist_id == ""): + if(self._attributes['_media_type'] in [MediaType.PLAYLIST, CHANNEL]): + playlist_id = self._attributes['_media_id'] + else: + self.log_me('error', "No playlist Id provided and the current playmode isn't 'playlist' nor 'channel', so I don't know where to add/remove the track") + if(song_id != "" and playlist_id != ""): + # self.log_me('debug', "add_playlist_items(playlistId=" + playlist_id + ", videoIds=[" + song_id + "]))") + if(playlist_id == "LM"): + if(mode=="add"): + await self.async_call_method(command=SERVICE_CALL_RATE_TRACK, parameters=[SERVICE_CALL_THUMB_UP]) + res = 'song added by liking it' + else: + await self.async_call_method(command=SERVICE_CALL_RATE_TRACK, parameters=[SERVICE_CALL_THUMB_DOWN]) + res = 'song removed by dis-liking it' + else: + if(mode=="add"): + try: + res = await self.hass.async_add_executor_job(lambda: self._api.add_playlist_items(playlistId=str(playlist_id), videoIds=[str(song_id)])) + res = 'song added' + except: + res = 'You can\'t add songs to this playlist (are you the owner?), requrest failed' + else: + try: + extra_info = await self.hass.async_add_executor_job(self._api.get_playlist, str(playlist_id)) + res = 'song not found in playlist' + if('tracks' in extra_info): + for track in extra_info['tracks']: + if track['videoId'] == song_id: + await self.hass.async_add_executor_job(lambda: self._api.remove_playlist_items(playlistId=str(playlist_id), videos=[track])) + res = 'song removed' + break + except: + res = 'You can\'t remove songs from this playlist (are you the owner?), requrest failed' + self.log_me('debug', res) + self.log_me('debug', "[E] async_modify_playlist") + + + + + + async def async_limit_count(self, limit): + self.log_debug_later("[S] async_limit_count") + self._trackLimitUser = limit + # having a tracklimit (requests from the api) smaller than the user limit + # (limits the list AFTER generation) is pointless, so lets adjust this here as well + if(self._trackLimitUser > self._trackLimit): + self._trackLimit = self._trackLimitUser + self.log_me("debug", "New limit: " + str(self._trackLimitUser)) + self.log_me("debug", "[E] async_limit_count") + + + async def async_start_radio(self, interrupt=True): + self.log_debug_later("[S] async_start_radio") + if(self._attributes['videoId'] == ""): + self.log_me('debug', "Currently not playing anything so I don't know what to base the radio on") + else: + self.log_me('debug', "Starting radio based on " + str(self._attributes['videoId'])) + media_type = CHANNEL_VID_NO_INTERRUPT + if(interrupt): + media_type = CHANNEL_VID + await self.async_play_media(media_type, self._attributes['videoId']) + self.log_me("debug", "[E] async_start_radio") + + + async def async_rate_track(self, rating="", song_id=""): + self.log_debug_later("[S] async_rate_track") + if(rating == ""): + self.log_me('error', "No Rating given, stopping") + if(song_id == ""): + if(self._attributes['videoId'] != ""): + self.log_me('debug', "No song Id given, taking current song") + song_id = self._attributes['videoId'] + else: + self.log_me('error', "No song Id given and currently not playing, giving up") + + if(song_id != "" and rating != ""): + try: + arg = 'LIKE' + if(rating == SERVICE_CALL_THUMB_UP): + self.log_me('debug', "rate thumb up") + arg = 'LIKE' + elif(rating == SERVICE_CALL_THUMB_DOWN): + self.log_me('debug', "rate thumb down") + arg = 'DISLIKE' + elif(rating == SERVICE_CALL_THUMB_MIDDLE): + self.log_me('debug', "rate thumb middle") + arg = 'INDIFFERENT' + elif(rating == SERVICE_CALL_TOGGLE_THUMB_UP_MIDDLE): + if('likeStatus' in self._attributes): + if(self._attributes['likeStatus'] == 'LIKE'): + self.log_me('debug', "rate thumb middle") + arg = 'INDIFFERENT' + else: + self.log_me('debug', "rate thumb up") + arg = 'LIKE' + await self.hass.async_add_executor_job(self._api.rate_song, song_id, arg) + # only change arguments if the track that we're rating is the current one + if(song_id == self._attributes['videoId']): + self._attributes['likeStatus'] = arg + if(self._like_in_name): + self._attr_name = self._org_name + " - " + arg + self.async_schedule_update_ha_state() + self._tracks[self._next_track_no]['likeStatus'] = arg + except: + self.exc() + self.log_me('debug', "[E] async_rate_track") + + + def exc(self, resp="self"): + # Print nicely formated exception. + _LOGGER.error("\n\n == == == == == == = ytube_music_player Integration Error == == == == == == == == ") + if(resp == "self"): + _LOGGER.error("unfortunately we hit an error, please open a ticket at") + _LOGGER.error("https://github.com/KoljaWindeler/ytube_music_player/issues") + else: + _LOGGER.error("unfortunately we hit an error in the sub api, please open a ticket at") + _LOGGER.error("https://github.com/sigma67/ytmusicapi/issues") + _LOGGER.error("and paste the following output:\n") + _LOGGER.error(traceback.format_exc()) + _LOGGER.error("\nthanks, Kolja") + _LOGGER.error(" == == == == == == = ytube_music_player Integration Error == == == == == == == == \n\n") + + + async def async_browse_media(self, media_content_type=None, media_content_id=None): + # Implement the websocket media browsing helper. + self.log_me('debug', "async_browse_media") + await self.async_check_api() + if media_content_type in [None, "library"]: + return await self.hass.async_add_executor_job(lambda: library_payload(self)) + + payload = { + "search_type": media_content_type, + "search_id": media_content_id, + } + + response = await build_item_response(self, payload) + if response is None: + raise BrowseError( + f"Media not found: {media_content_type} / {media_content_id}" + ) + return response + + # helper to resume tracking of the select field for media player + # we have to untrack it before we change it ourself and give HA some time + # to make the change and call this resubscription delayed + async def async_track_select_mediaplayer_helper(self, args): + # this should now be needed .. but one never know + if(self._untrack_remote_player_selector is not None): + try: + self._untrack_remote_player_selector() + self.log_me('debug', "- untrack passed") + except: + self.log_me('debug', "- untrack failed") + self._untrack_remote_player_selector = None + self._untrack_remote_player_selector = async_track_state_change_event( + self.hass, self._selects['speakers'], self.async_select_source_helper) + self.log_me('debug', "- untrack resub") diff --git a/custom_components/ytube_music_player/select.py b/custom_components/ytube_music_player/select.py new file mode 100644 index 00000000..a6224a04 --- /dev/null +++ b/custom_components/ytube_music_player/select.py @@ -0,0 +1,127 @@ +"""Platform for sensor integration.""" +import logging +from homeassistant.components.select import SelectEntity +from homeassistant.exceptions import NoEntitySpecifiedError +from . import DOMAIN +from .const import * + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, config, async_add_entities): + _LOGGER.debug("Init the dropdown(s)") + init_dropdowns = config.data.get(CONF_INIT_DROPDOWNS, DEFAULT_INIT_DROPDOWNS) + select_entities = { + "playlists": yTubeMusicPlaylistSelect(hass, config), + "speakers": yTubeMusicSpeakerSelect(hass, config), + "playmode": yTubeMusicPlayModeSelect(hass, config), + "radiomode": yTubeMusicSourceSelect(hass, config), + "repeatmode": yTubeMusicRepeatSelect(hass, config) + } + entities = [] + for dropdown,entity in select_entities.items(): + if dropdown in init_dropdowns: + entities.append(entity) + async_add_entities(entities, update_before_add=True) + +class yTubeMusicSelectEntity(SelectEntity): + def __init__(self, hass, config): + self.hass = hass + self._device_id = config.entry_id + self._device_name = config.data.get(CONF_NAME) + self._attr_has_entity_name = True + + def select_option(self, option): + """Change the selected option.""" + self._attr_current_option = option + self.schedule_update_ha_state() + @property + def device_info(self): + return { + 'identifiers': {(DOMAIN, self._device_id)}, + 'name': self._device_name, + 'manufacturer': "Google Inc.", + 'model': DOMAIN + } + + @property + def should_poll(self): + return False + + +class yTubeMusicPlaylistSelect(yTubeMusicSelectEntity): + def __init__(self, hass, config): + super().__init__(hass, config) + self._attr_unique_id = self._device_id + "_playlist" + self._attr_name = "Playlist" + self._attr_icon = 'mdi:playlist-music' + self.hass.data[DOMAIN][self._device_id]['select_playlists'] = self + self._attr_options = ["loading"] + self._attr_current_option = None + + async def async_update(self): + # update select + self._ready = True + try: + self._attr_options = list(self.hass.data[DOMAIN][self._device_id]['playlists'].keys()) + except: + pass + try: + self.async_schedule_update_ha_state() + except NoEntitySpecifiedError: + pass # we ignore this due to a harmless startup race condition + + +class yTubeMusicSpeakerSelect(yTubeMusicSelectEntity): + def __init__(self, hass, config): + super().__init__(hass, config) + self._attr_unique_id = self._device_id + "_speaker" + self._attr_name = "Speaker" + self._attr_icon = 'mdi:speaker' + self.hass.data[DOMAIN][self._device_id]['select_speakers'] = self + self._attr_options = ["loading"] + self._attr_current_option = None + + async def async_update(self, options=[]): + # update select + self._ready = True + try: + self._attr_options = options + except: + pass + try: + self.async_schedule_update_ha_state() + except NoEntitySpecifiedError: + pass # we ignore this due to a harmless startup race condition + + +class yTubeMusicPlayModeSelect(yTubeMusicSelectEntity): + def __init__(self, hass, config): + super().__init__(hass, config) + self._attr_unique_id = self._device_id + "_playmode" + self._attr_name = "Play Mode" + self._attr_icon = 'mdi:shuffle' + self.hass.data[DOMAIN][self._device_id]['select_playmode'] = self + self._attr_options = ["Shuffle","Random","Shuffle Random","Direct"] + self._attr_current_option = "Shuffle Random" + + +class yTubeMusicSourceSelect(yTubeMusicSelectEntity): + def __init__(self, hass, config): + super().__init__(hass, config) + self._attr_unique_id = self._device_id + "_radiomode" + self._attr_name = "Radio Mode" + self._attr_icon = 'mdi:music-box-multiple' + self.hass.data[DOMAIN][self._device_id]['select_radiomode'] = self + self._attr_options = ["Playlist","Playlist Radio"] # "Playlist" means not radio mode + self._attr_current_option = "Playlist" + + +class yTubeMusicRepeatSelect(yTubeMusicSelectEntity): + def __init__(self, hass, config): + super().__init__(hass, config) + self._attr_unique_id = self._device_id + "_repeat" + self._attr_name = "Repeat Mode" + self._attr_icon = 'mdi:repeat' + self.hass.data[DOMAIN][self._device_id]['select_repeatmode'] = self + self._attr_options = ["all", "one", "off"] # one for future + self._attr_current_option = "all" \ No newline at end of file diff --git a/custom_components/ytube_music_player/sensor.py b/custom_components/ytube_music_player/sensor.py new file mode 100644 index 00000000..d7039028 --- /dev/null +++ b/custom_components/ytube_music_player/sensor.py @@ -0,0 +1,82 @@ +"""Platform for sensor integration.""" +import logging +from homeassistant.helpers.entity import Entity +from homeassistant.exceptions import NoEntitySpecifiedError +from . import DOMAIN +from .const import * + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config, async_add_entities): + # Run setup via Storage + _LOGGER.debug("init ytube sensor") + if(config.data.get(CONF_INIT_EXTRA_SENSOR, DEFAULT_INIT_EXTRA_SENSOR)): + async_add_entities([yTubeMusicSensor(hass, config)], update_before_add=True) + + +class yTubeMusicSensor(Entity): + # Extra Sensor for the YouTube Music player integration + + def __init__(self,hass, config): + # Initialize the sensor. + self.hass = hass + self._state = STATE_OFF + self._device_id = config.entry_id + self._device_name = config.data.get(CONF_NAME) + self._attr_unique_id = config.entry_id + "_extra" # should be different from the media_player entity + self._attr_has_entity_name = True + self._attr_name = "Extra" + self._attr_icon = 'mdi:information-outline' + self.hass.data[DOMAIN][self._device_id]['extra_sensor'] = self + self._attr = {'tracks', 'search', 'lyrics', 'playlists', 'total_tracks'} + self._attributes = {} + for attr in self._attr: + self._attributes[attr] = "" + + _LOGGER.debug("init ytube sensor done") + + @property + def device_info(self): + return { + 'identifiers': {(DOMAIN, self._device_id)}, + 'name': self._device_name, + 'manufacturer': "Google Inc.", + 'model': DOMAIN + } + + @property + def name(self): + # Return the name of the sensor. + return self._attr_name + + @property + def state(self): + # Return the state of the sensor. + return self._state + + @property + def should_poll(self): + # No polling needed. + return False + + async def async_update(self): + # update sensor + self._ready = True + _LOGGER.debug("updating ytube sensor") + + # update all attributes from the data var + for attr in self._attr: + if attr in self.hass.data[DOMAIN][self._device_id]: + self._attributes[attr] = self.hass.data[DOMAIN][self._device_id][attr] + + try: + self.async_schedule_update_ha_state() + except NoEntitySpecifiedError: + pass # we ignore this due to a harmless startup race condition + + @property + def extra_state_attributes(self): + # Return the device state attributes. + return self._attributes \ No newline at end of file diff --git a/custom_components/ytube_music_player/services.yaml b/custom_components/ytube_music_player/services.yaml new file mode 100644 index 00000000..532d0685 --- /dev/null +++ b/custom_components/ytube_music_player/services.yaml @@ -0,0 +1,127 @@ +call_method: + fields: + entity_id: + example: "media_player.ytube_music_player" + required: true + selector: + entity: + domain: media_player + command: + example: "rate_track" + required: true + selector: + text: + parameters: + example: "thumb_up" + required: true + selector: + text: + +search: + fields: + entity_id: + example: "media_player.ytube_music_player" + required: true + selector: + entity: + domain: media_player + query: + example: "2pm tetris" + required: true + selector: + text: + filter: + required: false + selector: + text: + limit: + required: false + example: "20" + default: 20 + selector: + number: + min: 1 + max: 1000 + +add_to_playlist: + fields: + entity_id: + example: "media_player.ytube_music_player" + required: true + selector: + entity: + domain: media_player + song_id: + required: false + example: "" + selector: + text: + playlist_id: + required: false + example: "" + selector: + text: + +remove_from_playlist: + fields: + entity_id: + example: "media_player.ytube_music_player" + required: true + selector: + entity: + domain: media_player + song_id: + example: "" + playlist_id: + example: "" + +rate_track: + fields: + entity_id: + example: "media_player.ytube_music_player" + required: true + selector: + entity: + domain: media_player + rating: + example: "thumb_up" + required: true + selector: + select: + options: + - "thumb_up" + - "thumb_down" + - "thumb_middle" + - "thumb_toggle_up_middle" + song_id: + example: "" + +limit_count: + fields: + entity_id: + example: "media_player.ytube_music_player" + required: true + selector: + entity: + domain: media_player + limit: + example: "20" + required: true + selector: + number: + min: 1 + max: 1000 + +start_radio: + fields: + entity_id: + example: "media_player.ytube_music_player" + required: true + selector: + entity: + domain: media_player + interrupt: + required: true + example: "true" + selector: + boolean: diff --git a/custom_components/ytube_music_player/translations/en.json b/custom_components/ytube_music_player/translations/en.json new file mode 100644 index 00000000..da267734 --- /dev/null +++ b/custom_components/ytube_music_player/translations/en.json @@ -0,0 +1,250 @@ +{ + "title": "yTubeMediaPlayer", + "config": { + "step": { + "oauth": { + "description": "Please enter the path and the cookie data. Further information available at https://github.com/KoljaWindeler/ytube_music_player", + "data": { + "name": "Name for the entity (without 'media_player' prefix)", + "cookie": "Enter cookie text, copied from browser", + "speakers": "Select the default output device", + "header_path": "File path for the header file", + "advance_config": "Show advance configuration" + } + }, + "finish": { + "description": "Please enter the basic data. Further information available at https://github.com/KoljaWindeler/ytube_music_player", + "data": { + "speakers": "Select the output devices, the first one will be the default device", + "header_path": "File path for the header file", + "api_language": "The language parameter of the ytmusicapi, which determines the language of the returned results", + "advance_config": "Show advance configuration" + } + }, + "adv_finish": { + "description": "You can configure some behaviors of the player here, such as limiting data usage and setting the maximum number of tracks loaded per session.", + "data": { + "brand_id": "Enter a brand id if you are using a brand account", + "proxy_path": "Local path for proxy mode, leave blank if you don't need it", + "proxy_url": "Base URL for proxy mode, leave blank if you don't need it", + "like_in_name": "Show like status in the name", + "debug_as_error": "Show all debug output as ERROR in the log", + "shuffle": "Turn on shuffle on startup", + "shuffle_mode": "Playmode", + "track_limit": "Limit of simultaneously loaded tracks", + "max_datarate": "Limit the maximum bit rate", + "legacy_radio": "Create radio as watchlist of random playlist track", + "sort_browser": "Sort results in the media browser", + "extra_sensor": "Create sensor that provide extra information", + "dropdowns": "Create the dropdown(s) you want to use", + "select_speakers": "Entity id of input_select for speaker selection(Deprecated. Leaving a space can permanently delete this field)", + "select_playmode": "Entity id of input_select for playmode selection(Deprecated. Leaving a space can permanently delete this field)", + "select_source": "Entity id of input_select for playlist/radio selection(Deprecated. Leaving a space can permanently delete this field)", + "select_playlist": "Entity id of input_select for playlist selection(Deprecated. Leaving a space can permanently delete this field)", + "select_playcontinuous": "Entity id of input_boolean for play continuous selection(Deprecated. Leaving a space can permanently delete this field)" + } + } + }, + "error": { + "ERROR_GENERIC": "Something with your cookie wasn't right. Format and fields are ok but the login failed", + "ERROR_AUTH_USER": "Can't find the 'X-Goog-AuthUser' field, please check your input", + "ERROR_COOKIE": "Can't find the 'Cookie' field, please check your input", + "ERROR_CONTENTS": "Format of cookie is OK, found '__Secure-3PAPISID' and '__Secure-3PSID' but can't retrieve any data with this settings, maybe you didn't copy all data?", + "ERROR_FORMAT": "Format of cookie is NOT OK, likely missing '__Secure-3PAPISID' or '__Secure-3PSID'", + "ERROR_NONE": "Format of cookie seams OK, but the returned sub API object is None", + "ERROR_FORBIDDEN": "YouTube returned a 403 error, meaning that you login data are not longer valid. Please update the cookie" + } + }, + "options": { + "step": { + "oauth": { + "description": "Please enter the path and the cookie data. Further information available at https://github.com/KoljaWindeler/ytube_music_player", + "data": { + "name": "Name for the entity (without 'media_player' prefix)", + "cookie": "Enter cookie text, copied from browser", + "speakers": "Select the default output device", + "header_path": "File path for the header file", + "advance_config": "Show advance configuration" + } + }, + "finish": { + "description": "Please enter the basic data. Further information available at https://github.com/KoljaWindeler/ytube_music_player", + "data": { + "speakers": "Select the output devices, the first one will be the default device", + "header_path": "File path for the header file", + "api_language": "The language parameter of the ytmusicapi, which determines the language of the returned results", + "advance_config": "Show advance configuration" + } + }, + "adv_finish": { + "description": "You can configure some behaviors of the player here, such as limiting data usage and setting the maximum number of tracks loaded per session.", + "data": { + "brand_id": "Enter a brand id if you are using a brand account", + "proxy_path": "Local path for proxy mode, leave blank if you don't need it", + "proxy_url": "Base URL for proxy mode, leave blank if you don't need it", + "like_in_name": "Show like status in the name", + "debug_as_error": "Show all debug output as ERROR in the log", + "shuffle": "Turn on shuffle on startup", + "shuffle_mode": "Playmode", + "track_limit": "Limit of simultaneously loaded tracks", + "max_datarate": "Limit the maximum bit rate", + "legacy_radio": "Create radio as watchlist of random playlist track", + "sort_browser": "Sort results in the media browser", + "extra_sensor": "Create sensor that provide extra information", + "dropdowns": "Create the dropdown(s) you want to use", + "select_speakers": "Entity id of input_select for speaker selection(Deprecated. Leaving a space can permanently delete this field)", + "select_playmode": "Entity id of input_select for playmode selection(Deprecated. Leaving a space can permanently delete this field)", + "select_source": "Entity id of input_select for playlist/radio selection(Deprecated. Leaving a space can permanently delete this field)", + "select_playlist": "Entity id of input_select for playlist selection(Deprecated. Leaving a space can permanently delete this field)", + "select_playcontinuous": "Entity id of input_boolean for play continuous selection(Deprecated. Leaving a space can permanently delete this field)" + } + } + }, + "error": { + "ERROR_GENERIC": "Something with your cookie wasn't right. Format and fields are ok but the login failed", + "ERROR_AUTH_USER": "Can't find the 'X-Goog-AuthUser' field, please check your input", + "ERROR_COOKIE": "Can't find the 'Cookie' field, please check your input", + "ERROR_CONTENTS": "Format of cookie is OK, found '__Secure-3PAPISID' and '__Secure-3PSID' but can't retrieve any data with this settings, maybe you didn't copy all data?", + "ERROR_FORMAT": "Format of cookie is NOT OK, likely missing '__Secure-3PAPISID' or '__Secure-3PSID'", + "ERROR_NONE": "Format of cookie seams OK, but the returned sub API object is None", + "ERROR_FORBIDDEN": "YouTube returned a 403 error, meaning that you login data are not longer valid. Please update the cookie" + } + }, + "services": { + "add_to_playlist": { + "name": "Add song to playlist", + "description": "Adds a song to a playlist", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Entity ID of the ytube media player" + }, + "song_id": { + "name": "Song ID", + "description": "The id of the song, optional. By default the current song id is used. Only provide an argument to override this behavior." + }, + "playlist_id": { + "name": "Playlist ID", + "description": "The id of the playlist, optional. By default the current playlist is used. Only provide an argument to override this behavior." + } + } + }, + "remove_from_playlist": { + "name": "Remove song from playlist", + "description": "Removes a song from a playlist", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Entity ID of the ytube media player" + }, + "song_id": { + "name": "Song ID", + "description": "The id of the song, optional. By default the current song id is used. Only provide an argument to override this behavior." + }, + "playlist_id": { + "name": "Playlist ID", + "description": "The id of the playlist, optional. By default the current playlist is used. Only provide an argument to override this behavior." + } + } + }, + "call_method": { + "name": "Call a submethod of ytubemusic player", + "description": "Call a custom command.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Name(s) of the yTube music player entity where to run the custom command.", + "example": "media_player.ytube_music_player" + }, + "command": { + "name": "Command", + "description": "Command to pass to LyTube music player.", + "example": "rate_track" + }, + "parameters": { + "name": "Parameter", + "description": "Array of additional parameters, optional and depends on command.", + "example": "thumb_up" + } + } + }, + "search": { + "description": "Search for music / album / etc on Ytube YouTube Music Player", + "name": "Search", + "fields":{ + "entity_id": { + "name": "Entity ID", + "description": "Name(s) of the yTube music player entity where to run the custom command.", + "example": "media_player.ytube_music_player" + }, + "query": { + "name": "Query", + "description": "The search query", + "example": "2pm tetris" + }, + "filter": { + "name": "Filter", + "description": "filter for query, values can be 'albums', 'playlists','artists' or 'songs'. Leave this out to get all types." + }, + "limit": { + "name": "Limit", + "description": "Limits the amount of resuls", + "example": "20" + } + } + }, + "rate_track": { + "name": "Rate a track", + "description": "Rates a song", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Name(s) of the yTube music player entity where to run the custom command.", + "example": "media_player.ytube_music_player" + }, + "rating": { + "name":"Rating", + "description": "The rating of the song, can be 'thumb_up' / 'thumb_down' / 'thumb_middle' / 'thumb_toggle_up_middle'.", + "example": "thumb_up" + }, + "song_id": { + "name": "Song ID", + "description": "The id of the song, optional. By default the current song id is used. Only provide an argument to override this behavior.", + "example": "" + } + } + }, + "limit_count": { + "name": "Limit song count", + "description": "Limits the count of loaded tracks", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Name(s) of the yTube music player entity where to run the custom command.", + "example": "media_player.ytube_music_player" + }, + "limit": { + "name": "Limit", + "description": "The amount of tracks, loaded per call", + "example": "20" + } + } + }, + "start_radio": { + "name": "Radio", + "description": "Creates a radio of the current track", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Name(s) of the yTube music player entity where to run the command.", + "example": "media_player.ytube_music_player" + }, + "interrupt": { + "name": "Interrupt", + "description": "interrupt the current playback or not", + "example": "true" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/ytube_music_player/translations/fr.json b/custom_components/ytube_music_player/translations/fr.json new file mode 100644 index 00000000..6d4b435a --- /dev/null +++ b/custom_components/ytube_music_player/translations/fr.json @@ -0,0 +1,89 @@ +{ + "title": "yTubeMediaPlayer", + "config": { + "step": { + "user": { + "description": "Veuillez entrer le chemin et les données du cookie. Plus d'informations disponibles sur https://github.com/KoljaWindeler/ytube_music_player", + "data": { + "name": "Nom de l'entité (sans le préfixe 'media_player')", + "cookie": "Saisissez le texte du cookie, copié depuis le navigateur", + "speakers": "Sélectionnez le périphérique de sortie par défaut", + "header_path": "Chemin d'accès au fichier d'en-tête", + "advance_config": "Afficher la configuration avancée" + } + }, + "finish": { + "description": "Ici, vous pouvez modifier les identifiants d'entité de certains champs, si vous n'utilisez pas les listes déroulantes, laissez-les tels quels", + "data": { + "brand_id": "Entrez un identifiant de marque si vous utilisez un compte de marque", + "select_speakers": "ID de l'entité input_select pour la sélection du haut-parleur", + "select_playmode":"ID de l'entité input_select pour la sélection du mode de lecture", + "select_source":"ID de l'entité input_select pour la sélection de playlist/radio", + "select_playlist":"ID de l'entité input_select pour la sélection de la liste de lecture", + "select_playcontinuous":"ID de l'entité input_boolean pour la sélection de lecture continue", + "proxy_path": "Chemin local pour le mode proxy, laissez vide si vous n'en avez pas besoin", + "proxy_url": "URL de base pour le mode proxy, laissez vide si vous n'en avez pas besoin", + "like_in_name": "Afficher le statut aime dans le nom", + "debug_as_error": "Afficher toutes les sorties de débogage en tant qu'erreur dans le journal", + "shuffle": "Activer la lecture aléatoire au démarrage", + "track_limit": "Limite de pistes chargées simultanément", + "legacy_radio": "Créer une radio en tant que piste dans une liste de lecture aléatoire", + "sort_browser": "Trier les résultats dans le navigateur multimédia", + "extra_sensor": "Créer un capteur qui fournit des informations supplémentaires" + } + } + }, + "error": { + "ERROR_GENERIC": "Quelque chose n'allait pas avec votre cookie. Le format et les champs sont corrects mais la connexion a échoué", + "ERROR_AUTH_USER": "Impossible de trouver le champ 'X-Goog-AuthUser', veuillez vérifier votre saisie", + "ERROR_COOKIE": "Impossible de trouver le champ 'Cookie', veuillez vérifier votre saisie", + "ERROR_CONTENTS": "Le format du cookie est correct, trouvé '__Secure-3PAPISID' et '__Secure-3PSID' mais ne peut récupérer aucune donnée avec ces paramètres, peut-être que vous n'avez pas copié toutes les données ?", + "ERROR_FORMAT": "Le format du cookie n'est pas correct, il manque probablement '__Secure-3PAPISID' ou '__Secure-3PSID'", + "ERROR_NONE": "Le format du cookie semble correct, mais l'objet renvoyé est Aucun", + "ERROR_FORBIDDEN": "YouTube a renvoyé une erreur 403, ce qui signifie que vos données de connexion ne sont plus valides. Veuillez mettre à jour le cookie" + } + }, + "options": { + "step": { + "init": { + "description": "Veuillez entrer le chemin et les données du cookie. Plus d'informations disponibles sur https://github.com/KoljaWindeler/ytube_music_player", + "data": { + "name": "Nom de l'entité (sans le préfixe 'media_player')", + "cookie": "Saisissez le texte du cookie, copié depuis le navigateur", + "speakers": "Sélectionnez le périphérique de sortie par défaut", + "header_path": "Chemin d'accès au fichier d'en-tête", + "advance_config": "Afficher la configuration avancée" + } + }, + "finish": { + "description": "Ici, vous pouvez modifier les identifiants d'entité de certains champs, si vous n'utilisez pas les listes déroulantes, laissez-les tels quels", + "data": { + "brand_id": "Entrez un identifiant de marque si vous utilisez un compte de marque", + "select_speakers": "ID de l'entité input_select pour la sélection du haut-parleur", + "select_playmode":"ID de l'entité input_select pour la sélection du mode de lecture", + "select_source":"ID de l'entité input_select pour la sélection de playlist/radio", + "select_playlist":"ID de l'entité input_select pour la sélection de la liste de lecture", + "select_playcontinuous":"ID de l'entité input_boolean pour la sélection de lecture continue", + "proxy_path": "Chemin local pour le mode proxy, laissez vide si vous n'en avez pas besoin", + "proxy_url": "URL de base pour le mode proxy, laissez vide si vous n'en avez pas besoin", + "like_in_name": "Afficher le statut aime dans le nom", + "debug_as_error": "Afficher toutes les sorties de débogage en tant qu'erreur dans le journal", + "shuffle": "Activer la lecture aléatoire au démarrage", + "track_limit": "Limite de pistes chargées simultanément", + "legacy_radio": "Créer une radio en tant que piste dans une liste de lecture aléatoire", + "sort_browser": "Trier les résultats dans le navigateur multimédia", + "extra_sensor": "Créer un capteur qui fournit des informations supplémentaires" + } + } + }, + "error": { + "ERROR_GENERIC": "Quelque chose n'allait pas avec votre cookie. Le format et les champs sont corrects mais la connexion a échoué", + "ERROR_AUTH_USER": "Impossible de trouver le champ 'X-Goog-AuthUser', veuillez vérifier votre saisie", + "ERROR_COOKIE": "Impossible de trouver le champ 'Cookie', veuillez vérifier votre saisie", + "ERROR_CONTENTS": "Le format du cookie est correct, trouvé '__Secure-3PAPISID' et '__Secure-3PSID' mais ne peut récupérer aucune donnée avec ces paramètres, peut-être que vous n'avez pas copié toutes les données ?", + "ERROR_FORMAT": "Le format du cookie n'est pas correct, il manque probablement '__Secure-3PAPISID' ou '__Secure-3PSID'", + "ERROR_NONE": "Le format du cookie semble correct, mais l'objet renvoyé est Aucun", + "ERROR_FORBIDDEN": "YouTube a renvoyé une erreur 403, ce qui signifie que vos données de connexion ne sont plus valides. Veuillez mettre à jour le cookie" + } + } +} \ No newline at end of file diff --git a/custom_components/ytube_music_player/translations/pl.json b/custom_components/ytube_music_player/translations/pl.json new file mode 100644 index 00000000..887b5188 --- /dev/null +++ b/custom_components/ytube_music_player/translations/pl.json @@ -0,0 +1,89 @@ +{ + "title": "yTubeMediaPlayer", + "config": { + "step": { + "user": { + "description": "Podaj ścieżkę do danych cookie. Więcej informacji pod adresem https://github.com/KoljaWindeler/ytube_music_player", + "data": { + "name": "Nazwa entity (bez prefix'u 'media_player')", + "cookie": "Wprowadź tekst cookie skopiowanej z przeglądarki", + "speakers": "Wybierz domyślne urządzenie wyjściowe", + "header_path": "Ścieżka do pliku nagłówkowego", + "advance_config": "Pokaż zaawansowaną konfigurację" + } + }, + "finish": { + "description": "Tutaj możesz zmienić na identyfikatory entity wybranych pól, jeśli nie korzystasz z list rozwijanych, po prostu pozostaw je bez zmian", + "data": { + "brand_id": "Wprowadź identyfikator brand, jeśli korzystasz z konta brand", + "select_speakers": "Identyfikator entity input_select do wyboru źródła dźwięku", + "select_playmode":"Identyfikator entity input_select do wyboru playmode", + "select_source":"Identyfikator entity input_select do wyboru playlist/radio", + "select_playlist":"Identyfikator entity input_select do wyboru playlist", + "select_playcontinuous":"Identyfikator entity input_boolean do wyboru play continouus", + "proxy_path": "Ścieżka lokalna dla trybu proxy, pozostaw puste jeśli tego nie używasz", + "proxy_url": "Podstawowy adres URL dla trybu proxy, pozostaw puste, jeśli go nie używasz", + "like_in_name": "Pokaż status polubienia w nazwie", + "debug_as_error": "Pokaż wszystkie dane wyjściowe debugowania jako ERROR w dzienniku", + "shuffle": "Włącz odtwarzanie losowe podczas uruchamiania", + "track_limit": "Limit jednocześnie załadowanych utworów", + "legacy_radio": "Utwórz radio jako listę obserwowanych losowych utworów z listy odtwarzania", + "sort_browser": "Sortuj wyniki w przeglądarce multimediów", + "extra_sensor": "Utwórz czujnik, który dostarcza dodatkowych informacji" + } + } + }, + "error": { + "ERROR_GENERIC": "Coś z Twoim cookie było nie tak. Format i pola są w porządku, ale logowanie nie powiodło się", + "ERROR_AUTH_USER": "Nie można znaleźć pola „X-Goog-AuthUser”. Sprawdź wprowadzone dane", + "ERROR_COOKIE": "Nie można znaleźć pola „Pliki cookie”, sprawdź wprowadzone dane", + "ERROR_CONTENTS": "Format pliku cookie jest OK, znaleziono „__Secure-3PAPISID” i „__Secure-3PSID”, ale nie można pobrać żadnych danych z tymi ustawieniami, może nie skopiowałeś wszystkich danych?", + "ERROR_FORMAT": "Format pliku cookie jest NIEPRAWIDŁOWY, prawdopodobnie brakuje „__Secure-3PAPISID” lub „__Secure-3PSID”", + "ERROR_NONE": "Format plików cookie wydaje się być OK, ale zwrócony obiekt podrzędny interfejsu API jest pusty (NULL)", + "ERROR_FORBIDDEN": "YouTube zwrócił błąd 403, co oznacza, że Twoje dane logowania nie są już ważne. Proszę zaktualizować plik cookie" + } + }, + "options": { + "step": { + "init": { + "description": "Podaj ścieżkę i dane pliku cookie. Więcej informacji pod adresem https://github.com/KoljaWindeler/ytube_music_player", + "data": { + "name": "Nazwa entity (bez prefix'u 'media_player')", + "cookie": "Wprowadź tekst cookie skopiowanej z przeglądarki", + "speakers": "Wybierz domyślne urządzenie wyjściowe", + "header_path": "Ścieżka do pliku nagłówkowego", + "advance_config": "Pokaż zaawansowaną konfigurację" + } + }, + "finish": { + "description": "Tutaj możesz zmienić na identyfikatory entity wybranych pól, jeśli nie korzystasz z list rozwijanych, po prostu pozostaw je bez zmian", + "data": { + "brand_id": "Wprowadź identyfikator brand, jeśli korzystasz z konta brand", + "select_speakers": "Identyfikator entity input_select do wyboru źródła dźwięku", + "select_playmode":"Identyfikator entity input_select do wyboru playmode", + "select_source":"Identyfikator entity input_select do wyboru playlist/radio", + "select_playlist":"Identyfikator entity input_select do wyboru playlist", + "select_playcontinuous":"Identyfikator entity input_boolean do wyboru play continouus", + "proxy_path": "Ścieżka lokalna dla trybu proxy, pozostaw puste jeśli tego nie używasz", + "proxy_url": "Podstawowy adres URL dla trybu proxy, pozostaw puste, jeśli go nie używasz", + "like_in_name": "Pokaż status polubienia w nazwie", + "debug_as_error": "Pokaż wszystkie dane wyjściowe debugowania jako ERROR w dzienniku", + "shuffle": "Włącz odtwarzanie losowe podczas uruchamiania", + "track_limit": "Limit jednocześnie załadowanych utworów", + "legacy_radio": "Utwórz radio jako listę obserwowanych losowych utworów z listy odtwarzania", + "sort_browser": "Sortuj wyniki w przeglądarce multimediów", + "extra_sensor": "Utwórz czujnik, który dostarcza dodatkowych informacji" + } + } + }, + "error": { + "ERROR_GENERIC": "Coś z Twoim cookie było nie tak. Format i pola są w porządku, ale logowanie nie powiodło się", + "ERROR_AUTH_USER": "Nie można znaleźć pola „X-Goog-AuthUser”. Sprawdź wprowadzone dane", + "ERROR_COOKIE": "Nie można znaleźć pola „Pliki cookie”, sprawdź wprowadzone dane", + "ERROR_CONTENTS": "Format pliku cookie jest OK, znaleziono „__Secure-3PAPISID” i „__Secure-3PSID”, ale nie można pobrać żadnych danych z tymi ustawieniami, może nie skopiowałeś wszystkich danych?", + "ERROR_FORMAT": "Format pliku cookie jest NIEPRAWIDŁOWY, prawdopodobnie brakuje „__Secure-3PAPISID” lub „__Secure-3PSID”", + "ERROR_NONE": "Format plików cookie wydaje się być OK, ale zwrócony obiekt podrzędny interfejsu API jest pusty (NULL)", + "ERROR_FORBIDDEN": "YouTube zwrócił błąd 403, co oznacza, że Twoje dane logowania nie są już ważne. Proszę zaktualizować plik cookie" + } + } +} diff --git a/custom_components/ytube_music_player/translations/pt-BR.json b/custom_components/ytube_music_player/translations/pt-BR.json new file mode 100644 index 00000000..1cfdbf38 --- /dev/null +++ b/custom_components/ytube_music_player/translations/pt-BR.json @@ -0,0 +1,89 @@ +{ + "title": "yTubeMediaPlayer", + "config": { + "step": { + "user": { + "description": "Por favor, insira o caminho e os dados do cookie. Mais informações disponíveis em https://github.com/KoljaWindeler/ytube_music_player", + "data": { + "name": "Nome da entidade (sem o prefixo 'media_player')", + "cookie": "Digite o texto do cookie copiado do navegador", + "speakers": "Selecione o speaker de saída padrão", + "header_path": "Caminho do arquivo para o arquivo do cabeçalho", + "advance_config": "Mostrar configuração avançada" + } + }, + "finish": { + "description": "Aqui você pode alterar para IDs de entidade de campos selecionados, se você não usar os menus suspensos, deixe-os como estão", + "data": { + "brand_id": "Insira um ID de marca se estiver usando uma conta de marca", + "select_speakers": "ID da entidade do input_select para seleção de alto-falante", + "select_playmode":"ID da entidade do input_select para seleção do modo de reprodução", + "select_source":"ID da entidade do input_select para seleção de playlist/rádio", + "select_playlist":"ID da entidade do input_select para seleção de lista de reprodução", + "select_playcontinuous":"ID da entidade do input_boolean para reprodução de seleção contínua", + "proxy_path": "Caminho local para o modo proxy, deixe em branco se não precisar", + "proxy_url": "URL base para o modo proxy, deixe em branco se não precisar", + "like_in_name": "Mostrar status como no nome", + "debug_as_error": "Mostrar todas as saídas de depuração como ERROR no log", + "shuffle": "Ativar reprodução aleatória na inicialização", + "track_limit": "Limite de faixas carregadas simultaneamente", + "legacy_radio": "Criar rádio como lista de observação de faixa de lista de reprodução aleatória", + "sort_browser": "Classifique os resultados no navegador de mídia", + "extra_sensor": "Crie um sensor que forneça informações extras" + } + } + }, + "error": { + "ERROR_GENERIC": "Algo com seu cookie não estava certo. O formato e os campos estão corretos, mas o login falhou", + "ERROR_AUTH_USER": "Não é possível encontrar o campo 'X-Goog-AuthUser', verifique sua entrada", + "ERROR_COOKIE": "Não é possível encontrar o campo 'Cookie', verifique sua entrada", + "ERROR_CONTENTS": "O formato do cookie está OK, encontrou '__Secure-3PAPISID' e '__Secure-3PSID', mas não pode recuperar nenhum dado com essas configurações, talvez você não tenha copiado todos os dados?", + "ERROR_FORMAT": "O formato do cookie NÃO está correto, provavelmente faltando '__Secure-3PAPISID' ou '__Secure-3PSID'", + "ERROR_NONE": "O formato do cookie está OK, mas o valor retornado da subAPI é Nenhum", + "ERROR_FORBIDDEN": "O YouTube retornou um erro 403, o que significa que seus dados de login não são mais válidos. Por favor, atualize o cookie" + } + }, + "options": { + "step": { + "init": { + "description": "Por favor, insira o caminho e os dados do cookie. Mais informações disponíveis em https://github.com/KoljaWindeler/ytube_music_player", + "data": { + "name": "Nome da entidade (sem o prefixo 'media_player')", + "cookie": "Digite o texto do cookie copiado do navegador", + "speakers": "Selecione o speaker de saída padrão", + "header_path": "Caminho do arquivo para o arquivo do cabeçalho", + "advance_config": "Mostrar configuração avançada" + } + }, + "finish": { + "description": "Aqui você pode alterar para IDs de entidade de campos selecionados, se você não usar os menus suspensos, deixe-os como estão", + "data": { + "brand_id": "Insira um ID de marca se estiver usando uma conta de marca", + "select_speakers": "ID da entidade do input_select para seleção de alto-falante", + "select_playmode":"ID da entidade do input_select para seleção do modo de reprodução", + "select_source":"ID da entidade do input_select para seleção de playlist/rádio", + "select_playlist":"ID da entidade do input_select para seleção de lista de reprodução", + "select_playcontinuous":"ID da entidade do input_boolean para reprodução de seleção contínua", + "proxy_path": "Caminho local para o modo proxy, deixe em branco se não precisar", + "proxy_url": "URL base para o modo proxy, deixe em branco se não precisar", + "like_in_name": "Mostrar status como no nome", + "debug_as_error": "Mostrar todas as saídas de depuração como ERROR no log", + "shuffle": "Ativar reprodução aleatória na inicialização", + "track_limit": "Limite de faixas carregadas simultaneamente", + "legacy_radio": "Criar rádio como lista de observação de faixa de lista de reprodução aleatória", + "sort_browser": "Classifique os resultados no navegador de mídia", + "extra_sensor": "Crie um sensor que forneça informações extras" + } + } + }, + "error": { + "ERROR_GENERIC": "Algo com seu cookie não estava certo. O formato e os campos estão corretos, mas o login falhou", + "ERROR_AUTH_USER": "Não é possível encontrar o campo 'X-Goog-AuthUser', verifique sua entrada", + "ERROR_COOKIE": "Não é possível encontrar o campo 'Cookie', verifique sua entrada", + "ERROR_CONTENTS": "O formato do cookie está OK, encontrou '__Secure-3PAPISID' e '__Secure-3PSID', mas não pode recuperar nenhum dado com essas configurações, talvez você não tenha copiado todos os dados?", + "ERROR_FORMAT": "O formato do cookie NÃO está correto, provavelmente faltando '__Secure-3PAPISID' ou '__Secure-3PSID'", + "ERROR_NONE": "O formato do cookie está OK, mas o valor retornado da subAPI é Nenhum", + "ERROR_FORBIDDEN": "O YouTube retornou um erro 403, o que significa que seus dados de login não são mais válidos. Por favor, atualize o cookie" + } + } +} diff --git a/custom_components/ytube_music_player/translations/zh-Hans.json b/custom_components/ytube_music_player/translations/zh-Hans.json new file mode 100644 index 00000000..cbbc255d --- /dev/null +++ b/custom_components/ytube_music_player/translations/zh-Hans.json @@ -0,0 +1,232 @@ +{ + "title": "yTubeMediaPlayer", + "config": { + "step": { + "oauth": { + "description": "请在单独的浏览器窗口中打开下方链接,成功登录Google账户后返回本页面继续操作。", + "data": { + "code": "Google Device Code", + "name": "实体名称 (不含 'media_player' 前缀)" + } + }, + "finish": { + "description": "在这里进行基本设置。需要帮助请查看https://github.com/KoljaWindeler/ytube_music_player", + "data": { + "speakers": "选择输出设备白名单,第一个是默认设备。", + "header_path": "header文件保存路径", + "api_language": "API返回结果语言", + "advance_config": "显示高级选项" + } + }, + "adv_finish": { + "description": "在这里进行高级设置,比如限制流量使用和单次加载的最大曲目数。", + "data": { + "brand_id": "如果您正在使用brand account,请输入brand id", + "proxy_path": "代理模式的本地路径,不需要请留空", + "proxy_url": "代理服务器地址,不需要请留空", + "like_in_name": "在名称中显示喜欢状态", + "debug_as_error": "在日志中将所有调试输出显示为ERROR", + "shuffle": "启动时随机播放", + "shuffle_mode": "播放模式", + "track_limit": "加载曲目数量限制", + "max_datarate": "限制最大比特率,设置为0以禁用", + "legacy_radio": "将随机播放列表曲目创建为收藏夹电台", + "sort_browser": "在媒体浏览器中对结果进行排序", + "extra_sensor": "创建提供额外信息的传感器实体", + "dropdowns": "创建你需要的下拉菜单实体", + "select_speakers": "播放设备下拉菜单实体ID(已过期,留下一个空格字符可以永久删除这个字段)", + "select_playmode":"循环模式下拉菜单实体ID(已过期,留下一个空格字符可以永久删除这个字段)", + "select_source":"播放列表/电台选择下拉菜单实体ID(已过期,留下一个空格字符可以永久删除这个字段)", + "select_playlist":"播放列表下拉菜单实体ID(已过期,留下一个空格字符可以永久删除这个字段)", + "select_playcontinuous":"持续播放模式下拉菜单实体ID(已过期,留下一个空格字符可以永久删除这个字段)" + } + } + }, + "error": { + "ERROR_GENERIC": "没有获得访问权限,请再试一次。" + } + }, + "options": { + "step": { + "oauth": { + "description": "登录令牌已失效。请再次在单独的浏览器窗口中打开下方链接,成功登录Google账户后返回本页面继续操作。", + "data": { + "code": "Google Device Code", + "name": "实体名称 (不含 'media_player' 前缀)" + } + }, + "finish": { + "description": "在这里进行基本设置。需要帮助请查看https://github.com/KoljaWindeler/ytube_music_player", + "data": { + "speakers": "选择输出设备白名单,第一个是默认设备。", + "header_path": "header文件保存路径", + "api_language": "API返回结果语言", + "advance_config": "显示高级选项" + } + }, + "adv_finish": { + "description": "在这里进行高级设置,比如限制流量使用和单次加载的最大曲目数。", + "data": { + "brand_id": "如果您正在使用brand account,请输入brand id", + "proxy_path": "代理模式的本地路径,不需要请留空", + "proxy_url": "代理服务器地址,不需要请留空", + "like_in_name": "在名称中显示喜欢状态", + "debug_as_error": "在日志中将所有调试输出显示为ERROR", + "shuffle": "启动时随机播放", + "shuffle_mode": "播放模式", + "track_limit": "加载曲目数量限制", + "max_datarate": "限制最大比特率,设置为0以禁用", + "legacy_radio": "将随机播放列表曲目创建为收藏夹电台", + "sort_browser": "在媒体浏览器中对结果进行排序", + "extra_sensor": "创建提供额外信息的传感器实体", + "dropdowns": "创建你需要的下拉菜单实体", + "select_speakers": "播放设备下拉菜单实体ID(已过期,留下一个空格字符可以永久删除这个字段)", + "select_playmode":"循环模式下拉菜单实体ID(已过期,留下一个空格字符可以永久删除这个字段)", + "select_source":"播放列表/电台选择下拉菜单实体ID(已过期,留下一个空格字符可以永久删除这个字段)", + "select_playlist":"播放列表下拉菜单实体ID(已过期,留下一个空格字符可以永久删除这个字段)", + "select_playcontinuous":"持续播放模式下拉菜单实体ID(已过期,留下一个空格字符可以永久删除这个字段)" + } + } + }, + "error": { + "ERROR_GENERIC": "没有获得访问权限,请再试一次。" + } + }, + "services": { + "add_to_playlist": { + "name": "将歌曲添加到播放列表", + "description": "添加歌曲到播放列表", + "fields": { + "entity_id": { + "name": "实体ID", + "description": "ytube media player 的实体ID" + }, + "song_id": { + "name": "歌曲ID", + "description": "歌曲ID,可选。默认使用当前歌曲ID。" + }, + "playlist_id": { + "name": "播放列表ID", + "description": "播放列表ID,可选。默认使用当前播放列表ID。" + } + } + }, + "remove_from_playlist": { + "name": "从播放列表中移除歌曲", + "description": "从播放列表中移除一首歌曲", + "fields": { + "entity_id": { + "name": "实体ID", + "description": "ytube media player 的实体ID" + }, + "song_id": { + "name": "歌曲ID", + "description": "歌曲ID,可选。默认使用当前歌曲ID。" + }, + "playlist_id": { + "name": "播放列表ID", + "description": "播放列表ID,可选。默认使用当前播放列表ID。" + } + } + }, + "call_method": { + "name": "调用本组件方法", + "description": "运行自定义命令", + "fields": { + "entity_id": { + "name": "实体ID", + "description": "要运行自定义命令的目标实体ID。", + "example": "media_player.ytube_music_player" + }, + "command": { + "name": "命令", + "description": "要运行的自定义命令。", + "example": "rate_track" + }, + "parameters": { + "name": "参数", + "description": "参数数组,部分命令为可选参数。", + "example": "thumb_up" + } + } + }, + "search": { + "description": "在YouTube Music Player中搜索音乐/专辑等", + "name": "搜索", + "fields":{ + "entity_id": { + "name": "实体ID", + "description": "要运行自定义命令的目标实体ID。", + "example": "media_player.ytube_music_player" + }, + "query": { + "name": "搜索内容", + "description": "要搜索的内容文本", + "example": "2pm tetris" + }, + "filter": { + "name": "过滤器", + "description": "搜索结果过滤器,可以是 'albums', 'playlists','artists' 或者 'songs',留空返回所有结果。" + }, + "limit": { + "name": "限制", + "description": "结果数量限制", + "example": "20" + } + } + }, + "rate_track": { + "name": "对曲目进行评分", + "description": "对歌曲进行评分", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "要运行自定义命令的目标实体ID。", + "example": "media_player.ytube_music_player" + }, + "rating": { + "name":"评分", + "description": "歌曲的评分,可以是'thumb_up' / 'thumb_down' / 'thumb_middle' / 'thumb_toggle_up_middle'", + "example": "thumb_up" + }, + "song_id": { + "name": "歌曲ID", + "description": "歌曲ID,可选。默认使用当前歌曲ID。", + "example": "" + } + } + }, + "limit_count": { + "name": "歌曲限制数量", + "description": "限制加载的曲目数量", + "fields": { + "entity_id": { + "name": "实体ID", + "description": "要运行自定义命令的目标实体ID。", + "example": "media_player.ytube_music_player" + }, + "limit": { + "name": "限制", + "description": "每次执行加载的曲目数量", + "example": "20" + } + } + }, + "start_radio": { + "name": "电台", + "description": "基于当前曲目创建电台。", + "fields": { + "entity_id": { + "name": "实体ID", + "description": "要运行自定义命令的目标实体ID。", + "example": "media_player.ytube_music_player" + }, + "interrupt": { + "name": "中断", + "description": "是否中断当前播放", + "example": "true" + } + } + } + } +} diff --git a/www/community/Bubble-Card/bubble-card.js b/www/community/Bubble-Card/bubble-card.js index abcb9de1..920c205d 100644 --- a/www/community/Bubble-Card/bubble-card.js +++ b/www/community/Bubble-Card/bubble-card.js @@ -1,6 +1,6 @@ -(()=>{"use strict";var __webpack_modules__={946:(e,t,n)=>{function a(e,t=40){if(Array.isArray(e)&&3===e.length){for(let t=0;t<3;t++)if(e[t]<0||e[t]>255)return;return e.every((e=>Math.abs(e-255)<=t))}}let o;function i(e,t,n=1){if(e.startsWith("#"))if(4===e.length){let a=Math.min(255,parseInt(e.charAt(1).repeat(2),16)*n),i=Math.min(255,parseInt(e.charAt(2).repeat(2),16)*n),r=Math.min(255,parseInt(e.charAt(3).repeat(2),16)*n);o="rgba("+a+", "+i+", "+r+", "+t+")"}else{let a=Math.min(255,parseInt(e.slice(1,3),16)*n),i=Math.min(255,parseInt(e.slice(3,5),16)*n),r=Math.min(255,parseInt(e.slice(5,7),16)*n);o="rgba("+a+", "+i+", "+r+", "+t+")"}else if(e.startsWith("rgb")){let a=e.match(/\d+/g);o="rgba("+Math.min(255,a[0]*n)+", "+Math.min(255,a[1]*n)+", "+Math.min(255,a[2]*n)+", "+t+")"}else if(e.startsWith("var(--")){let a=e.slice(4,-1),r=window.getComputedStyle(document.documentElement).getPropertyValue(a);(r.startsWith("#")||r.startsWith("rgb"))&&(o=i(r,t,n))}return o}n.d(t,{_k:()=>i,wW:()=>a})},191:(__unused_webpack_module,__webpack_exports__,__webpack_require__)=>{__webpack_require__.d(__webpack_exports__,{BX:()=>fireEvent,GP:()=>applyScrollingEffect,IL:()=>getAttribute,Jn:()=>tapFeedback,OC:()=>isEntityType,P2:()=>throttle,Vv:()=>isColorLight,X:()=>getWeatherIcon,az:()=>createElement,gJ:()=>getImage,jk:()=>forwardHaptic,jx:()=>setLayout,mk:()=>getIconColor,o0:()=>formatDateTime,oY:()=>getName,pr:()=>isStateOn,q7:()=>getIcon,y0:()=>getState});var _style_ts__WEBPACK_IMPORTED_MODULE_0__=__webpack_require__(946);function hasStateChanged(e,t,n){if(e.hasState=t.states[n],e.hasState)return e.newState=[e.hasState.state,e.hasState.attributes.rgb_color],e.oldState&&e.newState[0]===e.oldState[0]&&e.newState[1]===e.oldState[1]?e.stateChanged=!1:(e.oldState=e.newState,e.stateChanged=!0),e.stateChanged}function configChanged(e,t){return!(!t.classList.contains("editor")||e.config===e.previousConfig||(e.previousConfig=e.config,0))}const fireEvent=(e,t,n,a)=>{a=a||{},n=null==n?{}:n;const o=new Event(t,{bubbles:void 0===a.bubbles||a.bubbles,cancelable:Boolean(a.cancelable),composed:void 0===a.composed||a.composed});return o.detail=n,e.dispatchEvent(o),o},forwardHaptic=e=>{fireEvent(window,"haptic",e)},navigate=(e,t,n=!1)=>{n?history.replaceState(null,"",t):history.pushState(null,"",t),fireEvent(window,"location-changed",{replace:n})};function toggleEntity(e,t){e.callService("homeassistant","toggle",{entity_id:t})}function tapFeedback(e){void 0!==e&&(e.style.display="",e.style.animation="tap-feedback .3s",setTimeout((()=>{e.style.animation="none",e.style.display="none"}),500))}function getIcon(e,t=e.config.entity,n=e.config.icon){const a=t?.split(".")[0],o=getAttribute(e,"device_class",t),i=getAttribute(e,"icon",t),r=n,s=getState(e,t),l={alarm_control_panel:"mdi:shield",alert:"mdi:alert",automation:"mdi:playlist-play",binary_sensor:function(){const n="off"===s;switch(getAttribute(e,"device_class",t)){case"battery":return n?"mdi:battery":"mdi:battery-outline";case"battery_charging":return n?"mdi:battery":"mdi:battery-charging";case"cold":return n?"mdi:thermometer":"mdi:snowflake";case"connectivity":return n?"mdi:server-network-off":"mdi:server-network";case"door":return n?"mdi:door-closed":"mdi:door-open";case"garage_door":return n?"mdi:garage":"mdi:garage-open";case"power":case"plug":return n?"mdi:power-plug-off":"mdi:power-plug";case"tamper":return n?"mdi:check-circle":"mdi:alert-circle";case"smoke":return n?"mdi:check-circle":"mdi:smoke";case"heat":return n?"mdi:thermometer":"mdi:fire";case"light":return n?"mdi:brightness-5":"mdi:brightness-7";case"lock":return n?"mdi:lock":"mdi:lock-open";case"moisture":return n?"mdi:water-off":"mdi:water";case"motion":return n?"mdi:motion-sensor-off":"mdi:motion-sensor";case"occupancy":case"presence":return n?"mdi:home-outline":"mdi:home";case"opening":return n?"mdi:square":"mdi:square-outline";case"running":return n?"mdi:stop":"mdi:play";case"sound":return n?"mdi:music-note-off":"mdi:music-note";case"update":return n?"mdi:package":"mdi:package-up";case"vibration":return n?"mdi:crop-portrait":"mdi:vibrate";case"window":return n?"mdi:window-closed":"mdi:window-open";default:return n?"mdi:radiobox-blank":"mdi:checkbox-marked-circle"}}(),calendar:"mdi:calendar",camera:"mdi:video",climate:"mdi:thermostat",configurator:"mdi:settings",conversation:"mdi:text-to-speech",cover:function(){const n="closed"!==s;switch(getAttribute(e,"device_class",t)){case"awning":return n?"mdi:awning-outline":"mdi:awning";case"blind":return n?"mdi:blinds-open":"mdi:blinds";case"curtain":return n?"mdi:curtains-open":"mdi:curtains";case"damper":case"shutter":default:return n?"mdi:window-shutter-open":"mdi:window-shutter";case"door":return n?"mdi:door-open":"mdi:door-closed";case"garage":return n?"mdi:garage-open":"mdi:garage";case"gate":return n?"mdi:gate-open":"mdi:gate";case"shade":return n?"mdi:roller-shade":"mdi:roller-shade-closed";case"window":return n?"mdi:window-open":"mdi:window-closed"}}(),device_tracker:"mdi:account",fan:"mdi:fan",group:"mdi:google-circles-communities",history_graph:"mdi:chart-line",homeassistant:"mdi:home-assistant",homekit:"mdi:home-automation",image_processing:"mdi:image-filter-frames",input_boolean:"mdi:drawing",input_datetime:"mdi:calendar-clock",input_number:"mdi:ray-vertex",input_select:"mdi:format-list-bulleted",input_text:"mdi:textbox",light:"mdi:lightbulb",lock:"mdi:lock",mailbox:"mdi:mailbox",media_player:"mdi:speaker",mower:"mdi:robot-mower",notify:"mdi:comment-alert",person:"mdi:account",plant:"mdi:flower",proximity:"mdi:apple-safari",remote:"mdi:remote",scene:"mdi:palette",script:"mdi:file-document",sensor:function(){switch(getAttribute(e,"device_class",t)){case"battery":return 100==s?"mdi:battery":s>=90?"mdi:battery-90":s>=80?"mdi:battery-80":s>=70?"mdi:battery-70":s>=60?"mdi:battery-60":s>=50?"mdi:battery-50":s>=40?"mdi:battery-40":s>=30?"mdi:battery-30":s>=20?"mdi:battery-20":s>=10?"mdi:battery-10":"mdi:battery-alert";case"humidity":return"mdi:water-percent";case"illuminance":return"mdi:brightness-5";case"temperature":return"mdi:thermometer";case"pressure":return"mdi:gauge";case"power":return"mdi:flash";case"signal_strength":return"mdi:wifi";case"energy":return"mdi:lightning-bolt";default:return"mdi:eye"}}(),simple_alarm:"mdi:bell",sun:"mdi:white-balance-sunny",switch:"mdi:flash",timer:"mdi:timer",updater:"mdi:cloud-upload",vacuum:"mdi:robot-vacuum",water_heater:"mdi:thermometer",weather:function(n=getState(e,t)){switch(n){case"cloudy":default:return"mdi:weather-cloudy";case"partlycloudy":return"mdi:weather-partly-cloudy";case"rainy":return"mdi:weather-rainy";case"snowy":return"mdi:weather-snowy";case"sunny":return"mdi:weather-sunny";case"clear-night":return"mdi:weather-night";case"fog":return"mdi:weather-fog";case"hail":return"mdi:weather-hail";case"lightning":return"mdi:weather-lightning";case"lightning-rainy":return"mdi:weather-lightning-rainy";case"pouring":return"mdi:weather-pouring";case"windy":return"mdi:weather-windy";case"windy-variant":return"mdi:weather-windy-variant";case"exceptional":return"mdi:alert-circle-outline"}}(),weblink:"mdi:open-in-new"};return r||i||(l[a]?l[a]:l[o]?l[o]:"")}function getWeatherIcon(e){switch(e){case"cloudy":default:return"mdi:weather-cloudy";case"partlycloudy":return"mdi:weather-partly-cloudy";case"rainy":return"mdi:weather-rainy";case"snowy":return"mdi:weather-snowy";case"sunny":return"mdi:weather-sunny";case"clear-night":return"mdi:weather-night";case"fog":return"mdi:weather-fog";case"hail":return"mdi:weather-hail";case"lightning":return"mdi:weather-lightning";case"lightning-rainy":return"mdi:weather-lightning-rainy";case"pouring":return"mdi:weather-pouring";case"windy":return"mdi:weather-windy";case"windy-variant":return"mdi:weather-windy-variant";case"exceptional":return"mdi:alert-circle-outline"}}let cachedColor=null,cachedResult=null;function resolveCssVariable(e){const t=getComputedStyle(document.body);let n=e;for(;n.startsWith("var(");){const e=n.match(/var\((--[^,]+),?\s*(.*)?\)/);if(!e)break;const a=t.getPropertyValue(e[1]).trim();if(a)n=a;else{if(!e[2])break;n=e[2].trim()}}return n}function isColorLight(e){const t=resolveCssVariable(e);if(!t)return!1;if(t===cachedColor)return cachedResult;cachedColor=t;const n=t.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);let a,o,i;if(n)a=parseInt(n[1],16),o=parseInt(n[2],16),i=parseInt(n[3],16);else{const e=t.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);if(!e)return cachedResult=!1,cachedResult;a=parseInt(e[1],10),o=parseInt(e[2],10),i=parseInt(e[3],10)}return cachedResult=(.2126*a+.7152*o+.0722*i)/255>.5,cachedResult}function getIconColor(e,t=e.config.entity,n=1){const a="var(--bubble-accent-color, var(--accent-color))",o=getAttribute(e,"rgb_color",t),i=isColorLight("var(--bubble-button-icon-background-color, var(--bubble-icon-background-color, var(--bubble-secondary-background-color, var(--card-background-color, var(--ha-card-background)))))");if(n=i?n-.2:n,!t)return a;if(!1===t.startsWith("light."))return a;const r=i?[200,200,195]:[225,225,222],s=i?[200,180,180]:[255,255,255],l=isStateOn(e)?r:s;if(!o)return`var(--bubble-light-color, rgba(${l.map((e=>Math.min(255,e*n))).join(", ")}))`;const c=o.map((e=>Math.min(255,e*n)));return(0,_style_ts__WEBPACK_IMPORTED_MODULE_0__.wW)(o)?`var(--bubble-light-color, rgba(${r.map((e=>Math.min(255,e*n))).join(", ")}))`:`var(--bubble-light-color, rgba(${c.join(", ")}))`}function getImage(e){if(e.config.force_icon)return"";const t=getAttribute(e,"entity_picture_local"),n=getAttribute(e,"entity_picture");return t||n||""}function getName(e){const t=e.config.name,n=getAttribute(e,"friendly_name");return t||n||""}function getState(e,t=e.config.entity){return e._hass.states[t]?.state??""}function getAttribute(context,attribute,entity=context.config.entity){return attribute?eval(`context._hass.states['${entity}']?.attributes.${attribute}`)??"":""}function isEntityType(e,t){return e.config.entity?.startsWith(t+".")??!1}function isStateOn(e,t=e.config.entity){const n=getState(e,t).toLowerCase(),a=Number(n);return!!(["on","open","opening","closing","cleaning","true","idle","home","playing","paused","locked","occupied","available","running","active","connected","online","mowing","starting","heat","cool","dry","heat_cool","fan_only","auto","alarm"].includes(n)||a>0)}function createElement(e,t=""){const n=document.createElement(e);return""!==t&&t.split(" ").forEach((e=>{n.classList.add(e)})),n}function debounce(e,t){let n;return function(...a){clearTimeout(n),n=setTimeout((()=>e.apply(this,a)),t)}}function applyScrollingEffect(e,t,n){const a=e.config.scrolling_effect??!0;if(!a)return void applyNonScrollingStyle(t,n);if(t.previousText===n)return;const o=t.className.split(" ").find((e=>e.startsWith("bubble-")));function i(){t.innerHTML=`
input_boolean
) and trigger its opening/closing in an automation.
+ The Bubble Card ${e} changelog is available here.
@@ -846,7 +836,7 @@Thank you! 🍻
${this.makeVersion()}data:
to your service.${n}`}return""}buildRipple(){return this.renderRipple?lit__WEBPACK_IMPORTED_MODULE_0__.qy`
p&&(p=t.lineIndent),tt(r))h++;else{if(t.lineIndent
0){for(o=r,a=0;o>0;o--)(r=ot(s=t.input.charCodeAt(++t.position)))>=0?a=(a<<4)+r:ht(t,"expected hexadecimal character");t.result+=rt(a),t.position++}else ht(t,"unknown escape sequence");n=i=t.position}else tt(s)?(ft(t,n,i,!0),kt(t,vt(t,!1,e)),n=i=t.position):t.position===t.lineStart&>(t)?ht(t,"unexpected end of the document within a double quoted scalar"):(t.position++,i=t.position)}ht(t,"unexpected end of the stream within a double quoted scalar")}(t,d)?_=!0:function(t){var e,n,i;if(42!==(i=t.input.charCodeAt(t.position)))return!1;for(i=t.input.charCodeAt(++t.position),e=t.position;0!==i&&!nt(i)&&!it(i);)i=t.input.charCodeAt(++t.position);return t.position===e&&ht(t,"name of an alias node must contain at least one character"),n=t.input.slice(e,t.position),U.call(t.anchorMap,n)||ht(t,'unidentified alias "'+n+'"'),t.result=t.anchorMap[n],vt(t,!0,-1),!0}(t)?(_=!0,null===t.tag&&null===t.anchor||ht(t,"alias node should not have any properties")):function(t,e,n){var i,o,a,r,s,c,l,u,p=t.kind,h=t.result;if(nt(u=t.input.charCodeAt(t.position))||it(u)||35===u||38===u||42===u||33===u||124===u||62===u||39===u||34===u||37===u||64===u||96===u)return!1;if((63===u||45===u)&&(nt(i=t.input.charCodeAt(t.position+1))||n&&it(i)))return!1;for(t.kind="scalar",t.result="",o=a=t.position,r=!1;0!==u;){if(58===u){if(nt(i=t.input.charCodeAt(t.position+1))||n&&it(i))break}else if(35===u){if(nt(t.input.charCodeAt(t.position-1)))break}else{if(t.position===t.lineStart&>(t)||n&&it(u))break;if(tt(u)){if(s=t.line,c=t.lineStart,l=t.lineIndent,vt(t,!1,-1),t.lineIndent>=e){r=!0,u=t.input.charCodeAt(t.position);continue}t.position=a,t.line=s,t.lineStart=c,t.lineIndent=l;break}}r&&(ft(t,o,a,!1),kt(t,t.line-s),o=a=t.position,r=!1),et(u)||(a=t.position+1),u=t.input.charCodeAt(++t.position)}return ft(t,o,a,!1),!!t.result||(t.kind=p,t.result=h,!1)}(t,d,K===n)&&(_=!0,null===t.tag&&(t.tag="?")),null!==t.anchor&&(t.anchorMap[t.anchor]=t.result)):0===f&&(_=c&&wt(t,m))),null===t.tag)null!==t.anchor&&(t.anchorMap[t.anchor]=t.result);else if("?"===t.tag){for(null!==t.result&&"scalar"!==t.kind&&ht(t,'unacceptable node kind for !> tag; it should be "scalar", not "'+t.kind+'"'),l=0,u=t.implicitTypes.length;l"),null!==t.result&&h.kind!==t.kind&&ht(t,"unacceptable node kind for !<"+t.tag+'> tag; it should be "'+h.kind+'", not "'+t.kind+'"'),h.resolve(t.result,t.tag)?(t.result=h.construct(t.result,t.tag),null!==t.anchor&&(t.anchorMap[t.anchor]=t.result)):ht(t,"cannot resolve a node with !<"+t.tag+"> explicit tag")}return null!==t.listener&&t.listener("close",t),null!==t.tag||null!==t.anchor||_}function Lt(t){var e,n,i,o,a=t.position,r=!1;for(t.version=null,t.checkLineBreaks=t.legacy,t.tagMap=Object.create(null),t.anchorMap=Object.create(null);0!==(o=t.input.charCodeAt(t.position))&&(vt(t,!0,-1),o=t.input.charCodeAt(t.position),!(t.lineIndent>0||37!==o));){for(r=!0,o=t.input.charCodeAt(++t.position),e=t.position;0!==o&&!nt(o);)o=t.input.charCodeAt(++t.position);for(i=[],(n=t.input.slice(e,t.position)).length<1&&ht(t,"directive name must not be less than one character in length");0!==o;){for(;et(o);)o=t.input.charCodeAt(++t.position);if(35===o){do{o=t.input.charCodeAt(++t.position)}while(0!==o&&!tt(o));break}if(tt(o))break;for(e=t.position;0!==o&&!nt(o);)o=t.input.charCodeAt(++t.position);i.push(t.input.slice(e,t.position))}0!==o&&bt(t),U.call(mt,n)?mt[n](t,n,i):dt(t,'unknown document directive "'+n+'"')}vt(t,!0,-1),0===t.lineIndent&&45===t.input.charCodeAt(t.position)&&45===t.input.charCodeAt(t.position+1)&&45===t.input.charCodeAt(t.position+2)?(t.position+=3,vt(t,!0,-1)):r&&ht(t,"directives end mark is expected"),xt(t,t.lineIndent-1,H,!1,!0),vt(t,!0,-1),t.checkLineBreaks&&W.test(t.input.slice(a,t.position))&&dt(t,"non-ASCII line breaks are interpreted as content"),t.documents.push(t.result),t.position===t.lineStart&>(t)?46===t.input.charCodeAt(t.position)&&(t.position+=3,vt(t,!0,-1)):t.position p&&(p=t.lineIndent),tt(r))d++;else{if(t.lineIndent 0){for(o=r,a=0;o>0;o--)(r=ot(s=t.input.charCodeAt(++t.position)))>=0?a=(a<<4)+r:dt(t,"expected hexadecimal character");t.result+=rt(a),t.position++}else dt(t,"unknown escape sequence");n=i=t.position}else tt(s)?(ft(t,n,i,!0),kt(t,vt(t,!1,e)),n=i=t.position):t.position===t.lineStart&>(t)?dt(t,"unexpected end of the document within a double quoted scalar"):(t.position++,i=t.position)}dt(t,"unexpected end of the stream within a double quoted scalar")}(t,h)?_=!0:function(t){var e,n,i;if(42!==(i=t.input.charCodeAt(t.position)))return!1;for(i=t.input.charCodeAt(++t.position),e=t.position;0!==i&&!nt(i)&&!it(i);)i=t.input.charCodeAt(++t.position);return t.position===e&&dt(t,"name of an alias node must contain at least one character"),n=t.input.slice(e,t.position),U.call(t.anchorMap,n)||dt(t,'unidentified alias "'+n+'"'),t.result=t.anchorMap[n],vt(t,!0,-1),!0}(t)?(_=!0,null===t.tag&&null===t.anchor||dt(t,"alias node should not have any properties")):function(t,e,n){var i,o,a,r,s,c,l,u,p=t.kind,d=t.result;if(nt(u=t.input.charCodeAt(t.position))||it(u)||35===u||38===u||42===u||33===u||124===u||62===u||39===u||34===u||37===u||64===u||96===u)return!1;if((63===u||45===u)&&(nt(i=t.input.charCodeAt(t.position+1))||n&&it(i)))return!1;for(t.kind="scalar",t.result="",o=a=t.position,r=!1;0!==u;){if(58===u){if(nt(i=t.input.charCodeAt(t.position+1))||n&&it(i))break}else if(35===u){if(nt(t.input.charCodeAt(t.position-1)))break}else{if(t.position===t.lineStart&>(t)||n&&it(u))break;if(tt(u)){if(s=t.line,c=t.lineStart,l=t.lineIndent,vt(t,!1,-1),t.lineIndent>=e){r=!0,u=t.input.charCodeAt(t.position);continue}t.position=a,t.line=s,t.lineStart=c,t.lineIndent=l;break}}r&&(ft(t,o,a,!1),kt(t,t.line-s),o=a=t.position,r=!1),et(u)||(a=t.position+1),u=t.input.charCodeAt(++t.position)}return ft(t,o,a,!1),!!t.result||(t.kind=p,t.result=d,!1)}(t,h,j===n)&&(_=!0,null===t.tag&&(t.tag="?")),null!==t.anchor&&(t.anchorMap[t.anchor]=t.result)):0===f&&(_=c&&wt(t,m))),null===t.tag)null!==t.anchor&&(t.anchorMap[t.anchor]=t.result);else if("?"===t.tag){for(null!==t.result&&"scalar"!==t.kind&&dt(t,'unacceptable node kind for !> tag; it should be "scalar", not "'+t.kind+'"'),l=0,u=t.implicitTypes.length;l"),null!==t.result&&d.kind!==t.kind&&dt(t,"unacceptable node kind for !<"+t.tag+'> tag; it should be "'+d.kind+'", not "'+t.kind+'"'),d.resolve(t.result,t.tag)?(t.result=d.construct(t.result,t.tag),null!==t.anchor&&(t.anchorMap[t.anchor]=t.result)):dt(t,"cannot resolve a node with !<"+t.tag+"> explicit tag")}return null!==t.listener&&t.listener("close",t),null!==t.tag||null!==t.anchor||_}function Tt(t){var e,n,i,o,a=t.position,r=!1;for(t.version=null,t.checkLineBreaks=t.legacy,t.tagMap=Object.create(null),t.anchorMap=Object.create(null);0!==(o=t.input.charCodeAt(t.position))&&(vt(t,!0,-1),o=t.input.charCodeAt(t.position),!(t.lineIndent>0||37!==o));){for(r=!0,o=t.input.charCodeAt(++t.position),e=t.position;0!==o&&!nt(o);)o=t.input.charCodeAt(++t.position);for(i=[],(n=t.input.slice(e,t.position)).length<1&&dt(t,"directive name must not be less than one character in length");0!==o;){for(;et(o);)o=t.input.charCodeAt(++t.position);if(35===o){do{o=t.input.charCodeAt(++t.position)}while(0!==o&&!tt(o));break}if(tt(o))break;for(e=t.position;0!==o&&!nt(o);)o=t.input.charCodeAt(++t.position);i.push(t.input.slice(e,t.position))}0!==o&&bt(t),U.call(mt,n)?mt[n](t,n,i):ht(t,'unknown document directive "'+n+'"')}vt(t,!0,-1),0===t.lineIndent&&45===t.input.charCodeAt(t.position)&&45===t.input.charCodeAt(t.position+1)&&45===t.input.charCodeAt(t.position+2)?(t.position+=3,vt(t,!0,-1)):r&&dt(t,"directives end mark is expected"),xt(t,t.lineIndent-1,H,!1,!0),vt(t,!0,-1),t.checkLineBreaks&&W.test(t.input.slice(a,t.position))&&ht(t,"non-ASCII line breaks are interpreted as content"),t.documents.push(t.result),t.position===t.lineStart&>(t)?46===t.input.charCodeAt(t.position)&&(t.position+=3,vt(t,!0,-1)):t.position${n.map((e=>{const n=this.getEntryContext(e),i=this.buildIconElement(e,n);return t.qy`
${i.map((e=>{const n=this.getEntryContext(e),i=this.buildIconElement(e,n);return t.qy`
${o.map((e=>{const n=this.getEntryContext(e),i=this.buildIconElement(e,n);return t.qy`
${this.errors.map((e=>t.qy`
\n"))},e.random=function(t){return t[Math.floor(Math.random()*t.length)]},e.reject=l(!1),e.rejectattr=function(t,e){return t.filter((function(t){return!t[e]}))},e.select=l(!0),e.selectattr=function(t,e){return t.filter((function(t){return!!t[e]}))},e.replace=function(t,e,n,i){var a=t;if(e instanceof RegExp)return t.replace(e,n);void 0===i&&(i=-1);var r="";if("number"==typeof e)e=""+e;else if("string"!=typeof e)return t;if("number"==typeof t&&(t=""+t),"string"!=typeof t&&!(t instanceof o.SafeString))return t;if(""===e)return r=n+t.split("").join(n)+n,o.copySafeness(t,r);var s=t.indexOf(e);if(0===i||-1===s)return t;for(var c=0,l=0;s>-1&&(-1===i||l=o&&u.push(n),a.push(u)}return a},e.sum=function(t,e,n){return void 0===n&&(n=0),e&&(t=i.map(t,(function(t){return t[e]}))),n+t.reduce((function(t,e){return t+e}),0)},e.sort=o.makeMacro(["value","reverse","case_sensitive","attribute"],[],(function(t,e,n,o){var a=this,r=i.map(t,(function(t){return t})),s=i.getAttrGetter(o);return r.sort((function(t,r){var c=o?s(t):t,l=o?s(r):r;if(a.env.opts.throwOnUndefined&&o&&(void 0===c||void 0===l))throw new TypeError('sort: attribute "'+o+'" resolved to undefined');return!n&&i.isString(c)&&i.isString(l)&&(c=c.toLowerCase(),l=l.toLowerCase()),c${n}
`}return""}buildRipple(){return this.renderRipple?lit__WEBPACK_IMPORTED_MODULE_0__.qy`=c)return-1;if(37===(o=e.charCodeAt(r++))){if(o=e.charAt(r++),!(a=k[o in Dt?e.charAt(r++):o])||(i=a(t,n,i))<0)return-1}else if(o!=n.charCodeAt(i++))return-1}return i}return v.x=w(n,v),v.X=w(i,v),v.c=w(e,v),g.x=w(n,g),g.X=w(i,g),g.c=w(e,g),{format:function(t){var e=w(t+="",v);return e.toString=function(){return t},e},parse:function(t){var e=E(t+="",!1);return e.toString=function(){return t},e},utcFormat:function(t){var e=w(t+="",g);return e.toString=function(){return t},e},utcParse:function(t){var e=E(t+="",!0);return e.toString=function(){return t},e}}}({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]}),Ot=St.format,Mt=St.parse,It=St.utcFormat,$t=St.utcParse;var Ge="%Y-%m-%dT%H:%M:%S.%LZ";Date.prototype.toISOString||It(Ge);var Je=+new Date("2000-01-01T00:00:00.000Z")?function(t){var e=new Date(t);return isNaN(e)?null:e}:$t(Ge);const Qe=Je,tn=["weeks","days","hours","minutes","seconds","milliseconds"],en={weeks:604800,days:86400,hours:3600,minutes:60,seconds:1,milliseconds:.001};function nn(t){return"object"==typeof t&&!Array.isArray(t)}class on{constructor(t=0,e=0,n=0,i=0,o=0,a=0){this.days=0,this.seconds=0,this.milliseconds=0;let r={days:t,seconds:e,milliseconds:n,minutes:i,hours:o,weeks:a};nn(t)&&(delete r.days,Object.assign(r,t));let s=0;if(tn.forEach((t=>{s+=(r[t]??0)*en[t]})),s${n.map((e=>{const n=this.getEntryContext(e),i=this.buildIconElement(e,n);return t.qy`
${i.map((e=>{const n=this.getEntryContext(e),i=this.buildIconElement(e,n);return t.qy`
${o.map((e=>{const n=this.getEntryContext(e),i=this.buildIconElement(e,n);return t.qy`
${this.errors.map((e=>t.qy`
@@ -572,6 +611,7 @@ class FlexTableCard extends HTMLElement {
+
`;
// push css-style & table as content into the card's DOM tree
@@ -630,6 +670,118 @@ class FlexTableCard extends HTMLElement {
});
}
+ _updateFooter(footer, config, rows) {
+ var innerHTML = '';
+ var colnum = -1;
+ var raw = "";
+ var colspan_remainder = 0
+
+ config.columns.map((col, idx) => {
+ if (!col.hidden) {
+ colnum++;
+ if (colspan_remainder > 0)
+ // Skip column if previous colspan would overlap it
+ colspan_remainder--;
+ else {
+ var cfg = config.columns[idx];
+ if (col.footer_type) {
+ switch (col.footer_type) {
+ case 'sum':
+ raw = this._sumColumn(rows, colnum);
+ break;
+ case 'average':
+ raw = this._avgColumn(rows, colnum);
+ break;
+ case 'count':
+ raw = rows.length;
+ break;
+ case 'max':
+ raw = this._maxColumn(rows, colnum);
+ break;
+ case 'min':
+ raw = this._minColumn(rows, colnum);
+ break;
+ case 'text':
+ raw = col.footer_text;
+ break;
+ default:
+ console.log("Invalid footer_type: ", col.footer_type);
+ }
+ let x = raw;
+ let value = cfg.footer_modify ? eval(cfg.footer_modify) : x;
+ if (col.footer_type == 'text') {
+ let colspan = cfg.footer_colspan ? cfg.footer_colspan : 1;
+ innerHTML += ` ';
+ footer.innerHTML = innerHTML;
+ }
+
+ _sumColumn(rows, colnum) {
+ var sum = 0;
+ for (var i = 0; i < rows.length; i++) {
+ let cellValue = this._findNumber(rows[i].data[colnum].sort_unmodified ? rows[i].data[colnum].raw_content : rows[i].data[colnum].content);
+ if (!Number.isNaN(cellValue)) sum += cellValue;
+ }
+ return sum;
+ }
+
+ _avgColumn(rows, colnum) {
+ var sum = 0;
+ var count = 0;
+ for (var i = 0; i < rows.length; i++) {
+ let cellValue = this._findNumber(rows[i].data[colnum].sort_unmodified ? rows[i].data[colnum].raw_content : rows[i].data[colnum].content);
+ if (!Number.isNaN(cellValue)) {
+ sum += cellValue;
+ count++;
+ }
+ }
+ return sum / count;
+ }
+
+ _maxColumn(rows, colnum) {
+ var max = Number.MIN_VALUE;
+ for (var i = 0; i < rows.length; i++) {
+ let cellValue = this._findNumber(rows[i].data[colnum].sort_unmodified ? rows[i].data[colnum].raw_content : rows[i].data[colnum].content);
+ if (!Number.isNaN(cellValue)) {
+ if (cellValue > max) max = cellValue;
+ }
+ }
+ return max == Number.MIN_VALUE ? Number.NaN : max;
+ }
+
+ _minColumn(rows, colnum) {
+ var min = Number.MAX_VALUE;
+ for (var i = 0; i < rows.length; i++) {
+ let cellValue = this._findNumber(rows[i].data[colnum].sort_unmodified ? rows[i].data[colnum].raw_content : rows[i].data[colnum].content);
+ if (!Number.isNaN(cellValue)) {
+ if (cellValue < min) min = cellValue;
+ }
+ }
+ return min == Number.MAX_VALUE ? Number.NaN : min;
+ }
+
+ // Trim whitespace and leading non-numeric, but not minus sign
+ _findNumber(val) {
+ if (typeof val === "number") {
+ return val;
+ }
+ else {
+ let value = val.trim();
+ return (Number.isNaN(parseFloat(value[0])) && value[0] !== '-') ? parseFloat(value.substring(1)) : parseFloat(value);
+ }
+ }
set hass(hass) {
const config = this._config;
const root = this.shadowRoot;
@@ -648,12 +800,12 @@ class FlexTableCard extends HTMLElement {
}
this.#old_rowcount = rowcount;
- if (config.service) {
- // Use service to populate
- const service_config = config.service.split('.');
- let domain = service_config[0];
- let service = service_config[1];
- let service_data = config.service_data;
+ if (config.action || config.service) {
+ // Use action to populate
+ const action_config = config.action ? config.action.split('.') : config.service.split('.');
+ let domain = action_config[0];
+ let action = action_config[1];
+ let action_data = config.action_data || config.service_data;
let entity_list = entities.map((entity) =>
entity.entity_id
@@ -662,9 +814,9 @@ class FlexTableCard extends HTMLElement {
hass.callWS({
"type": "call_service",
"domain": domain,
- "service": service,
- "service_data": service_data,
- "target": { "entity_id": entity_list },
+ "service": action,
+ "service_data": action_data,
+ "target": entity_list.length ? { "entity_id": entity_list } : undefined,
"return_response": true,
}).then(return_response => {
const entities = new Array();
@@ -710,7 +862,9 @@ class FlexTableCard extends HTMLElement {
// finally set card height and insert card
this._setCardSize(this.tbl.rows.length);
// all preprocessing / rendering will be done here inside DataTable::get_rows()
- this._updateContent(root.getElementById('flextbl'), this.tbl.get_rows());
+ let data_rows = this.tbl.get_rows();
+ this._updateContent(root.getElementById('flextbl'), data_rows);
+ if (config.display_footer) this._updateFooter(root.getElementById("flexfoot"), config, data_rows);
}
_setCardSize(num_rows) {
diff --git a/www/community/flex-table-card/flex-table-card.js.gz b/www/community/flex-table-card/flex-table-card.js.gz
index 86a3662e..5606f16c 100644
Binary files a/www/community/flex-table-card/flex-table-card.js.gz and b/www/community/flex-table-card/flex-table-card.js.gz differ
diff --git a/www/community/frigate-hass-card/audio-cf3a75aa.js b/www/community/frigate-hass-card/audio-cf3a75aa.js
new file mode 100644
index 00000000..268044d1
--- /dev/null
+++ b/www/community/frigate-hass-card/audio-cf3a75aa.js
@@ -0,0 +1 @@
+const o=o=>void 0!==o.mozHasAudio?o.mozHasAudio:void 0===o.audioTracks||Boolean(o.audioTracks?.length);export{o as m};
diff --git a/www/community/frigate-hass-card/audio-cf3a75aa.js.gz b/www/community/frigate-hass-card/audio-cf3a75aa.js.gz
new file mode 100644
index 00000000..70294547
Binary files /dev/null and b/www/community/frigate-hass-card/audio-cf3a75aa.js.gz differ
diff --git a/www/community/frigate-hass-card/card-09c4bade.js b/www/community/frigate-hass-card/card-09c4bade.js
new file mode 100644
index 00000000..0d54082a
--- /dev/null
+++ b/www/community/frigate-hass-card/card-09c4bade.js
@@ -0,0 +1,650 @@
+function e(e,t,n,a){var i,r=arguments.length,o=r<3?t:null===a?a=Object.getOwnPropertyDescriptor(t,n):a;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,n,a);else for(var s=e.length-1;s>=0;s--)(i=e[s])&&(o=(r<3?i(o):r>3?i(t,n,o):i(t,n))||o);return r>3&&o&&Object.defineProperty(t,n,o),o}"function"==typeof SuppressedError&&SuppressedError;
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+const t=globalThis,n=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,a=Symbol(),i=new WeakMap;let r=class{constructor(e,t,n){if(this._$cssResult$=!0,n!==a)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o;const t=this.t;if(n&&void 0===e){const n=void 0!==t&&1===t.length;n&&(e=i.get(t)),void 0===e&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),n&&i.set(t,e))}return e}toString(){return this.cssText}};const o=e=>new r("string"==typeof e?e:e+"",void 0,a),s=(e,...t)=>{const n=1===e.length?e[0]:t.reduce(((t,n,a)=>t+(e=>{if(!0===e._$cssResult$)return e.cssText;if("number"==typeof e)return e;throw Error("Value passed to 'css' function must be a 'css' function result: "+e+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+e[a+1]),e[0]);return new r(n,e,a)},c=(e,a)=>{if(n)e.adoptedStyleSheets=a.map((e=>e instanceof CSSStyleSheet?e:e.styleSheet));else for(const n of a){const a=document.createElement("style"),i=t.litNonce;void 0!==i&&a.setAttribute("nonce",i),a.textContent=n.cssText,e.appendChild(a)}},l=n?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t="";for(const n of e.cssRules)t+=n.cssText;return o(t)})(e):e
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */,{is:d,defineProperty:u,getOwnPropertyDescriptor:h,getOwnPropertyNames:p,getOwnPropertySymbols:m,getPrototypeOf:g}=Object,f=globalThis,_=f.trustedTypes,v=_?_.emptyScript:"",y=f.reactiveElementPolyfillSupport,b=(e,t)=>e,w={toAttribute(e,t){switch(t){case Boolean:e=e?v:null;break;case Object:case Array:e=null==e?e:JSON.stringify(e)}return e},fromAttribute(e,t){let n=e;switch(t){case Boolean:n=null!==e;break;case Number:n=null===e?null:Number(e);break;case Object:case Array:try{n=JSON.parse(e)}catch(e){n=null}}return n}},x=(e,t)=>!d(e,t),C={attribute:!0,type:String,converter:w,reflect:!1,hasChanged:x};Symbol.metadata??=Symbol("metadata"),f.litPropertyMetadata??=new WeakMap;class M extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=C){if(t.state&&(t.attribute=!1),this._$Ei(),this.elementProperties.set(e,t),!t.noAccessor){const n=Symbol(),a=this.getPropertyDescriptor(e,n,t);void 0!==a&&u(this.prototype,e,a)}}static getPropertyDescriptor(e,t,n){const{get:a,set:i}=h(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get(){return a?.call(this)},set(t){const r=a?.call(this);i.call(this,t),this.requestUpdate(e,r,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??C}static _$Ei(){if(this.hasOwnProperty(b("elementProperties")))return;const e=g(this);e.finalize(),void 0!==e.l&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(b("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(b("properties"))){const e=this.properties,t=[...p(e),...m(e)];for(const n of t)this.createProperty(n,e[n])}const e=this[Symbol.metadata];if(null!==e){const t=litPropertyMetadata.get(e);if(void 0!==t)for(const[e,n]of t)this.elementProperties.set(e,n)}this._$Eh=new Map;for(const[e,t]of this.elementProperties){const n=this._$Eu(e,t);void 0!==n&&this._$Eh.set(n,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){const t=[];if(Array.isArray(e)){const n=new Set(e.flat(1/0).reverse());for(const e of n)t.unshift(l(e))}else void 0!==e&&t.push(l(e));return t}static _$Eu(e,t){const n=t.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof e?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise((e=>this.enableUpdating=e)),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach((e=>e(this)))}addController(e){(this._$EO??=new Set).add(e),void 0!==this.renderRoot&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){const e=new Map,t=this.constructor.elementProperties;for(const n of t.keys())this.hasOwnProperty(n)&&(e.set(n,this[n]),delete this[n]);e.size>0&&(this._$Ep=e)}createRenderRoot(){const e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return c(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach((e=>e.hostConnected?.()))}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach((e=>e.hostDisconnected?.()))}attributeChangedCallback(e,t,n){this._$AK(e,n)}_$EC(e,t){const n=this.constructor.elementProperties.get(e),a=this.constructor._$Eu(e,n);if(void 0!==a&&!0===n.reflect){const i=(void 0!==n.converter?.toAttribute?n.converter:w).toAttribute(t,n.type);this._$Em=e,null==i?this.removeAttribute(a):this.setAttribute(a,i),this._$Em=null}}_$AK(e,t){const n=this.constructor,a=n._$Eh.get(e);if(void 0!==a&&this._$Em!==a){const e=n.getPropertyOptions(a),i="function"==typeof e.converter?{fromAttribute:e.converter}:void 0!==e.converter?.fromAttribute?e.converter:w;this._$Em=a,this[a]=i.fromAttribute(t,e.type),this._$Em=null}}requestUpdate(e,t,n){if(void 0!==e){if(n??=this.constructor.getPropertyOptions(e),!(n.hasChanged??x)(this[e],t))return;this.P(e,t,n)}!1===this.isUpdatePending&&(this._$ES=this._$ET())}P(e,t,n){this._$AL.has(e)||this._$AL.set(e,t),!0===n.reflect&&this._$Em!==e&&(this._$Ej??=new Set).add(e)}async _$ET(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}const e=this.scheduleUpdate();return null!=e&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[e,t]of this._$Ep)this[e]=t;this._$Ep=void 0}const e=this.constructor.elementProperties;if(e.size>0)for(const[t,n]of e)!0!==n.wrapped||this._$AL.has(t)||void 0===this[t]||this.P(t,this[t],n)}let e=!1;const t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach((e=>e.hostUpdate?.())),this.update(t)):this._$EU()}catch(t){throw e=!1,this._$EU(),t}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach((e=>e.hostUpdated?.())),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EU(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Ej&&=this._$Ej.forEach((e=>this._$EC(e,this[e]))),this._$EU()}updated(e){}firstUpdated(e){}}M.elementStyles=[],M.shadowRootOptions={mode:"open"},M[b("elementProperties")]=new Map,M[b("finalized")]=new Map,y?.({ReactiveElement:M}),(f.reactiveElementVersions??=[]).push("2.0.4");
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+const k=globalThis,S=k.trustedTypes,E=S?S.createPolicy("lit-html",{createHTML:e=>e}):void 0,$="$lit$",A=`lit$${(Math.random()+"").slice(9)}$`,z="?"+A,I=`<${z}>`,T=document,j=()=>T.createComment(""),O=e=>null===e||"object"!=typeof e&&"function"!=typeof e,D=Array.isArray,R="[ \t\n\f\r]",P=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,L=/-->/g,N=/>/g,F=RegExp(`>|${R}(?:([^\\s"'>=/]+)(${R}*=${R}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),V=/'/g,H=/"/g,B=/^(?:script|style|textarea|title)$/i,q=(e=>(t,...n)=>({_$litType$:e,strings:t,values:n}))(1),U=Symbol.for("lit-noChange"),W=Symbol.for("lit-nothing"),Z=new WeakMap,Q=T.createTreeWalker(T,129);function G(e,t){if(!Array.isArray(e)||!e.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==E?E.createHTML(t):t}const Y=(e,t)=>{const n=e.length-1,a=[];let i,r=2===t?"":"")),a]};class K{constructor({strings:e,_$litType$:t},n){let a;this.parts=[];let i=0,r=0;const o=e.length-1,s=this.parts,[c,l]=Y(e,t);if(this.el=K.createElement(c,n),Q.currentNode=this.el.content,2===t){const e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;null!==(a=Q.nextNode())&&s.length${value} `;
+ colspan_remainder = colspan - 1;
+ }
+ else
+ innerHTML += `${cfg.prefix || ""}${value}${cfg.suffix || ""} `;
+ }
+ else {
+ innerHTML += ''
+ }
+ }
+ }
+ });
+
+ innerHTML += ' -1&&e%1==0&&e