diff --git a/setup.py b/setup.py index cb9397985a..d4f435fbad 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import find_packages, setup -VERSION = "0.0.62" +VERSION = "0.0.63" def readme(): diff --git a/tests/test_tuya.py b/tests/test_tuya.py index bf26236acf..462f43f57d 100644 --- a/tests/test_tuya.py +++ b/tests/test_tuya.py @@ -23,12 +23,12 @@ ZONE_STATE, ) from zhaquirks.tuya import Data, TuyaManufClusterAttributes -import zhaquirks.tuya.electric_heating -import zhaquirks.tuya.motion -import zhaquirks.tuya.siren import zhaquirks.tuya.ts0042 import zhaquirks.tuya.ts0043 -import zhaquirks.tuya.valve +import zhaquirks.tuya.ts0601_electric_heating +import zhaquirks.tuya.ts0601_motion +import zhaquirks.tuya.ts0601_siren +import zhaquirks.tuya.ts0601_trv from tests.common import ClusterListener @@ -81,7 +81,7 @@ def utcnow(cls): return cls(1970, 1, 1, 2, 0, 0) -@pytest.mark.parametrize("quirk", (zhaquirks.tuya.motion.TuyaMotion,)) +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_motion.TuyaMotion,)) async def test_motion(zigpy_device_from_quirk, quirk): """Test tuya motion sensor.""" @@ -109,7 +109,9 @@ async def test_motion(zigpy_device_from_quirk, quirk): assert motion_listener.cluster_commands[1][2][0] == OFF -@pytest.mark.parametrize("quirk", (zhaquirks.tuya.singleswitch.TuyaSingleSwitch,)) +@pytest.mark.parametrize( + "quirk", (zhaquirks.tuya.ts0601_singleswitch.TuyaSingleSwitch,) +) async def test_singleswitch_state_report(zigpy_device_from_quirk, quirk): """Test tuya single switch.""" @@ -133,7 +135,9 @@ async def test_singleswitch_state_report(zigpy_device_from_quirk, quirk): assert switch_listener.attribute_updates[1][1] == OFF -@pytest.mark.parametrize("quirk", (zhaquirks.tuya.singleswitch.TuyaSingleSwitch,)) +@pytest.mark.parametrize( + "quirk", (zhaquirks.tuya.ts0601_singleswitch.TuyaSingleSwitch,) +) async def test_singleswitch_requests(zigpy_device_from_quirk, quirk): """Test tuya single switch.""" @@ -256,7 +260,7 @@ async def async_success(*args, **kwargs): ] -@pytest.mark.parametrize("quirk", (zhaquirks.tuya.siren.TuyaSiren,)) +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_siren.TuyaSiren,)) async def test_siren_state_report(zigpy_device_from_quirk, quirk): """Test tuya siren standard state reporting from incoming commands.""" @@ -295,7 +299,7 @@ async def test_siren_state_report(zigpy_device_from_quirk, quirk): assert switch_listener.attribute_updates[1][1] == OFF -@pytest.mark.parametrize("quirk", (zhaquirks.tuya.siren.TuyaSiren,)) +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_siren.TuyaSiren,)) async def test_siren_send_attribute(zigpy_device_from_quirk, quirk): """Test tuya siren outgoing commands.""" @@ -334,7 +338,7 @@ async def async_success(*args, **kwargs): assert status == foundation.Status.UNSUP_CLUSTER_COMMAND -@pytest.mark.parametrize("quirk", (zhaquirks.tuya.valve.SiterwellGS361_Type1,)) +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_trv.SiterwellGS361_Type1,)) async def test_valve_state_report(zigpy_device_from_quirk, quirk): """Test thermostatic valves standard reporting from incoming commands.""" @@ -384,7 +388,7 @@ async def test_valve_state_report(zigpy_device_from_quirk, quirk): assert thermostat_listener.attribute_updates[12][1] == 0x01 -@pytest.mark.parametrize("quirk", (zhaquirks.tuya.valve.SiterwellGS361_Type1,)) +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_trv.SiterwellGS361_Type1,)) async def test_valve_send_attribute(zigpy_device_from_quirk, quirk): """Test thermostatic valve outgoing commands.""" @@ -480,7 +484,7 @@ async def async_success(*args, **kwargs): assert status == foundation.Status.UNSUP_CLUSTER_COMMAND -@pytest.mark.parametrize("quirk", (zhaquirks.tuya.valve.MoesHY368_Type1,)) +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_trv.MoesHY368_Type1,)) async def test_moes(zigpy_device_from_quirk, quirk): """Test thermostatic valve outgoing commands.""" @@ -1005,7 +1009,7 @@ async def async_success(*args, **kwargs): datetime.datetime = origdatetime -@pytest.mark.parametrize("quirk", (zhaquirks.tuya.electric_heating.MoesBHT,)) +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT,)) async def test_eheating_state_report(zigpy_device_from_quirk, quirk): """Test thermostatic valves standard reporting from incoming commands.""" @@ -1027,7 +1031,7 @@ async def test_eheating_state_report(zigpy_device_from_quirk, quirk): assert thermostat_listener.attribute_updates[1][1] == 2100 -@pytest.mark.parametrize("quirk", (zhaquirks.tuya.electric_heating.MoesBHT,)) +@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_electric_heating.MoesBHT,)) async def test_eheat_send_attribute(zigpy_device_from_quirk, quirk): """Test electric thermostat outgoing commands.""" diff --git a/xbee.md b/xbee.md index 1396c28ce4..bca3387cb8 100644 --- a/xbee.md +++ b/xbee.md @@ -13,8 +13,6 @@ If you want the pin to work as input, it must be configured as input with XCTU. If you want the pin to work as output, it is still important to configure the sample reporting in order to know the state of the switch. -Currently digital output requires the coordinator to be XBee as well. THe input should work with any coordinator but this is untested. - ## Analog Input The analog input pins are exposed as sensors. @@ -76,7 +74,6 @@ automation: attribute: 85 value: '{{ trigger.to_state.state }}' ``` -Currently PWM output requires the coordinator to be XBee as well. ## UART @@ -106,3 +103,42 @@ automation: command_type: server args: Assistant ``` + +## Raw AT Commands + +Like with UART, you can send remote AT commands with `zha.issue_zigbee_cluster_command` service. +If the command is unsuccessful, you will get an exception in the logs. If it is successful, the response will be available as `zha_event` event. + +You can check the AT-to-Command_ID mapping in Device info screen. Click on `Manage clusters`, then select XBeeRemoteATRequest cluster, and you would find the mapping in the `Cluster Commands` dropdown list. + +Here is an example for the temperature sensor of an XBee Pro, you can get its value with TP command: +``` +template: + - trigger: + - platform: event + event_type: zha_event + event_data: + device_ieee: 00:13:a2:00:41:98:23:f9 + command: tp_command_response + sensor: + - name: "XBee Temperature" + state: '{{ trigger.event.data.args }}' + unit_of_measurement: "°C" + device_class: temperature + state_class: measurement + +automation: + - alias: Update XBee Temperature + trigger: + platform: time_pattern + minutes: "/5" + action: + service: zha.issue_zigbee_cluster_command + data: + ieee: 00:13:a2:00:41:98:23:f9 + endpoint_id: 230 + command: 0x43 + command_type: server + cluster_type: out + cluster_id: 33 +``` diff --git a/zhaquirks/ikea/__init__.py b/zhaquirks/ikea/__init__.py index d5a3d25d6f..13a8424a4b 100644 --- a/zhaquirks/ikea/__init__.py +++ b/zhaquirks/ikea/__init__.py @@ -6,6 +6,8 @@ from zigpy.zcl.clusters.general import Scenes from zigpy.zcl.clusters.lightlink import LightLink +from zhaquirks import DoublingPowerConfigurationCluster + _LOGGER = logging.getLogger(__name__) IKEA = "IKEA of Sweden" ROTATED = "device_rotated" @@ -46,3 +48,61 @@ class ScenesCluster(CustomCluster, Scenes): 0x0008: ("hold", (t.int16s, t.int8s), False), 0x0009: ("release", (t.int16s,), False), } + + +class PowerConfiguration2AAACluster(DoublingPowerConfigurationCluster): + """Updating Power attributes 2 AAA.""" + + BATTERY_SIZES = 0x0031 + BATTERY_QUANTITY = 0x0033 + BATTERY_RATED_VOLTAGE = 0x0034 + + _CONSTANT_ATTRIBUTES = { + BATTERY_SIZES: 4, + BATTERY_QUANTITY: 2, + BATTERY_RATED_VOLTAGE: 15, + } + + +class PowerConfiguration2CRCluster(DoublingPowerConfigurationCluster): + """Updating Power attributes 2 CR2032.""" + + BATTERY_SIZES = 0x0031 + BATTERY_QUANTITY = 0x0033 + BATTERY_RATED_VOLTAGE = 0x0034 + + _CONSTANT_ATTRIBUTES = { + BATTERY_SIZES: 10, + BATTERY_QUANTITY: 2, + BATTERY_RATED_VOLTAGE: 30, + } + + +class PowerConfiguration1CRCluster(DoublingPowerConfigurationCluster): + """Updating Power attributes 1 CR2032.""" + + BATTERY_SIZES = 0x0031 + BATTERY_QUANTITY = 0x0033 + BATTERY_RATED_VOLTAGE = 0x0034 + + _CONSTANT_ATTRIBUTES = { + BATTERY_SIZES: 10, + BATTERY_QUANTITY: 1, + BATTERY_RATED_VOLTAGE: 30, + } + + +class PowerConfiguration1CRXCluster(DoublingPowerConfigurationCluster): + """Updating Power attributes 1 CR2032 and Zero voltage.""" + + BATTERY_VOLTAGE = 0x0020 + BATTERY_SIZES = 0x0031 + BATTERY_QUANTITY = 0x0033 + BATTERY_RATED_VOLTAGE = 0x0034 + + _CONSTANT_ATTRIBUTES = { + BATTERY_VOLTAGE: 0, + BATTERY_SIZES: 10, + BATTERY_QUANTITY: 1, + BATTERY_RATED_VOLTAGE: 30, + } diff --git a/zhaquirks/ikea/dimmer.py b/zhaquirks/ikea/dimmer.py index 8afe37ae75..f2315638ef 100644 --- a/zhaquirks/ikea/dimmer.py +++ b/zhaquirks/ikea/dimmer.py @@ -13,7 +13,6 @@ ) from zigpy.zcl.clusters.lightlink import LightLink -from zhaquirks import DoublingPowerConfigurationCluster from zhaquirks.const import ( ARGS, CLUSTER_ID, @@ -29,7 +28,7 @@ PROFILE_ID, RIGHT, ) -from zhaquirks.ikea import IKEA, ROTATED +from zhaquirks.ikea import IKEA, ROTATED, PowerConfiguration1CRXCluster class IkeaDimmer(CustomDevice): @@ -71,7 +70,7 @@ class IkeaDimmer(CustomDevice): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - DoublingPowerConfigurationCluster, + PowerConfiguration1CRXCluster, Identify.cluster_id, PollControl.cluster_id, LightLink.cluster_id, diff --git a/zhaquirks/ikea/fivebtnremotezha.py b/zhaquirks/ikea/fivebtnremotezha.py index 1f14d257c7..b1b54481a1 100644 --- a/zhaquirks/ikea/fivebtnremotezha.py +++ b/zhaquirks/ikea/fivebtnremotezha.py @@ -15,7 +15,6 @@ from zigpy.zcl.clusters.homeautomation import Diagnostic from zigpy.zcl.clusters.lightlink import LightLink -from zhaquirks import DoublingPowerConfigurationCluster from zhaquirks.const import ( ARGS, CLUSTER_ID, @@ -43,7 +42,12 @@ SHORT_PRESS, TURN_ON, ) -from zhaquirks.ikea import IKEA, LightLinkCluster, ScenesCluster +from zhaquirks.ikea import ( + IKEA, + LightLinkCluster, + PowerConfiguration1CRCluster, + ScenesCluster, +) IKEA_CLUSTER_ID = 0xFC7C # decimal = 64636 @@ -88,7 +92,7 @@ class IkeaTradfriRemote(CustomDevice): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - DoublingPowerConfigurationCluster, + PowerConfiguration1CRCluster, Identify.cluster_id, PollControl.cluster_id, LightLinkCluster, @@ -210,7 +214,7 @@ class IkeaTradfriRemote2(IkeaTradfriRemote): DEVICE_TYPE: zha.DeviceType.COLOR_SCENE_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - DoublingPowerConfigurationCluster, + PowerConfiguration1CRCluster, Identify.cluster_id, Alarms.cluster_id, LightLinkCluster, diff --git a/zhaquirks/ikea/fourbtnremote.py b/zhaquirks/ikea/fourbtnremote.py index 5c84f72dfc..1735d79dfd 100644 --- a/zhaquirks/ikea/fourbtnremote.py +++ b/zhaquirks/ikea/fourbtnremote.py @@ -12,7 +12,6 @@ ) from zigpy.zcl.clusters.lightlink import LightLink -from zhaquirks import DoublingPowerConfigurationCluster from zhaquirks.const import ( ARGS, CLUSTER_ID, @@ -41,7 +40,7 @@ TURN_OFF, TURN_ON, ) -from zhaquirks.ikea import IKEA, ScenesCluster +from zhaquirks.ikea import IKEA, PowerConfiguration2AAACluster, ScenesCluster WWAH_CLUSTER_ID = 0xFC57 # decimal = 64599 @@ -85,7 +84,7 @@ class IkeaTradfriRemote(CustomDevice): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - DoublingPowerConfigurationCluster, + PowerConfiguration2AAACluster, Identify.cluster_id, PollControl.cluster_id, LightLink.cluster_id, diff --git a/zhaquirks/ikea/motion.py b/zhaquirks/ikea/motion.py index 7326c875be..23225192c9 100644 --- a/zhaquirks/ikea/motion.py +++ b/zhaquirks/ikea/motion.py @@ -12,7 +12,6 @@ ) from zigpy.zcl.clusters.lightlink import LightLink -from zhaquirks import DoublingPowerConfigurationCluster from zhaquirks.const import ( DEVICE_TYPE, ENDPOINTS, @@ -21,7 +20,7 @@ OUTPUT_CLUSTERS, PROFILE_ID, ) -from zhaquirks.ikea import IKEA, LightLinkCluster +from zhaquirks.ikea import IKEA, LightLinkCluster, PowerConfiguration2CRCluster DIAGNOSTICS_CLUSTER_ID = 0x0B05 # decimal = 2821 @@ -65,7 +64,7 @@ class IkeaTradfriMotion(CustomDevice): DEVICE_TYPE: zll.DeviceType.ON_OFF_SENSOR, INPUT_CLUSTERS: [ Basic.cluster_id, - DoublingPowerConfigurationCluster, + PowerConfiguration2CRCluster, Identify.cluster_id, Alarms.cluster_id, DIAGNOSTICS_CLUSTER_ID, diff --git a/zhaquirks/ikea/motionzha.py b/zhaquirks/ikea/motionzha.py index beeeab823a..9cb220c8bc 100644 --- a/zhaquirks/ikea/motionzha.py +++ b/zhaquirks/ikea/motionzha.py @@ -14,7 +14,6 @@ ) from zigpy.zcl.clusters.lightlink import LightLink -from zhaquirks import DoublingPowerConfigurationCluster from zhaquirks.const import ( DEVICE_TYPE, ENDPOINTS, @@ -23,7 +22,7 @@ OUTPUT_CLUSTERS, PROFILE_ID, ) -from zhaquirks.ikea import IKEA, LightLinkCluster +from zhaquirks.ikea import IKEA, LightLinkCluster, PowerConfiguration2CRCluster IKEA_CLUSTER_ID = 0xFC7C # decimal = 64636 DIAGNOSTICS_CLUSTER_ID = 0x0B05 # decimal = 2821 @@ -68,7 +67,7 @@ class IkeaTradfriMotion(CustomDevice): DEVICE_TYPE: zha.DeviceType.ON_OFF_SENSOR, INPUT_CLUSTERS: [ Basic.cluster_id, - DoublingPowerConfigurationCluster, + PowerConfiguration2CRCluster, Identify.cluster_id, Alarms.cluster_id, DIAGNOSTICS_CLUSTER_ID, @@ -127,7 +126,7 @@ class IkeaTradfriMotionE1745(CustomDevice): DEVICE_TYPE: zha.DeviceType.ON_OFF_SENSOR, INPUT_CLUSTERS: [ Basic.cluster_id, - DoublingPowerConfigurationCluster, + PowerConfiguration2CRCluster, Identify.cluster_id, Alarms.cluster_id, PollControl.cluster_id, diff --git a/zhaquirks/ikea/opencloseremote.py b/zhaquirks/ikea/opencloseremote.py index 74247dea9c..c873c86d8a 100644 --- a/zhaquirks/ikea/opencloseremote.py +++ b/zhaquirks/ikea/opencloseremote.py @@ -19,7 +19,6 @@ ) from zigpy.zcl.clusters.lightlink import LightLink -from zhaquirks import DoublingPowerConfigurationCluster from zhaquirks.const import ( ARGS, CLOSE, @@ -36,7 +35,7 @@ SHORT_PRESS, ZHA_SEND_EVENT, ) -from zhaquirks.ikea import IKEA +from zhaquirks.ikea import IKEA, PowerConfiguration1CRCluster COMMAND_CLOSE = "down_close" COMMAND_STOP_OPENING = "stop_opening" @@ -122,7 +121,7 @@ class IkeaTradfriOpenCloseRemote(CustomDevice): DEVICE_TYPE: zha.DeviceType.WINDOW_COVERING_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - DoublingPowerConfigurationCluster, + PowerConfiguration1CRCluster, Identify.cluster_id, Alarms.cluster_id, PollControl.cluster_id, diff --git a/zhaquirks/ikea/shortcutbtn.py b/zhaquirks/ikea/shortcutbtn.py index f59eb488b4..a645841710 100644 --- a/zhaquirks/ikea/shortcutbtn.py +++ b/zhaquirks/ikea/shortcutbtn.py @@ -15,7 +15,6 @@ ) from zigpy.zcl.clusters.lightlink import LightLink -from zhaquirks import DoublingPowerConfigurationCluster from zhaquirks.const import ( ARGS, CLUSTER_ID, @@ -36,7 +35,7 @@ SHORT_PRESS, TURN_ON, ) -from zhaquirks.ikea import IKEA, LightLinkCluster +from zhaquirks.ikea import IKEA, LightLinkCluster, PowerConfiguration1CRCluster class IkeaTradfriShortcutBtn(CustomDevice): @@ -80,7 +79,7 @@ class IkeaTradfriShortcutBtn(CustomDevice): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - DoublingPowerConfigurationCluster, + PowerConfiguration1CRCluster, Identify.cluster_id, Alarms.cluster_id, PollControl.cluster_id, diff --git a/zhaquirks/ikea/symfonisk.py b/zhaquirks/ikea/symfonisk.py index 403c7141ac..99808d4736 100644 --- a/zhaquirks/ikea/symfonisk.py +++ b/zhaquirks/ikea/symfonisk.py @@ -13,7 +13,6 @@ ) from zigpy.zcl.clusters.lightlink import LightLink -from zhaquirks import DoublingPowerConfigurationCluster from zhaquirks.const import ( ARGS, CLUSTER_ID, @@ -35,7 +34,7 @@ TRIPLE_PRESS, TURN_ON, ) -from zhaquirks.ikea import IKEA, ROTATED +from zhaquirks.ikea import IKEA, ROTATED, PowerConfiguration1CRCluster class IkeaSYMFONISK(CustomDevice): @@ -77,7 +76,7 @@ class IkeaSYMFONISK(CustomDevice): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - DoublingPowerConfigurationCluster, + PowerConfiguration1CRCluster, Identify.cluster_id, PollControl.cluster_id, LightLink.cluster_id, diff --git a/zhaquirks/ikea/twobtnremote.py b/zhaquirks/ikea/twobtnremote.py index 58deae50ef..6e81a7f6b3 100644 --- a/zhaquirks/ikea/twobtnremote.py +++ b/zhaquirks/ikea/twobtnremote.py @@ -15,7 +15,6 @@ ) from zigpy.zcl.clusters.lightlink import LightLink -from zhaquirks import DoublingPowerConfigurationCluster from zhaquirks.const import ( ARGS, CLUSTER_ID, @@ -40,7 +39,7 @@ TURN_OFF, TURN_ON, ) -from zhaquirks.ikea import IKEA, LightLinkCluster +from zhaquirks.ikea import IKEA, LightLinkCluster, PowerConfiguration1CRCluster IKEA_CLUSTER_ID = 0xFC7C # decimal = 64636 @@ -87,7 +86,7 @@ class IkeaTradfriRemote2Btn(CustomDevice): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - DoublingPowerConfigurationCluster, + PowerConfiguration1CRCluster, Identify.cluster_id, Alarms.cluster_id, PollControl.cluster_id, diff --git a/zhaquirks/legrand/dimmer.py b/zhaquirks/legrand/dimmer.py index ad8cf7fbfe..7c57f0a846 100644 --- a/zhaquirks/legrand/dimmer.py +++ b/zhaquirks/legrand/dimmer.py @@ -132,6 +132,47 @@ class DimmerWithoutNeutral2(DimmerWithoutNeutral): } +class DimmerWithoutNeutral3(DimmerWithoutNeutral): + """Dimmer switch w/o neutral (at least for firmware 0x2e3).""" + + signature = { + # + MODELS_INFO: [(f" {LEGRAND}", " Dimmer switch w/o neutral")], + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Scenes.cluster_id, + BinaryInput.cluster_id, + MANUFACTURER_SPECIFIC_CLUSTER_ID, + ], + OUTPUT_CLUSTERS: [ + Basic.cluster_id, + MANUFACTURER_SPECIFIC_CLUSTER_ID, + Ota.cluster_id, + OnOff.cluster_id, + Scenes.cluster_id, + ], + }, + 242: { + PROFILE_ID: 41440, + DEVICE_TYPE: 0x0066, + INPUT_CLUSTERS: [0x0021], + OUTPUT_CLUSTERS: [0x0021], + }, + }, + } + + class DimmerWithNeutral(DimmerWithoutNeutral): """Dimmer switch with neutral.""" diff --git a/zhaquirks/mli/__init__.py b/zhaquirks/mli/__init__.py new file mode 100644 index 0000000000..28b84e722b --- /dev/null +++ b/zhaquirks/mli/__init__.py @@ -0,0 +1 @@ +"""Mueller Licht International.""" diff --git a/zhaquirks/mli/tint.py b/zhaquirks/mli/tint.py new file mode 100644 index 0000000000..8c9cf9dc0d --- /dev/null +++ b/zhaquirks/mli/tint.py @@ -0,0 +1,130 @@ +"""Tint remote.""" +import logging + +from zigpy.profiles import zha +from zigpy.quirks import CustomCluster, CustomDevice +from zigpy.zcl import foundation +from zigpy.zcl.clusters.general import ( + Basic, + Groups, + Identify, + LevelControl, + OnOff, + Ota, + Scenes, +) +from zigpy.zcl.clusters.lighting import Color +from zigpy.zcl.clusters.lightlink import LightLink + +from zhaquirks import Bus, LocalDataCluster +from zhaquirks.const import ( + DEVICE_TYPE, + ENDPOINTS, + INPUT_CLUSTERS, + MODELS_INFO, + OUTPUT_CLUSTERS, + PROFILE_ID, +) + +TINT_SCENE_ATTR = 0x4005 + +_LOGGER = logging.getLogger(__name__) + + +class TintRemoteScenesCluster(LocalDataCluster, Scenes): + """Tint remote cluster.""" + + cluster_id = Scenes.cluster_id + + def __init__(self, *args, **kwargs): + """Init.""" + super().__init__(*args, **kwargs) + + self.endpoint.device.scene_bus.add_listener(self) + + def change_scene(self, value): + """Change scene attribute to new value.""" + self._update_attribute(self.attridx["current_scene"], value) + + +class TintRemoteBasicCluster(CustomCluster, Basic): + """Tint remote cluster.""" + + cluster_id = Basic.cluster_id + + def __init__(self, *args, **kwargs): + """Init.""" + super().__init__(*args, **kwargs) + + def handle_cluster_general_request(self, hdr, args, *, dst_addressing=None): + """Send write_attributes value to TintRemoteSceneCluster.""" + if hdr.command_id != foundation.Command.Write_Attributes: + return + + attr = args[0][0] + if attr.attrid != TINT_SCENE_ATTR: + return + + value = attr.value.value + self.endpoint.device.scene_bus.listener_event("change_scene", value) + + +class TintRemote(CustomDevice): + """Tint remote quirk.""" + + def __init__(self, *args, **kwargs): + """Init.""" + self.scene_bus = Bus() + super().__init__(*args, **kwargs) + + signature = { + # endpoint=1 profile=260 device_type=2048 device_version=1 input_clusters=[0, 3, 4096] + # output_clusters=[0, 3, 4, 5, 8, 25, 768, 4096] + MODELS_INFO: [("MLI", "ZBT-Remote-ALL-RGBW")], + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.COLOR_CONTROLLER, + INPUT_CLUSTERS: [ + Basic.cluster_id, # 0 + Identify.cluster_id, # 3 + LightLink.cluster_id, # 4096 + ], + OUTPUT_CLUSTERS: [ + Basic.cluster_id, # 0 + Identify.cluster_id, # 3 + Groups.cluster_id, # 4 + OnOff.cluster_id, # 6 + LevelControl.cluster_id, # 8 + Ota.cluster_id, # 25 + Color.cluster_id, # 768 + LightLink.cluster_id, # 4096 + ], + }, + }, + } + + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.COLOR_CONTROLLER, + INPUT_CLUSTERS: [ + TintRemoteBasicCluster, # 0 + Identify.cluster_id, # 3 + LightLink.cluster_id, # 4096 + ], + OUTPUT_CLUSTERS: [ + Basic.cluster_id, # 0 + Identify.cluster_id, # 3 + Groups.cluster_id, # 4 + TintRemoteScenesCluster, # 5 + OnOff.cluster_id, # 6 + LevelControl.cluster_id, # 8 + Ota.cluster_id, # 25 + Color.cluster_id, # 768 + LightLink.cluster_id, # 4096 + ], + }, + }, + } diff --git a/zhaquirks/salus/sp600.py b/zhaquirks/salus/sp600.py index 41f682595b..bf56ee1d1d 100644 --- a/zhaquirks/salus/sp600.py +++ b/zhaquirks/salus/sp600.py @@ -23,17 +23,6 @@ ) from zhaquirks.salus import COMPUTIME -MODEL = "SP600" - - -class MeteringCluster(CustomCluster, Metering): - """Fix multiplier and divisor.""" - - cluster_id = Metering.cluster_id - MULTIPLIER = 0x0301 - DIVISOR = 0x0302 - _CONSTANT_ATTRIBUTES = {MULTIPLIER: 1, DIVISOR: 1000} - class TemperatureMeasurementCluster(CustomCluster, TemperatureMeasurement): """Temperature cluster that divides value by 2.""" @@ -53,6 +42,10 @@ class SP600(CustomDevice): signature = { ENDPOINTS: { + # 9: { PROFILE_ID: 0x0104, DEVICE_TYPE: zha.DeviceType.SMART_PLUG, @@ -70,7 +63,7 @@ class SP600(CustomDevice): OUTPUT_CLUSTERS: [Ota.cluster_id], } }, - MODELS_INFO: [(COMPUTIME, MODEL)], + MODELS_INFO: [(COMPUTIME, "SP600")], } replacement = { @@ -86,7 +79,58 @@ class SP600(CustomDevice): Scenes.cluster_id, OnOff.cluster_id, TemperatureMeasurementCluster, - MeteringCluster, + Metering.cluster_id, + 0xFC01, + ], + OUTPUT_CLUSTERS: [Ota.cluster_id], + } + }, + } + + +class SPE600(CustomDevice): + """Salus SPE600 smart plug.""" + + signature = { + ENDPOINTS: { + # + 9: { + PROFILE_ID: 0x0104, + DEVICE_TYPE: zha.DeviceType.SMART_PLUG, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + TemperatureMeasurement.cluster_id, + Metering.cluster_id, + 0xFC01, + ], + OUTPUT_CLUSTERS: [Ota.cluster_id], + } + }, + MODELS_INFO: [(COMPUTIME, "SPE600")], + } + + replacement = { + ENDPOINTS: { + 9: { + PROFILE_ID: 0x0104, + DEVICE_TYPE: zha.DeviceType.SMART_PLUG, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + TemperatureMeasurementCluster, + Metering.cluster_id, 0xFC01, ], OUTPUT_CLUSTERS: [Ota.cluster_id], diff --git a/zhaquirks/tuya/__init__.py b/zhaquirks/tuya/__init__.py index a29baa8841..94f1c6ff4e 100644 --- a/zhaquirks/tuya/__init__.py +++ b/zhaquirks/tuya/__init__.py @@ -10,6 +10,7 @@ from zigpy.zcl.clusters.closures import WindowCovering from zigpy.zcl.clusters.general import LevelControl, OnOff, PowerConfiguration from zigpy.zcl.clusters.hvac import Thermostat, UserInterface +from zigpy.zcl.clusters.smartenergy import Metering from zhaquirks import Bus, EventableCluster, LocalDataCluster from zhaquirks.const import DOUBLE_PRESS, LONG_PRESS, SHORT_PRESS, ZHA_SEND_EVENT @@ -93,6 +94,8 @@ "_TZE200_zpzndjez": {0x0000: 0x0000, 0x0001: 0x0002, 0x0002: 0x0001}, "_TZE200_cowvfni3": {0x0000: 0x0002, 0x0001: 0x0000, 0x0002: 0x0001}, "_TYST11_wmcdj3aq": {0x0000: 0x0000, 0x0001: 0x0002, 0x0002: 0x0001}, + "_TZE200_yenbr4om": {0x0000: 0x0000, 0x0001: 0x0002, 0x0002: 0x0001}, + "_TZE200_5sbebbzs": {0x0000: 0x0000, 0x0001: 0x0002, 0x0002: 0x0001}, } # --------------------------------------------------------- # TUYA Switch Custom Values @@ -731,6 +734,29 @@ def handle_cluster_request( ) +class TZBPowerOnState(t.enum8): + """Tuya power on state enum.""" + + Off = 0x00 + On = 0x01 + LastState = 0x02 + + +class TuyaZBOnOffRestorePowerCluster(CustomCluster, OnOff): + """Tuya on off Zigbee cluster with restore state.""" + + attributes = OnOff.attributes.copy() + attributes.update({0x8002: ("power_on_state", TZBPowerOnState)}) + + +class TuyaZBMeteringCluster(CustomCluster, Metering): + """Divides the kWh for tuya.""" + + MULTIPLIER = 0x0301 + DIVISOR = 0x0302 + _CONSTANT_ATTRIBUTES = {MULTIPLIER: 1, DIVISOR: 100} + + # Tuya Window Cover Implementation class TuyaManufacturerWindowCover(TuyaManufCluster): """Manufacturer Specific Cluster for cover device.""" @@ -746,7 +772,7 @@ def handle_cluster_request( ) -> None: """Handle cluster request.""" """Tuya Specific Cluster Commands""" - if hdr.command_id == TUYA_SET_DATA_RESPONSE: + if hdr.command_id in (TUYA_GET_DATA, TUYA_SET_DATA_RESPONSE): tuya_payload = args[0] _LOGGER.debug( "%s Received Attribute Report. Command is 0x%04x, Tuya Paylod values" @@ -766,6 +792,15 @@ def handle_cluster_request( ATTR_COVER_POSITION, tuya_payload.data[4], ) + elif ( + tuya_payload.command_id + == TUYA_DP_TYPE_VALUE + TUYA_DP_ID_PERCENT_CONTROL + ): + self.endpoint.device.cover_bus.listener_event( + COVER_EVENT, + ATTR_COVER_POSITION, + tuya_payload.data[4], + ) elif ( tuya_payload.command_id == TUYA_DP_TYPE_ENUM + TUYA_DP_ID_DIRECTION_CHANGE @@ -810,7 +845,7 @@ def handle_cluster_request( ) elif hdr.command_id == TUYA_SET_TIME: """Time event call super""" - super().handle_cluster_request(self, hdr, args, dst_addressing) + super().handle_cluster_request(hdr, args, dst_addressing=dst_addressing) else: _LOGGER.debug( "%s Received Attribute Report - Unknown Command. Self [%s], Header [%s], Tuya Paylod [%s]", diff --git a/zhaquirks/tuya/air/ts0601_air_quality.py b/zhaquirks/tuya/air/ts0601_air_quality.py index cdce21d825..30dd15564b 100644 --- a/zhaquirks/tuya/air/ts0601_air_quality.py +++ b/zhaquirks/tuya/air/ts0601_air_quality.py @@ -2,7 +2,7 @@ from zigpy.profiles import zha from zigpy.quirks import CustomDevice -from zigpy.zcl.clusters.general import Basic, Groups, Ota, Scenes, Time +from zigpy.zcl.clusters.general import Basic, GreenPowerProxy, Groups, Ota, Scenes, Time from zhaquirks.const import ( DEVICE_TYPE, @@ -34,7 +34,6 @@ class TuyaCO2Sensor(CustomDevice): MODELS_INFO: [ ("_TZE200_8ygsuhe1", "TS0601"), ("_TZE200_yvx5lh6k", "TS0601"), - ("_TZE200_ryfmq5rl", "TS0601"), ], ENDPOINTS: { 1: { @@ -70,3 +69,66 @@ class TuyaCO2Sensor(CustomDevice): } } } + + +class TuyaCO2SensorGPP(CustomDevice): + """Tuya Air quality device with GPP.""" + + signature = { + # NodeDescriptor(logical_type=, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=, mac_capability_flags=, manufacturer_code=4098, maximum_buffer_size=82, maximum_incoming_transfer_size=82, server_mask=11264, maximum_outgoing_transfer_size=82, descriptor_capability_field=, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=False, *is_full_function_device=True, *is_mains_powered=True, *is_receiver_on_when_idle=True, *is_router=True, *is_security_capable=False)] + # device_version=1 + # SizePrefixedSimpleDescriptor(endpoint=1, profile=260, device_type=81, device_version=1, + # input_clusters=[0, 4, 5, 61184], + # output_clusters=[25, 10]) + MODELS_INFO: [ + ("_TZE200_ryfmq5rl", "TS0601"), + ], + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.SMART_PLUG, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + TuyaCO2ManufCluster.cluster_id, + ], + OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], + }, + 242: { + # + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.ON_OFF_PLUG_IN_UNIT, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + Metering.cluster_id, + ElectricalMeasurement.cluster_id, + TuyaClusterE000.cluster_id, + TuyaClusterE001.cluster_id, + ], + OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], + }, + # + 242: { + PROFILE_ID: 41440, + DEVICE_TYPE: 97, + INPUT_CLUSTERS: [], + OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], + }, + }, + } + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.ON_OFF_PLUG_IN_UNIT, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + TuyaZBOnOffRestorePowerCluster, + TuyaZBMeteringCluster, + TuyaZBElectricalMeasurement, + TuyaClusterE000, + TuyaClusterE001, + ], + OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], + }, + }, + } diff --git a/zhaquirks/tuya/plug.py b/zhaquirks/tuya/ts0121_plug.py similarity index 69% rename from zhaquirks/tuya/plug.py rename to zhaquirks/tuya/ts0121_plug.py index 39f529f368..4e83b02f8b 100644 --- a/zhaquirks/tuya/plug.py +++ b/zhaquirks/tuya/ts0121_plug.py @@ -1,7 +1,6 @@ -"""Tuya plug.""" +"""Tuya TS0121 plug.""" from zigpy.profiles import zha -from zigpy.quirks import CustomCluster, CustomDevice -import zigpy.types as t +from zigpy.quirks import CustomDevice from zigpy.zcl.clusters.general import Basic, Groups, OnOff, Ota, Scenes, Time from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement from zigpy.zcl.clusters.smartenergy import Metering @@ -10,35 +9,18 @@ DEVICE_TYPE, ENDPOINTS, INPUT_CLUSTERS, - MODELS_INFO, + MODEL, OUTPUT_CLUSTERS, PROFILE_ID, ) - - -class PowerOnState(t.enum8): - """Tuya power on state enum.""" - - Off = 0x00 - On = 0x01 - LastState = 0x02 - - -class OnOffRestorePowerCluster(CustomCluster, OnOff): - """Tuya on off cluster with restore state.""" - - attributes = OnOff.attributes.copy() - attributes.update({0x8002: ("power_on_state", PowerOnState)}) +from zhaquirks.tuya import TuyaZBMeteringCluster, TuyaZBOnOffRestorePowerCluster class Plug(CustomDevice): - """Tuya plug with restore power state support.""" + """Tuya TS0121 plug with restore tuya power state support.""" signature = { - MODELS_INFO: [ - ("_TZ3000_g5xawfcq", "TS0121"), - ("_TZ3000_3ooaz3ng", "TS0121"), - ], + MODEL: "TS0121", ENDPOINTS: { # MODELS_INFO: [ + ("_TZE200_jeaxp72v", "TS0601"), ("_TZE200_kfvq6avy", "TS0601"), ("_TZE200_zivfvd7h", "TS0601"), - ("_TZE200_ps5v5jor", "TS0601"), ("_TZE200_hhrtiq0x", "TS0601"), + ("_TZE200_ps5v5jor", "TS0601"), + ("_TZE200_owwdxjbx", "TS0601"), + ("_TZE200_8daqwrsj", "TS0601"), ], ENDPOINTS: { 1: { @@ -984,7 +1059,6 @@ def __init__(self, *args, **kwargs): ("_TZE200_ckud7u2l", "TS0601"), ("_TZE200_ywdxldoj", "TS0601"), ("_TZE200_cwnjrr72", "TS0601"), - ("_TZE200_b6wax7g0", "TS0601"), ], ENDPOINTS: { 1: { @@ -1022,6 +1096,57 @@ def __init__(self, *args, **kwargs): } +# for Moes TRV _TZE200_b6wax7g0 +class MoesHY368_Type1new(TuyaThermostat): + """MoesHY368 Thermostatic radiator valve.""" + + def __init__(self, *args, **kwargs): + """Init device.""" + self.window_detection_bus = Bus() + super().__init__(*args, **kwargs) + + signature = { + # endpoint=1 profile=260 device_type=81 device_version=0 input_clusters=[0, 4, 5, 61184] + # output_clusters=[10, 25]> + MODELS_INFO: [ + ("_TZE200_b6wax7g0", "TS0601"), + ], + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.SMART_PLUG, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + TuyaManufClusterAttributes.cluster_id, + ], + OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], + } + }, + } + + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.THERMOSTAT, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + MoesManufClusterNew, + MoesThermostatNew, + MoesUserInterface, + MoesWindowDetection, + TuyaPowerConfigurationCluster, + ], + OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], + } + } + } + + class MoesHY368_Type2(TuyaThermostat): """MoesHY368 Thermostatic radiator valve (2nd cluster signature).""" @@ -1072,6 +1197,7 @@ class ZonnsmartTV01_ZG(TuyaThermostat): # output_clusters=[10, 25]> MODELS_INFO: [ ("_TZE200_e9ba97vf", "TS0601"), + ("_TZE200_husqqvux", "TS0601"), ], ENDPOINTS: { 1: { diff --git a/zhaquirks/tuya/thermostat_88teujp.py b/zhaquirks/tuya/ts0601_trv_sas.py similarity index 97% rename from zhaquirks/tuya/thermostat_88teujp.py rename to zhaquirks/tuya/ts0601_trv_sas.py index bbad4a6fa9..e3afb83da1 100644 --- a/zhaquirks/tuya/thermostat_88teujp.py +++ b/zhaquirks/tuya/ts0601_trv_sas.py @@ -167,10 +167,12 @@ class Thermostat_TYST11_c88teujp(TuyaThermostat): # input_clusters=[0, 3] # output_clusters=[3, 25]> MODELS_INFO: [ - ("_TYST11_c88teujp", "88teujp"), ("_TYST11_KGbxAXL2", "GbxAXL2"), - ("_TYST11_zuhszj9s", "uhszj9s"), + ("_TYST11_c88teujp", "88teujp"), + ("_TYST11_azqp6ssj", "zqp6ssj"), ("_TYST11_yw7cahqs", "w7cahqs"), + ("_TYST11_9gvruqf5", "gvruqf5"), + ("_TYST11_zuhszj9s", "uhszj9s"), ], ENDPOINTS: { 1: { @@ -221,6 +223,8 @@ class Thermostat_TZE200_c88teujp(TuyaThermostat): ("_TZE200_c88teujp", "TS0601"), ("_TZE200_azqp6ssj", "TS0601"), ("_TZE200_yw7cahqs", "TS0601"), + ("_TZE200_9gvruqf5", "TS0601"), + ("_TZE200_zuhszj9s", "TS0601"), ], ENDPOINTS: { 1: { diff --git a/zhaquirks/xbee/__init__.py b/zhaquirks/xbee/__init__.py index b91452bbb6..ace64c517e 100644 --- a/zhaquirks/xbee/__init__.py +++ b/zhaquirks/xbee/__init__.py @@ -16,6 +16,8 @@ the xbee stays alive in Home Assistant. """ +import asyncio +import enum import logging from typing import Any, List, Optional, Union @@ -25,6 +27,7 @@ from zigpy.zcl.clusters.general import ( AnalogInput, AnalogOutput, + Basic, BinaryInput, LevelControl, OnOff, @@ -41,15 +44,204 @@ DIO_PIN_LOW = 0x04 ON_OFF_CMD = 0x0000 XBEE_DATA_CLUSTER = 0x11 -XBEE_DST_ENDPOINT = 0xE8 +XBEE_AT_REQUEST_CLUSTER = 0x21 +XBEE_AT_RESPONSE_CLUSTER = 0xA1 +XBEE_AT_ENDPOINT = 0xE6 +XBEE_DATA_ENDPOINT = 0xE8 XBEE_IO_CLUSTER = 0x92 XBEE_PROFILE_ID = 0xC105 -XBEE_REMOTE_AT = 0x17 -XBEE_SRC_ENDPOINT = 0xE8 ATTR_ON_OFF = 0x0000 ATTR_PRESENT_VALUE = 0x0055 PIN_ANALOG_OUTPUT = 2 +REMOTE_AT_COMMAND_TIMEOUT = 30 + + +class int_t(int): + """Signed int type.""" + + _signed = True + + def serialize(self): + """Serialize int_t.""" + return self.to_bytes(self._size, "big", signed=self._signed) + + @classmethod + def deserialize(cls, data): + """Deserialize int_t.""" + # Work around https://bugs.python.org/issue23640 + r = cls(int.from_bytes(data[: cls._size], "big", signed=cls._signed)) + data = data[cls._size :] + return r, data + + +class uint_t(int_t): + """Unsigned int type.""" + + _signed = False + + +class uint8_t(uint_t): + """Unsigned int 8 bit type.""" + + _size = 1 + + +class int16_t(int_t): + """Signed int 16 bit type.""" + + _size = 2 + + +class uint16_t(uint_t): + """Unsigned int 16 bit type.""" + + _size = 2 + + +class uint32_t(uint_t): + """Unsigned int 32 bit type.""" + + _size = 4 + + +class uint64_t(uint_t): + """Unsigned int 64 bit type.""" + + _size = 8 + + +class Bytes(bytes): + """Bytes serializable class.""" + + def serialize(self): + """Serialize Bytes.""" + return self + + @classmethod + def deserialize(cls, data): + """Deserialize Bytes.""" + return cls(data), b"" + + +# https://github.com/zigpy/zigpy-xbee/blob/dev/zigpy_xbee/api.py +AT_COMMANDS = { + # Addressing commands + "DH": uint32_t, + "DL": uint32_t, + "MY": uint16_t, + "MP": uint16_t, + "NC": uint32_t, # 0 - MAX_CHILDREN. + "SH": uint32_t, + "SL": uint32_t, + "NI": Bytes, # 20 byte printable ascii string + # "SE": uint8_t, + # "DE": uint8_t, + # "CI": uint16_t, + "TO": uint8_t, + "NP": uint16_t, + "DD": uint32_t, + "CR": uint8_t, # 0 - 0x3F + # Networking commands + "CH": uint8_t, # 0x0B - 0x1A + "DA": None, # no param + # "ID": uint64_t, + "OP": uint64_t, + "NH": uint8_t, + "BH": uint8_t, # 0 - 0x1E + "OI": uint16_t, + "NT": uint8_t, # 0x20 - 0xFF + "NO": uint8_t, # bitfield, 0 - 3 + "SC": uint16_t, # 1 - 0xFFFF + "SD": uint8_t, # 0 - 7 + # "ZS": uint8_t, # 0 - 2 + "NJ": uint8_t, + "JV": t.Bool, + "NW": uint16_t, # 0 - 0x64FF + "JN": t.Bool, + "AR": uint8_t, + "DJ": t.Bool, # WTF, docs + "II": uint16_t, + # Security commands + # "EE": t.Bool, + # "EO": uint8_t, + # "NK": Bytes, # 128-bit value + # "KY": Bytes, # 128-bit value + # RF interfacing commands + "PL": uint8_t, # 0 - 4 (basically an Enum) + "PM": t.Bool, + "DB": uint8_t, + "PP": uint8_t, # RO + "AP": uint8_t, # 1-2 (an Enum) + "AO": uint8_t, # 0 - 3 (an Enum) + "BD": uint8_t, # 0 - 7 (an Enum) + "NB": uint8_t, # 0 - 3 (an Enum) + "SB": uint8_t, # 0 - 1 (an Enum) + "RO": uint8_t, + "D6": uint8_t, # 0 - 5 (an Enum) + "D7": uint8_t, # 0 - 7 (an Enum) + "P3": uint8_t, # 0 - 5 (an Enum) + "P4": uint8_t, # 0 - 5 (an Enum) + # I/O commands + "IR": uint16_t, + "IC": uint16_t, + "D0": uint8_t, # 0 - 5 (an Enum) + "D1": uint8_t, # 0 - 5 (an Enum) + "D2": uint8_t, # 0 - 5 (an Enum) + "D3": uint8_t, # 0 - 5 (an Enum) + "D4": uint8_t, # 0 - 5 (an Enum) + "D5": uint8_t, # 0 - 5 (an Enum) + "D8": uint8_t, # 0 - 5 (an Enum) + "D9": uint8_t, # 0 - 5 (an Enum) + "P0": uint8_t, # 0 - 5 (an Enum) + "P1": uint8_t, # 0 - 5 (an Enum) + "P2": uint8_t, # 0 - 5 (an Enum) + "P5": uint8_t, # 0 - 5 (an Enum) + "P6": uint8_t, # 0 - 5 (an Enum) + "P7": uint8_t, # 0 - 5 (an Enum) + "P8": uint8_t, # 0 - 5 (an Enum) + "P9": uint8_t, # 0 - 5 (an Enum) + "LT": uint8_t, + "PR": uint16_t, + "RP": uint8_t, + "%V": uint16_t, # read only + "V+": uint16_t, + "TP": int16_t, + "M0": uint16_t, # 0 - 0x3FF + "M1": uint16_t, # 0 - 0x3FF + # Diagnostics commands + "VR": uint16_t, + "HV": uint16_t, + "AI": uint8_t, + # AT command options + "CT": uint16_t, # 2 - 0x028F + "CN": None, + "GT": uint16_t, + "CC": uint8_t, + # Sleep commands + "SM": uint8_t, + "SN": uint16_t, + "SP": uint16_t, + "ST": uint16_t, + "SO": uint8_t, + "WH": uint16_t, + "SI": None, + "PO": uint16_t, # 0 - 0x3E8 + # Execution commands + "AC": None, + "WR": None, + "RE": None, + "FR": None, + "NR": t.Bool, + "SI": None, + "CB": uint8_t, + "DN": Bytes, # "up to 20-Byte printable ASCII string" + "IS": None, + "1S": None, + "AS": None, + # Stuff I've guessed + # "CE": uint8_t, +} # 4 AO lines # 10 digital @@ -76,6 +268,16 @@ } +class XBeeBasic(LocalDataCluster, Basic): + """XBee Basic Cluster.""" + + def __init__(self, endpoint, is_server=True): + """Set default values and store them in cache.""" + super().__init__(endpoint, is_server) + self._update_attribute(0x0000, 0x02) # ZCLVersion + self._update_attribute(0x0007, self.PowerSource.Unknown) # PowerSource + + class XBeeOnOff(LocalDataCluster, OnOff): """XBee on/off cluster.""" @@ -90,10 +292,9 @@ async def command( pin_cmd = DIO_PIN_LOW else: pin_cmd = DIO_PIN_HIGH - result = await self._endpoint.device.remote_at(pin_name, pin_cmd) - if result == foundation.Status.SUCCESS: - self._update_attribute(ATTR_ON_OFF, command_id) - return 0, result + await self._endpoint.device.remote_at(pin_name, pin_cmd) + self._update_attribute(ATTR_ON_OFF, command_id) + return 0, foundation.Status.SUCCESS class XBeeAnalogInput(LocalDataCluster, AnalogInput): @@ -105,7 +306,7 @@ class XBeeAnalogInput(LocalDataCluster, AnalogInput): class XBeePWM(LocalDataCluster, AnalogOutput): """XBee PWM Cluster.""" - ep_id_2_pwm = {0xDA: "M0", 0xDB: "M1"} + _ep_id_2_pwm = {0xDA: "M0", 0xDB: "M1"} def __init__(self, endpoint, is_server=True): """Set known attributes and store them in cache.""" @@ -118,41 +319,274 @@ def __init__(self, endpoint, is_server=True): async def write_attributes(self, attributes, manufacturer=None): """Intercept present_value attribute write.""" + attr_id = None if ATTR_PRESENT_VALUE in attributes: - duty_cycle = int(round(float(attributes.pop(ATTR_PRESENT_VALUE)))) - at_command = self.ep_id_2_pwm.get(self._endpoint.endpoint_id) - result = await self._endpoint.device.remote_at(at_command, duty_cycle) - if result != foundation.Status.SUCCESS: - return result + attr_id = ATTR_PRESENT_VALUE + elif "present_value" in attributes: + attr_id = "present_value" + if attr_id: + duty_cycle = int(round(float(attributes[attr_id]))) + at_command = self._ep_id_2_pwm.get(self._endpoint.endpoint_id) + await self._endpoint.device.remote_at(at_command, duty_cycle) at_command = ENDPOINT_TO_AT.get(self._endpoint.endpoint_id) - result = await self._endpoint.device.remote_at( - at_command, PIN_ANALOG_OUTPUT - ) - if result != foundation.Status.SUCCESS or not attributes: - return result + await self._endpoint.device.remote_at(at_command, PIN_ANALOG_OUTPUT) return await super().write_attributes(attributes, manufacturer) async def read_attributes_raw(self, attributes, manufacturer=None): """Intercept present_value attribute read.""" - if ATTR_PRESENT_VALUE in attributes: - at_command = self.ep_id_2_pwm.get(self._endpoint.endpoint_id) + if ATTR_PRESENT_VALUE in attributes or "present_value" in attributes: + at_command = self._ep_id_2_pwm.get(self._endpoint.endpoint_id) result = await self._endpoint.device.remote_at(at_command) self._update_attribute(ATTR_PRESENT_VALUE, float(result)) return await super().read_attributes_raw(attributes, manufacturer) +class XBeeRemoteATRequest(LocalDataCluster): + """Remote AT Command Request Cluster.""" + + cluster_id = XBEE_AT_REQUEST_CLUSTER + server_commands = {} + + _seq: int = 1 + + class EUI64(t.EUI64): + """EUI64 serializable class.""" + + @classmethod + def deserialize(cls, data): + """Deserialize EUI64.""" + r, data = super().deserialize(data) + return cls(r[::-1]), data + + def serialize(self): + """Serialize EUI64.""" + assert self._length == len(self) + return super().serialize()[::-1] + + class NWK(int): + """Network address serializable class.""" + + _signed = False + _size = 2 + + def serialize(self): + """Serialize NWK.""" + return self.to_bytes(self._size, "big", signed=self._signed) + + @classmethod + def deserialize(cls, data): + """Deserialize NWK.""" + r = cls(int.from_bytes(data[: cls._size], "big", signed=cls._signed)) + data = data[cls._size :] + return r, data + + def __init__(self, *args, **kwargs): + """Generate client_commands from AT_COMMANDS.""" + super().__init__(*args, **kwargs) + self.client_commands = { + k: (v[0], (v[1],), None) + for k, v in zip(range(1, len(AT_COMMANDS) + 1), AT_COMMANDS.items()) + } + + def _save_at_request(self, frame_id, future): + self._endpoint.in_clusters[XBEE_AT_RESPONSE_CLUSTER].save_at_request( + frame_id, future + ) + + def remote_at_command(self, cmd_name, *args, apply_changes=True, **kwargs): + """Execute a Remote AT Command and Return Response.""" + if hasattr(self._endpoint.device.application, "remote_at_command"): + return self._endpoint.device.application.remote_at_command( + self._endpoint.device.nwk, + cmd_name, + *args, + apply_changes=apply_changes, + encryption=False, + **kwargs, + ) + _LOGGER.debug("Remote AT%s command: %s", cmd_name, args) + options = uint8_t(0) + if apply_changes: + options |= 0x02 + return self._remote_at_command(options, cmd_name, *args) + + async def _remote_at_command(self, options, name, *args): + _LOGGER.debug("Remote AT command: %s %s", name, args) + data = t.serialize(args, (AT_COMMANDS[name],)) + try: + return await asyncio.wait_for( + await self._command(options, name.encode("ascii"), data, *args), + timeout=REMOTE_AT_COMMAND_TIMEOUT, + ) + except asyncio.TimeoutError: + _LOGGER.warning("No response to %s command", name) + raise + + async def _command(self, options, command, data, *args): + _LOGGER.debug("Command %s %s", command, data) + frame_id = self._seq + self._seq = (self._seq % 255) + 1 + schema = ( + uint8_t, + uint8_t, + uint8_t, + uint8_t, + self.EUI64, + self.NWK, + Bytes, + Bytes, + ) + data = t.serialize( + ( + 0x32, + 0x00, + options, + frame_id, + self._endpoint.device.application.ieee, + self._endpoint.device.application.nwk, + command, + data, + ), + schema, + ) + result = await self._endpoint.device.application.request( + self._endpoint.device, + XBEE_PROFILE_ID, + XBEE_AT_REQUEST_CLUSTER, + XBEE_AT_ENDPOINT, + XBEE_AT_ENDPOINT, + self._endpoint.device.application.get_sequence(), + data, + expect_reply=False, + ) + + future = asyncio.Future() + self._save_at_request(frame_id, future) + if result[0] != foundation.Status.SUCCESS: + future.set_exception(RuntimeError("AT Command request: {}".format(result))) + return future + + async def command( + self, command_id, *args, manufacturer=None, expect_reply=False, tsn=None + ): + """Handle AT request.""" + command = self.client_commands[command_id][0] + try: + value = args[0] + if isinstance(value, dict): + value = None + except IndexError: + value = None + + if value: + value = await self.remote_at_command(command, value) + else: + value = await self.remote_at_command(command) + + tsn = self._endpoint.device.application.get_sequence() + hdr = foundation.ZCLHeader.cluster(tsn, command_id) + self._endpoint.device.endpoints[232].out_clusters[ + LevelControl.cluster_id + ].handle_cluster_request(hdr, value) + return 0, foundation.Status.SUCCESS + + +class XBeeRemoteATResponse(LocalDataCluster): + """Remote AT Command Response Cluster.""" + + cluster_id = XBEE_AT_RESPONSE_CLUSTER + + _awaiting = {} + + class ATCommandResult(enum.IntEnum): + """AT command results.""" + + OK = 0 + ERROR = 1 + INVALID_COMMAND = 2 + INVALID_PARAMETER = 3 + TX_FAILURE = 4 + + class ATCommand(Bytes): + """AT command serializable class.""" + + @classmethod + def deserialize(cls, data): + """Deserialize ATCommand.""" + return cls(data[:2]), data[2:] + + def save_at_request(self, frame_id, future): + """Save pending request.""" + self._awaiting[frame_id] = (future,) + + def handle_cluster_request( + self, + hdr: foundation.ZCLHeader, + args: List[Any], + *, + dst_addressing: Optional[ + Union[t.Addressing.Group, t.Addressing.IEEE, t.Addressing.NWK] + ] = None, + ): + """Handle AT response.""" + if hdr.command_id == DATA_IN_CMD: + frame_id = args[0] + cmd = args[1] + status = args[2] + value = args[3] + _LOGGER.debug( + "Remote AT command response: %s", (frame_id, cmd, status, value) + ) + (fut,) = self._awaiting.pop(frame_id) + try: + status = self.ATCommandResult(status) + except ValueError: + status = self.ATCommandResult.ERROR + + if status: + fut.set_exception( + RuntimeError("AT Command response: {}".format(status.name)) + ) + return + + response_type = AT_COMMANDS[cmd.decode("ascii")] + if response_type is None or len(value) == 0: + fut.set_result(None) + return + + response, remains = response_type.deserialize(value) + fut.set_result(response) + + else: + super().handle_cluster_request(hdr, args) + + client_commands = {} + server_commands = { + 0x0000: ( + "remote_at_response", + ( + uint8_t, + ATCommand, + uint8_t, + Bytes, + ), + None, + ) + } + + class XBeeCommon(CustomDevice): """XBee common class.""" def remote_at(self, command, *args, **kwargs): """Remote at command.""" - if hasattr(self._application, "remote_at_command"): - return self._application.remote_at_command( - self.nwk, command, *args, apply_changes=True, encryption=False, **kwargs - ) - _LOGGER.warning("Remote At Command not supported by this coordinator") + return ( + self.endpoints[230] + .out_clusters[XBEE_AT_REQUEST_CLUSTER] + .remote_at_command(command, *args, apply_changes=True, **kwargs) + ) def deserialize(self, endpoint_id, cluster_id, data): """Deserialize.""" @@ -288,7 +722,15 @@ class EventRelayCluster(EventableCluster, LocalDataCluster, LevelControl): attributes = {} client_commands = {} - server_commands = {0x0000: ("receive_data", (str,), None)} + + def __init__(self, *args, **kwargs): + """Generate server_commands from AT_COMMANDS.""" + super().__init__(*args, **kwargs) + self.server_commands = { + k: (v[0].lower() + "_command_response", (str,), None) + for k, v in zip(range(1, len(AT_COMMANDS) + 1), AT_COMMANDS.items()) + } + self.server_commands[0x0000] = ("receive_data", (str,), None) class SerialDataCluster(LocalDataCluster): """Serial Data Cluster for the XBee.""" @@ -318,8 +760,8 @@ def command( self._endpoint.device, XBEE_PROFILE_ID, XBEE_DATA_CLUSTER, - XBEE_SRC_ENDPOINT, - XBEE_DST_ENDPOINT, + XBEE_DATA_ENDPOINT, + XBEE_DATA_ENDPOINT, self._endpoint.device.application.get_sequence(), data, expect_reply=False, @@ -348,10 +790,14 @@ def handle_cluster_request( replacement = { ENDPOINTS: { + 230: { + INPUT_CLUSTERS: [XBeeRemoteATResponse], + OUTPUT_CLUSTERS: [XBeeRemoteATRequest], + }, 232: { - INPUT_CLUSTERS: [DigitalIOCluster, SerialDataCluster], + INPUT_CLUSTERS: [DigitalIOCluster, SerialDataCluster, XBeeBasic], OUTPUT_CLUSTERS: [SerialDataCluster, EventRelayCluster], - } + }, }, "manufacturer": "Digi", }