diff --git a/README.md b/README.md index 87058c7d8..11e5a2917 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,12 @@ Zigbee Home Automation integration with zigpy allows you to connect one of many off-the-shelf Zigbee adapters using one of the available Zigbee radio library modules compatible with zigpy to control Zigbee based devices. There is currently support for controlling Zigbee device types such as binary sensors (e.g., motion and door sensors), sensors (e.g., temperature sensors), lightbulbs, switches, and fans. A working implementation of zigbe exist in **[Home Assistant](https://www.home-assistant.io)** (Python based open source home automation software) as part of its **[ZHA component](https://www.home-assistant.io/components/zha/)** -zigpy works with separate radio libraries which can each interface with multiple USB and GPIO radio adapters/modules over different native UART serial protocols. Such radio libraries includes **[bellows](https://github.com/zigpy/bellows)** (which communicates with EZSP/EmberZNet based radios) and **[zigpy-xbee](https://github.com/zigpy/zigpy-xbee)** (which communicates with XBee based Zigbee radios). There are also experimental radio libraries called **[zigpy-deconz](https://github.com/zigpy/zigpy-deconz)** and **[pyconz](https://github.com/Equidamoid/pyconz/)** available for deCONZ serial protocol (for communicating with ConBee and RaspBee USB and GPIO radios from Dresden-Elektronik). +## Compatible hardware + +zigpy works with separate radio libraries which can each interface with multiple USB and GPIO radio hardware adapters/modules over different native UART serial protocols. Such radio libraries includes **[bellows](https://github.com/zigpy/bellows)** (which communicates with EZSP/EmberZNet based radios), **[zigpy-xbee](https://github.com/zigpy/zigpy-xbee)** (which communicates with XBee based Zigbee radios), and as **[zigpy-deconz](https://github.com/zigpy/zigpy-deconz)** for deCONZ serial protocol (for communicating with ConBee and RaspBee USB and GPIO radios from Dresden-Elektronik). There are also an experimental radio library called **[zigpy-zigate](https://github.com/doudz/zigpy-zigate)** for communicating with ZiGate based radios. + +### Known working Zigbee radio modules -**Known working Zigbee radio modules:** - EmberZNet based radios using the EZSP protocol (via the [bellows](https://github.com/zigpy/bellows) library for zigpy) - [Nortek GoControl QuickStick Combo Model HUSBZB-1 (Z-Wave & Zigbee USB Adapter)](https://www.nortekcontrol.com/products/2gig/husbzb-1-gocontrol-quickstick-combo/) - [Elelabs Zigbee USB Adapter](https://elelabs.com/products/elelabs_usb_adapter.html) @@ -23,7 +26,12 @@ zigpy works with separate radio libraries which can each interface with multiple - [ConBee](https://www.dresden-elektronik.de/conbee/) USB radio adapter from [Dresden-Elektronik](https://www.dresden-elektronik.de) - [RaspBee](https://www.dresden-elektronik.de/raspbee/) GPIO radio adapter from [Dresden-Elektronik](https://www.dresden-elektronik.de) -**Release packages available via PyPI:** +### Experimental Zigbee radio modules +- ZiGate based radios (via the [zigpy-zigate](https://github.com/doudz/zigpy-zigate) library for zigpy) + - [ZiGate open source ZigBee adapter hardware](https://zigate.fr/) + +## Release packages available via PyPI + Packages of tagged versions are also released via PyPI - https://pypi.org/project/zigpy-homeassistant/ - https://pypi.org/project/bellows-homeassistant/ @@ -31,5 +39,7 @@ Packages of tagged versions are also released via PyPI - https://pypi.org/project/zigpy-deconz/ - https://pypi.org/project/zigpy-zigate/ -**Related projects:** +## Related projects + +### ZHA Device Handlers ZHA deviation handling in Home Assistant relies on on the third-party [ZHA Device Handlers](https://github.com/dmulcahey/zha-device-handlers) project. Zigbee devices that deviate from or do not fully conform to the standard specifications set by the [Zigbee Alliance](https://www.zigbee.org) may require the development of custom [ZHA Device Handlers](https://github.com/dmulcahey/zha-device-handlers) (ZHA custom quirks handler implementation) to for all their functions to work properly with the ZHA component in Home Assistant. These ZHA Device Handlers for Home Assistant can thus be used to parse custom messages to and from non-compliant Zigbee devices. The custom quirks implementations for zigpy implemented as ZHA Device Handlers for Home Assistant are a similar concept to that of [Hub-connected Device Handlers for the SmartThings Classics platform](https://docs.smartthings.com/en/latest/device-type-developers-guide/) as well as that of [Zigbee-Shepherd Converters as used by Zigbee2mqtt](https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html), meaning they are each virtual representations of a physical device that expose additional functionality that is not provided out-of-the-box by the existing integration between these platforms. diff --git a/tests/test_appdb.py b/tests/test_appdb.py index 4b78c3cfa..29439e981 100644 --- a/tests/test_appdb.py +++ b/tests/test_appdb.py @@ -74,7 +74,7 @@ async def test_database(tmpdir, monkeypatch): clus._update_attribute(4, bytes("Custom", "ascii")) clus._update_attribute(5, bytes("Model", "ascii")) clus.listener_event("cluster_command", 0) - clus.listener_event("zdo_command") + clus.listener_event("general_command") # Test a CustomDevice custom_ieee = make_ieee(1) diff --git a/tests/test_application.py b/tests/test_application.py index 10f539478..e0e5cf748 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -250,3 +250,17 @@ def test_get_dst_address(app): r = app.get_dst_address(mock.MagicMock()) assert r.addrmode == 3 assert r.endpoint == 1 + + +@pytest.mark.asyncio +async def test_update_network(app): + with pytest.raises(NotImplementedError): + await app.update_network() + + +def test_props(app): + assert app.channel is None + assert app.channels is None + assert app.extended_pan_id is None + assert app.pan_id is None + assert app.nwk_update_id is None diff --git a/tests/test_device.py b/tests/test_device.py index 0ded8c24b..69ec8ef2b 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -117,14 +117,10 @@ def test_handle_message_no_endpoint(dev): def test_handle_message(dev): ep = dev.add_endpoint(3) - dev.deserialize = mock.MagicMock( - return_value=[ - mock.sentinel.tsn, - mock.sentinel.cmd_id, - mock.sentinel.is_reply, - mock.sentinel.args, - ] - ) + hdr = mock.MagicMock() + hdr.tsn = mock.sentinel.tsn + hdr.is_reply = mock.sentinel.is_reply + dev.deserialize = mock.MagicMock(return_value=[hdr, mock.sentinel.args]) ep.handle_message = mock.MagicMock() dev.handle_message(99, 98, 3, 3, b"abcd") assert ep.handle_message.call_count == 1 @@ -136,11 +132,19 @@ def test_handle_message_reply(dev): tsn = mock.sentinel.tsn req_mock = mock.MagicMock() dev._pending[tsn] = req_mock + hdr_1 = mock.MagicMock() + hdr_1.tsn = tsn + hdr_1.command_id = mock.sentinel.command_id + hdr_1.is_reply = True + hdr_2 = mock.MagicMock() + hdr_2.tsn = mock.sentinel.another_tsn + hdr_2.command_id = mock.sentinel.command_id + hdr_2.is_reply = True dev.deserialize = mock.MagicMock( side_effect=( - (tsn, mock.sentinel.cmd_id, True, mock.sentinel.args), - (mock.sentinel.another_tsn, mock.sentinel.cmd_id, True, mock.sentinel.args), - (tsn, mock.sentinel.cmd_id, True, mock.sentinel.args), + (hdr_1, mock.sentinel.args), + (hdr_2, mock.sentinel.args), + (hdr_1, mock.sentinel.args), ) ) dev.handle_message(99, 98, 3, 3, b"abcd") diff --git a/tests/test_endpoint.py b/tests/test_endpoint.py index 22954ba4c..adb2588fb 100644 --- a/tests/test_endpoint.py +++ b/tests/test_endpoint.py @@ -115,19 +115,21 @@ def test_multiple_add_output_cluster(ep): def test_handle_message(ep): c = ep.add_input_cluster(0) c.handle_message = mock.MagicMock() - ep.handle_message(0, 0, 0, 1, []) - c.handle_message.assert_called_once_with(0, 1, []) + ep.handle_message(mock.sentinel.profile, 0, mock.sentinel.hdr, mock.sentinel.data) + c.handle_message.assert_called_once_with(mock.sentinel.hdr, mock.sentinel.data) def test_handle_message_output(ep): c = ep.add_output_cluster(0) c.handle_message = mock.MagicMock() - ep.handle_message(0, 0, 0, 1, []) - c.handle_message.assert_called_once_with(0, 1, []) + ep.handle_message(mock.sentinel.profile, 0, mock.sentinel.hdr, mock.sentinel.data) + c.handle_message.assert_called_once_with(mock.sentinel.hdr, mock.sentinel.data) def test_handle_request_unknown(ep): - ep.handle_message(0, 99, 0, 0, []) + hdr = mock.MagicMock() + hdr.command_id = mock.sentinel.command_id + ep.handle_message(mock.sentinel.profile, 99, hdr, mock.sentinel.args) def test_cluster_attr(ep): @@ -159,6 +161,22 @@ def test_reply(ep): assert ep._device.reply.call_count == 1 +def test_reply_change_profile_id(ep): + ep.profile_id = 49246 + ep.reply(0x1000, 8, b"", 0x3F) + assert ep._device.reply.call_count == 1 + assert ep._device.reply.call_args[0][0] == ep.profile_id + + ep.reply(0x1000, 8, b"", 0x40) + assert ep._device.reply.call_count == 2 + assert ep._device.reply.call_args[0][0] == 0x0104 + + ep.profile_id = 0xBEEF + ep.reply(0x1000, 8, b"", 0x40) + assert ep._device.reply.call_count == 3 + assert ep._device.reply.call_args[0][0] == ep.profile_id + + def _mk_rar(attrid, value, status=0): r = zcl.foundation.ReadAttributeRecord() r.attrid = attrid diff --git a/tests/test_types.py b/tests/test_types.py index 9ce5c011e..e9c62f9ea 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -232,3 +232,69 @@ def test_optional(): d, r = t.Optional(t.uint8_t).deserialize(b"\x001234aaa") assert d == 0 assert r == b"1234aaa" + + +def test_nodata(): + """Test No Data ZCL data type.""" + data = b"\xaa\x55\xbb" + r, rest = t.NoData.deserialize(data) + assert isinstance(r, t.NoData) + assert rest == data + + assert t.NoData().serialize() == b"" + + +def test_date(): + """Test Date ZCL data type.""" + year = t.uint8_t(70) + month = t.uint8_t(1) + day = t.uint8_t(1) + dow = t.uint8_t(4) + + data = year.serialize() + month.serialize() + day.serialize() + dow.serialize() + extra = b"\xaa\x55" + + r, rest = t.Date.deserialize(data + extra) + assert rest == extra + assert r._year == 70 + assert r.year == 1970 + assert r.month == 1 + assert r.day == 1 + assert r.day_of_week == 4 + + assert r.serialize() == data + r.year = 2020 + assert r.serialize()[0] == 2020 - 1900 + assert t.Date().year is None + + +def test_eui64(): + """Test EUI64.""" + data = b"\x01\x02\x03\x04\x05\x06\x07\x08" + extra = b"\xaa\x55" + + ieee, rest = t.EUI64.deserialize(data + extra) + assert ieee[0] == 1 + assert ieee[1] == 2 + assert ieee[2] == 3 + assert ieee[3] == 4 + assert ieee[4] == 5 + assert ieee[5] == 6 + assert ieee[6] == 7 + assert ieee[7] == 8 + assert rest == extra + assert ieee.serialize() == data + + +def test_eui64_convert(): + ieee = t.EUI64.convert("08:07:06:05:04:03:02:01") + assert ieee[0] == 1 + assert ieee[1] == 2 + assert ieee[2] == 3 + assert ieee[3] == 4 + assert ieee[4] == 5 + assert ieee[5] == 6 + assert ieee[6] == 7 + assert ieee[7] == 8 + + assert t.EUI64.convert(None) is None diff --git a/tests/test_zcl.py b/tests/test_zcl.py index af407bf90..7ad752c70 100644 --- a/tests/test_zcl.py +++ b/tests/test_zcl.py @@ -17,31 +17,37 @@ def endpoint(): def test_deserialize_general(endpoint): - tsn, command_id, is_reply, args = endpoint.deserialize(0, b"\x00\x01\x00") - assert tsn == 1 - assert command_id == 0 - assert is_reply is False + hdr, args = endpoint.deserialize(0, b"\x00\x01\x00") + assert hdr.tsn == 1 + assert hdr.command_id == 0 + assert hdr.is_reply is False def test_deserialize_general_unknown(endpoint): - tsn, command_id, is_reply, args = endpoint.deserialize(0, b"\x00\x01\xff") - assert tsn == 1 - assert command_id == 255 - assert is_reply is False + hdr, args = endpoint.deserialize(0, b"\x00\x01\xff") + assert hdr.tsn == 1 + assert hdr.frame_control.is_general is True + assert hdr.frame_control.is_cluster is False + assert hdr.command_id == 255 + assert hdr.is_reply is False def test_deserialize_cluster(endpoint): - tsn, command_id, is_reply, args = endpoint.deserialize(0, b"\x01\x01\x00xxx") - assert tsn == 1 - assert command_id == 256 - assert is_reply is False + hdr, args = endpoint.deserialize(0, b"\x01\x01\x00xxx") + assert hdr.tsn == 1 + assert hdr.frame_control.is_general is False + assert hdr.frame_control.is_cluster is True + assert hdr.command_id == 0 + assert hdr.is_reply is False def test_deserialize_cluster_client(endpoint): - tsn, command_id, is_reply, args = endpoint.deserialize(3, b"\x09\x01\x00AB") - assert tsn == 1 - assert command_id == 256 - assert is_reply is True + hdr, args = endpoint.deserialize(3, b"\x09\x01\x00AB") + assert hdr.tsn == 1 + assert hdr.frame_control.is_general is False + assert hdr.frame_control.is_cluster is True + assert hdr.command_id == 0 + assert hdr.is_reply is True assert args == [0x4241] @@ -51,10 +57,10 @@ def test_deserialize_cluster_unknown(endpoint): def test_deserialize_cluster_command_unknown(endpoint): - tsn, command_id, is_reply, args = endpoint.deserialize(0, b"\x01\x01\xff") - assert tsn == 1 - assert command_id == 255 + 256 - assert is_reply is False + hdr, args = endpoint.deserialize(0, b"\x01\x01\xff") + assert hdr.tsn == 1 + assert hdr.command_id == 255 + assert hdr.is_reply is False def test_unknown_cluster(): @@ -152,20 +158,49 @@ def test_attribute_report(cluster): attr.attrid = 4 attr.value = zcl.foundation.TypeValue() attr.value.value = 1 - cluster.handle_message(0, 0x0A, [[attr]]) + hdr = mock.MagicMock(auto_spec=foundation.ZCLHeader) + hdr.command_id = foundation.Command.Report_Attributes + hdr.frame_control.is_general = True + hdr.frame_control.is_cluster = False + cluster.handle_message(hdr, [[attr]]) assert cluster._attr_cache[4] == 1 def test_handle_request_unknown(cluster): - cluster.handle_message(0, 0xFF, []) + hdr = mock.MagicMock(auto_spec=foundation.ZCLHeader) + hdr.command_id = mock.sentinel.command_id + hdr.frame_control.is_general = True + hdr.frame_control.is_cluster = False + cluster.listener_event = mock.MagicMock() + cluster._update_attribute = mock.MagicMock() + cluster.handle_cluster_general_request = mock.MagicMock() + cluster.handle_cluster_request = mock.MagicMock() + cluster.handle_message(hdr, mock.sentinel.args) + + assert cluster.listener_event.call_count == 1 + assert cluster.listener_event.call_args[0][0] == "general_command" + assert cluster._update_attribute.call_count == 0 + assert cluster.handle_cluster_general_request.call_count == 1 + assert cluster.handle_cluster_request.call_count == 0 def test_handle_cluster_request(cluster): - cluster.handle_message(0, 256, []) - - -def test_handle_unexpected_reply(cluster): - cluster.handle_message(0, 0, []) + hdr = mock.MagicMock(auto_spec=foundation.ZCLHeader) + hdr.command_id = mock.sentinel.command_id + hdr.frame_control.is_general = False + hdr.frame_control.is_cluster = True + hdr.command_id.is_general = False + cluster.listener_event = mock.MagicMock() + cluster._update_attribute = mock.MagicMock() + cluster.handle_cluster_general_request = mock.MagicMock() + cluster.handle_cluster_request = mock.MagicMock() + cluster.handle_message(hdr, mock.sentinel.args) + + assert cluster.listener_event.call_count == 1 + assert cluster.listener_event.call_args[0][0] == "cluster_command" + assert cluster._update_attribute.call_count == 0 + assert cluster.handle_cluster_general_request.call_count == 0 + assert cluster.handle_cluster_request.call_count == 1 def _mk_rar(attrid, value, status=0): @@ -451,3 +486,15 @@ def test_general_command_reply(cluster): cluster.reply.assert_called_with( True, cmd_id, mock.ANY, True, [], manufacturer=0x4567, tsn=mock.sentinel.tsn ) + + +def test_handle_cluster_request_handler(cluster): + cluster.handle_cluster_request( + mock.sentinel.tsn, mock.sentinel.command_id, mock.sentinel.args + ) + + +def test_handle_cluster_general_request(cluster): + cluster.handle_cluster_general_request( + mock.sentinel.tsn, mock.sentinel.command_id, mock.sentinel.args + ) diff --git a/tests/test_zcl_foundation.py b/tests/test_zcl_foundation.py index a5ef86677..a44142a77 100644 --- a/tests/test_zcl_foundation.py +++ b/tests/test_zcl_foundation.py @@ -298,3 +298,11 @@ def test_frame_header_cluster(): hdr.manufacturer = None assert hdr.manufacturer is None assert hdr.frame_control.is_manufacturer_specific is False + + +def test_data_types(): + """Test data types mappings.""" + assert len(foundation.DATA_TYPES) == len(foundation.DATA_TYPE_IDX) + data_types_set = set([d[1] for d in foundation.DATA_TYPES.values()]) + dt_2_id_set = set(foundation.DATA_TYPE_IDX.keys()) + assert data_types_set == dt_2_id_set diff --git a/tests/test_zdo.py b/tests/test_zdo.py index 143b17296..77fa33f64 100644 --- a/tests/test_zdo.py +++ b/tests/test_zdo.py @@ -45,16 +45,16 @@ def zdo_f(app): def test_deserialize(zdo_f): - tsn, command_id, is_reply, args = zdo_f.deserialize(2, b"\x01\x02\x03xx") - assert tsn == 1 - assert is_reply is False + hdr, args = zdo_f.deserialize(2, b"\x01\x02\x03xx") + assert hdr.tsn == 1 + assert hdr.is_reply is False assert args == [0x0302] def test_deserialize_unknown(zdo_f): - tsn, command_id, is_reply, args = zdo_f.deserialize(0x0100, b"\x01") - assert tsn == 1 - assert is_reply is False + hdr, args = zdo_f.deserialize(0x0100, b"\x01") + assert hdr.tsn == 1 + assert hdr.is_reply is False @pytest.mark.asyncio @@ -109,7 +109,9 @@ def test_broadcast(app): def _handle_match_desc(zdo_f, profile): zdo_f.reply = mock.MagicMock() - zdo_f.handle_message(5, 0x0006, 123, 0x0006, [None, profile, [], []]) + hdr = mock.MagicMock() + hdr.command_id = zdo_types.ZDOCmd.Match_Desc_req + zdo_f.handle_message(5, 0x0006, hdr, [None, profile, [], []]) assert zdo_f.reply.call_count == 1 @@ -121,21 +123,21 @@ def test_handle_match_desc_generic(zdo_f): return _handle_match_desc(zdo_f, 0) -def test_unexpected_reply(zdo_f): - zdo_f.handle_message(5, 4, 3, 2, []) - - def test_handle_nwk_addr(zdo_f): ieee = zdo_f._device.application.ieee zdo_f.reply = mock.MagicMock() - zdo_f.handle_message(5, 0x0000, 234, 0x0000, [ieee]) + hdr = mock.MagicMock() + hdr.command_id = zdo_types.ZDOCmd.NWK_addr_req + zdo_f.handle_message(5, 0x0000, hdr, [ieee]) assert zdo_f.reply.call_count == 1 def test_handle_ieee_addr(zdo_f): nwk = zdo_f._device.application.nwk zdo_f.reply = mock.MagicMock() - zdo_f.handle_message(5, 0x0001, 234, 0x0001, [nwk]) + hdr = mock.MagicMock() + hdr.command_id = zdo_types.ZDOCmd.IEEE_addr_req + zdo_f.handle_message(5, 0x0001, hdr, [nwk]) assert zdo_f.reply.call_count == 1 @@ -143,18 +145,32 @@ def test_handle_announce(zdo_f): dev = zdo_f._device zdo_f.listener_event = mock.MagicMock() dev._application.devices.pop(dev.ieee) - zdo_f.handle_message(5, 0x0013, 111, 0x0013, [0, dev.ieee, dev.nwk]) + hdr = mock.MagicMock() + hdr.command_id = zdo_types.ZDOCmd.Device_annce + zdo_f.handle_message(5, 0x0013, hdr, [0, dev.ieee, dev.nwk]) assert zdo_f.listener_event.call_count == 1 def test_handle_permit_join(zdo_f): zdo_f.listener_event = mock.MagicMock() - zdo_f.handle_message(5, 0x0036, 111, 0x0036, [100, 1]) + hdr = mock.MagicMock() + hdr.command_id = zdo_types.ZDOCmd.Mgmt_Permit_Joining_req + zdo_f.handle_message(5, 0x0036, hdr, [100, 1]) assert zdo_f.listener_event.call_count == 1 def test_handle_unsupported(zdo_f): - zdo_f.handle_message(5, 0xFFFF, 321, 0xFFFF, []) + zdo_f.listener_event = mock.MagicMock() + hdr = mock.MagicMock() + hdr.command_id = 0xFFFF + assert hdr.command_id not in list(zdo_types.ZDOCmd) + zdo_f.request = mock.MagicMock() + zdo_f.reply = mock.MagicMock() + zdo_f.handle_message(5, 0xFFFF, hdr, []) + + assert zdo_f.listener_event.call_count == 0 + assert zdo_f.request.call_count == 0 + assert zdo_f.reply.call_count == 0 def test_device_accessor(zdo_f): diff --git a/tests/test_zdo_types.py b/tests/test_zdo_types.py index 66df23b99..b735f61e3 100644 --- a/tests/test_zdo_types.py +++ b/tests/test_zdo_types.py @@ -156,3 +156,39 @@ def test_status_undef(): assert rest == extra assert status == 0xAA assert not isinstance(status, types.Status) + + +def test_zdo_header(): + tsn = t.uint8_t(0xAA) + cmd_id = 0x55 + data = tsn.serialize() + extra = b"abcdefExtraDataHere" + hdr, rest = types.ZDOHeader.deserialize(cmd_id, data + extra) + assert rest == extra + assert hdr.tsn == tsn + assert hdr.command_id == cmd_id + assert hdr.is_reply is False + + hdr.command_id = types.ZDOCmd.Bind_rsp + assert hdr.is_reply is True + + assert hdr.serialize() == data + + new_tsn = 0xBB + hdr.tsn = new_tsn + assert isinstance(hdr.tsn, t.uint8_t) + assert hdr.tsn == new_tsn + + +def test_zdo_header_cmd_id(): + unk_cmd = 0x00FF + assert unk_cmd not in list(types.ZDOCmd) + hdr = types.ZDOHeader(unk_cmd, 0x55) + assert isinstance(hdr.command_id, t.uint16_t) + assert hdr.command_id == unk_cmd + + unk_cmd += 1 + assert unk_cmd not in list(types.ZDOCmd) + hdr.command_id = unk_cmd + assert isinstance(hdr.command_id, t.uint16_t) + assert hdr.command_id == unk_cmd diff --git a/zigpy/__init__.py b/zigpy/__init__.py index 402f1790a..edf073c77 100644 --- a/zigpy/__init__.py +++ b/zigpy/__init__.py @@ -1,6 +1,6 @@ # coding: utf-8 MAJOR_VERSION = 0 -MINOR_VERSION = 9 +MINOR_VERSION = 10 PATCH_VERSION = "0" __short_version__ = "{}.{}".format(MAJOR_VERSION, MINOR_VERSION) __version__ = "{}.{}".format(__short_version__, PATCH_VERSION) diff --git a/zigpy/appdb.py b/zigpy/appdb.py index 4429a73f8..23bf281ee 100644 --- a/zigpy/appdb.py +++ b/zigpy/appdb.py @@ -17,13 +17,12 @@ def _sqlite_adapters(): def adapt_ieee(eui64): - return repr(eui64) + return str(eui64) sqlite3.register_adapter(t.EUI64, adapt_ieee) def convert_ieee(s): - ieee = [t.uint8_t(p, base=16) for p in s.split(b":")] - return t.EUI64(ieee) + return t.EUI64.convert(s.decode()) sqlite3.register_converter("ieee", convert_ieee) diff --git a/zigpy/application.py b/zigpy/application.py index e53cec1e3..8042a9898 100644 --- a/zigpy/application.py +++ b/zigpy/application.py @@ -1,6 +1,7 @@ import asyncio import logging import os.path +from typing import Optional import zigpy.appdb import zigpy.device @@ -23,8 +24,14 @@ def __init__(self, database_file=None): self.devices = {} self._groups = zigpy.group.Groups(self) self._listeners = {} + self._channel = None + self._channels = None + self._ext_pan_id = None self._ieee = None self._nwk = None + self._nwk_update_id = None + self._pan_id = None + self._ota = zigpy.ota.OTA(self) if database_file is None: ota_dir = None @@ -59,6 +66,24 @@ def add_device(self, ieee, nwk): self.devices[ieee] = dev return dev + async def update_network( + self, + channel: Optional[t.uint8_t] = None, + channels: Optional[t.uint32_t] = None, + pan_id: Optional[t.PanId] = None, + extended_pan_id: Optional[t.ExtendedPanId] = None, + network_key: Optional[t.KeyData] = None, + ): + """Update network parameters. + + :param channel: Radio channel + :param channels: Channels mask + :param pan_id: Network pan id + :param extended_pan_id: Extended pan id + :param network_key: network key + """ + raise NotImplementedError + def device_initialized(self, device): """Used by a device to signal that it is initialized""" self.listener_event("raw_device_initialized", device) @@ -265,6 +290,21 @@ def get_dst_address(self, cluster): dstaddr.endpoint = 1 return dstaddr + @property + def channel(self): + """Current radio channel.""" + return self._channel + + @property + def channels(self): + """Channel mask.""" + return self._channels + + @property + def extended_pan_id(self): + """Extended PAN Id.""" + return self._ext_pan_id + @property def groups(self): return self._groups @@ -277,6 +317,16 @@ def ieee(self): def nwk(self): return self._nwk + @property + def nwk_update_id(self): + """NWK Update ID.""" + return self._nwk_update_id + @property def ota(self): return self._ota + + @property + def pan_id(self): + """Network PAN Id.""" + return self._pan_id diff --git a/zigpy/device.py b/zigpy/device.py index 91de8d125..157cdb221 100644 --- a/zigpy/device.py +++ b/zigpy/device.py @@ -203,7 +203,7 @@ def handle_message(self, profile, cluster, src_ep, dst_ep, message): self._node_handle = asyncio.ensure_future(self.refresh_node_descriptor()) try: - tsn, command_id, is_reply, args = self.deserialize(src_ep, cluster, message) + hdr, args = self.deserialize(src_ep, cluster, message) except ValueError as e: LOGGER.error( "Failed to parse message (%s) on cluster %d, because %s", @@ -224,9 +224,9 @@ def handle_message(self, profile, cluster, src_ep, dst_ep, message): ) return - if tsn in self._pending and is_reply: + if hdr.tsn in self._pending and hdr.is_reply: try: - self._pending[tsn].result.set_result(args) + self._pending[hdr.tsn].result.set_result(args) return except asyncio.InvalidStateError: self.debug( @@ -234,11 +234,11 @@ def handle_message(self, profile, cluster, src_ep, dst_ep, message): "Invalid state on future for 0x%02x seq " "-- probably duplicate response" ), - tsn, + hdr.tsn, ) return endpoint = self.endpoints[src_ep] - return endpoint.handle_message(profile, cluster, tsn, command_id, args) + return endpoint.handle_message(profile, cluster, hdr, args) def reply(self, profile, cluster, src_ep, dst_ep, sequence, data, use_ieee=False): return self.request( diff --git a/zigpy/endpoint.py b/zigpy/endpoint.py index 4ea8917b8..69fa96d33 100644 --- a/zigpy/endpoint.py +++ b/zigpy/endpoint.py @@ -172,17 +172,17 @@ def deserialize(self, cluster_id, data): cluster = self.in_clusters.get(cluster_id, self.out_clusters.get(cluster_id)) return cluster.deserialize(data) - def handle_message(self, profile, cluster, tsn, command_id, args): + def handle_message(self, profile, cluster, hdr, args): if cluster in self.in_clusters: handler = self.in_clusters[cluster].handle_message elif cluster in self.out_clusters: handler = self.out_clusters[cluster].handle_message else: self.debug("Message on unknown cluster 0x%04x", cluster) - self.listener_event("unknown_cluster_message", command_id, args) + self.listener_event("unknown_cluster_message", hdr.command_id, args) return - handler(tsn, command_id, args) + handler(hdr, args) def request(self, cluster, sequence, data, expect_reply=True, command_id=0x00): if self.profile_id == zigpy.profiles.zll.PROFILE_ID and not ( @@ -203,14 +203,17 @@ def request(self, cluster, sequence, data, expect_reply=True, command_id=0x00): expect_reply=expect_reply, ) - def reply(self, cluster, sequence, data): + def reply(self, cluster, sequence, data, command_id=0x00): + if self.profile_id == zigpy.profiles.zll.PROFILE_ID and not ( + cluster == zigpy.zcl.clusters.lightlink.LightLink.cluster_id + and command_id < 0x40 + ): + profile_id = zigpy.profiles.zha.PROFILE_ID + else: + profile_id = self.profile_id + return self.device.reply( - self.profile_id, - cluster, - self._endpoint_id, - self._endpoint_id, - sequence, - data, + profile_id, cluster, self._endpoint_id, self._endpoint_id, sequence, data ) def log(self, lvl, msg, *args): diff --git a/zigpy/types/basic.py b/zigpy/types/basic.py index cc5878d30..fa6bc88c4 100644 --- a/zigpy/types/basic.py +++ b/zigpy/types/basic.py @@ -296,3 +296,52 @@ def deserialize(cls, data): return None, b"" return Optional + + +class data8(_FixedList): + """General data, Discrete, 8 bit.""" + + _itemtype = uint8_t + _length = 1 + + +class data16(data8): + """General data, Discrete, 16 bit.""" + + _length = 2 + + +class data24(data8): + """General data, Discrete, 24 bit.""" + + _length = 3 + + +class data32(data8): + """General data, Discrete, 32 bit.""" + + _length = 4 + + +class data40(data8): + """General data, Discrete, 40 bit.""" + + _length = 5 + + +class data48(data8): + """General data, Discrete, 48 bit.""" + + _length = 6 + + +class data56(data8): + """General data, Discrete, 56 bit.""" + + _length = 7 + + +class data64(data8): + """General data, Discrete, 64 bit.""" + + _length = 8 diff --git a/zigpy/types/named.py b/zigpy/types/named.py index c2a654146..dd5ef7c3c 100644 --- a/zigpy/types/named.py +++ b/zigpy/types/named.py @@ -1,6 +1,7 @@ import enum from . import basic +from .struct import Struct class BroadcastAddress(basic.uint16_t, enum.Enum): @@ -16,21 +17,20 @@ class BroadcastAddress(basic.uint16_t, enum.Enum): class EUI64(basic.fixed_list(8, basic.uint8_t)): # EUI 64-bit ID (an IEEE address). - @classmethod - def deserialize(cls, data): - r, data = super().deserialize(data) - return cls(r[::-1]), data - - def serialize(self): - assert self._length == len(self) - return b"".join([i.serialize() for i in self[::-1]]) - def __repr__(self): - return ":".join("%02x" % i for i in self) + return ":".join("%02x" % i for i in self[::-1]) def __hash__(self): return hash(repr(self)) + @classmethod + def convert(cls, ieee: str): + if ieee is None: + return None + ieee = [basic.uint8_t(p, base=16) for p in ieee.split(":")[::-1]] + assert len(ieee) == cls._length + return cls(ieee) + class KeyData(basic.fixed_list(16, basic.uint8_t)): pass @@ -42,18 +42,79 @@ class Bool(basic.uint8_t, enum.Enum): class HexRepr: - _hex_len = 2 - def __repr__(self): - return ("0x{:0" + str(self._hex_len) + "x}").format(self) + return ("0x{:0" + str(self._size * 2) + "x}").format(self) def __str__(self): - return ("0x{:0" + str(self._hex_len) + "x}").format(self) + return ("0x{:0" + str(self._size * 2) + "x}").format(self) + + +class AttributeId(HexRepr, basic.uint16_t): + pass + + +class BACNetOid(basic.uint32_t): + pass + + +class ClusterId(basic.uint16_t): + pass + + +class Date(Struct): + _fields = [ + ("_year", basic.uint8_t), + ("month", basic.uint8_t), + ("day", basic.uint8_t), + ("day_of_week", basic.uint8_t), + ] + + @property + def year(self): + """Return year.""" + if self._year is None: + return self._year + return 1900 + self._year + + @year.setter + def year(self, value): + assert 1900 <= value <= 2155 + self._year = basic.uint8_t(value - 1900) class NWK(HexRepr, basic.uint16_t): - _hex_len = 4 + pass + + +class PanId(NWK): + pass + + +class ExtendedPanId(EUI64): + pass class Group(HexRepr, basic.uint16_t): - _hex_len = 4 + pass + + +class NoData: + @classmethod + def deserialize(cls, data): + return cls(), data + + def serialize(self): + return b"" + + +class TimeOfDay(Struct): + _fields = [ + ("hours", basic.uint8_t), + ("minutes", basic.uint8_t), + ("seconds", basic.uint8_t), + ("hundredths", basic.uint8_t), + ] + + +class UTCTime(basic.uint32_t): + pass diff --git a/zigpy/zcl/__init__.py b/zigpy/zcl/__init__.py index 82e00eb12..bba2906e6 100644 --- a/zigpy/zcl/__init__.py +++ b/zigpy/zcl/__init__.py @@ -15,6 +15,8 @@ class Registry(type): def __init__(cls, name, bases, nmspc): # noqa: N805 super(Registry, cls).__init__(name, bases, nmspc) + if hasattr(cls, "cluster_id"): + cls.cluster_id = t.ClusterId(cls.cluster_id) if hasattr(cls, "attributes"): cls._attridx = {} for attrid, (attrname, datatype) in cls.attributes.items(): @@ -89,27 +91,24 @@ def deserialize(self, data): try: schema = commands[hdr.command_id][1] - is_reply = commands[hdr.command_id][2] + hdr.frame_control.is_reply = commands[hdr.command_id][2] except KeyError: LOGGER.warning("Unknown cluster-specific command %s", hdr.command_id) - return hdr.tsn, hdr.command_id + 256, hdr.is_reply, data - - # Bad hack to differentiate foundation vs cluster - hdr.command_id = hdr.command_id + 256 + return hdr, data else: # General command try: schema = foundation.COMMANDS[hdr.command_id][0] - is_reply = foundation.COMMANDS[hdr.command_id][1] + hdr.frame_control.is_reply = foundation.COMMANDS[hdr.command_id][1] except KeyError: LOGGER.warning("Unknown foundation command %s", hdr.command_id) - return hdr.tsn, hdr.command_id, hdr.is_reply, data + return hdr, data value, data = t.deserialize(data, schema) if data != b"": LOGGER.warning("Data remains after deserializing ZCL frame") - return hdr.tsn, hdr.command_id, is_reply, value + return hdr, value @util.retryable_request def request( @@ -160,19 +159,21 @@ def reply(self, general, command_id, schema, *args, manufacturer=None, tsn=None) hdr.manufacturer = manufacturer data = hdr.serialize() + t.serialize(args, schema) - return self._endpoint.reply(self.cluster_id, tsn, data) + return self._endpoint.reply(self.cluster_id, tsn, data, command_id=command_id) - def handle_message(self, tsn, command_id, args): - self.debug("ZCL request 0x%04x: %s", command_id, args) - if command_id <= 0xFF: - self.listener_event("zdo_command", tsn, command_id, args) - else: - # Unencapsulate bad hack - command_id -= 256 - self.listener_event("cluster_command", tsn, command_id, args) - self.handle_cluster_request(tsn, command_id, args) + def handle_message(self, hdr, args): + self.debug("ZCL request 0x%04x: %s", hdr.command_id, args) + if hdr.frame_control.is_cluster: + self.handle_cluster_request(hdr.tsn, hdr.command_id, args) + self.listener_event("cluster_command", hdr.tsn, hdr.command_id, args) return + self.listener_event("general_command", hdr.tsn, hdr.command_id, args) + self.handle_cluster_general_request(hdr.tsn, hdr.command_id, args) + def handle_cluster_request(self, tsn, command_id, args): + self.debug("No handler for cluster command %s", command_id) + + def handle_cluster_general_request(self, tsn, command_id, args): if command_id == foundation.Command.Report_Attributes: valuestr = ", ".join( [ @@ -184,14 +185,6 @@ def handle_message(self, tsn, command_id, args): self.debug("Attribute report received: %s", valuestr) for attr in args[0]: self._update_attribute(attr.attrid, attr.value.value) - else: - self.handle_cluster_general_request(tsn, command_id, args) - - def handle_cluster_request(self, tsn, command_id, args): - self.debug("No handler for cluster command %s", command_id) - - def handle_cluster_general_request(self, tsn, command_id, args): - self.debug("No handler for general command %s", command_id) def read_attributes_raw(self, attributes, manufacturer=None): attributes = [t.uint16_t(a) for a in attributes] @@ -451,7 +444,7 @@ def attribute_updated(self, attrid, value): def cluster_command(self, *args, **kwargs): pass - def zdo_command(self, *args, **kwargs): + def general_command(self, *args, **kwargs): pass diff --git a/zigpy/zcl/foundation.py b/zigpy/zcl/foundation.py index dc5b8844e..847acaf6d 100644 --- a/zigpy/zcl/foundation.py +++ b/zigpy/zcl/foundation.py @@ -62,6 +62,10 @@ class Discrete: pass +class Null: + pass + + class TypeValue: def __init__(self, python_type=None, value=None): self.type = python_type @@ -97,16 +101,28 @@ def deserialize(cls, data): return self, data +class Array(TypedCollection): + pass + + +class Bag(TypedCollection): + pass + + +class Set(TypedCollection): + pass # ToDo: Make this a real set? + + DATA_TYPES = { - 0x00: ("No data", None, None), - 0x08: ("General", t.fixed_list(1, t.uint8_t), Discrete), - 0x09: ("General", t.fixed_list(2, t.uint8_t), Discrete), - 0x0A: ("General", t.fixed_list(3, t.uint8_t), Discrete), - 0x0B: ("General", t.fixed_list(4, t.uint8_t), Discrete), - 0x0C: ("General", t.fixed_list(5, t.uint8_t), Discrete), - 0x0D: ("General", t.fixed_list(6, t.uint8_t), Discrete), - 0x0E: ("General", t.fixed_list(7, t.uint8_t), Discrete), - 0x0F: ("General", t.fixed_list(8, t.uint8_t), Discrete), + 0x00: ("No data", t.NoData, Null), + 0x08: ("General", t.data8, Discrete), + 0x09: ("General", t.data16, Discrete), + 0x0A: ("General", t.data24, Discrete), + 0x0B: ("General", t.data32, Discrete), + 0x0C: ("General", t.data40, Discrete), + 0x0D: ("General", t.data48, Discrete), + 0x0E: ("General", t.data56, Discrete), + 0x0F: ("General", t.data64, Discrete), 0x10: ("Boolean", t.Bool, Discrete), 0x18: ("Bitmap", t.bitmap8, Discrete), 0x19: ("Bitmap", t.bitmap16, Discrete), @@ -141,29 +157,22 @@ def deserialize(cls, data): 0x42: ("Character string", t.CharacterString, Discrete), 0x43: ("Long octet string", t.LongOctetString, Discrete), 0x44: ("Long character string", t.LongCharacterString, Discrete), - 0x48: ("Array", TypedCollection, Discrete), + 0x48: ("Array", Array, Discrete), 0x4C: ("Structure", t.LVList(TypeValue, 2), Discrete), - 0x50: ("Set", TypedCollection, Discrete), - 0x51: ("Bag", TypedCollection, Discrete), - 0xE0: ("Time of day", t.uint32_t, Analog), - 0xE1: ("Date", t.uint32_t, Analog), - 0xE2: ("UTCTime", t.uint32_t, Analog), - 0xE8: ("Cluster ID", t.uint16_t, Discrete), - 0xE9: ("Attribute ID", t.uint16_t, Discrete), - 0xEA: ("BACNet OID", t.uint32_t, Discrete), + 0x50: ("Set", Set, Discrete), + 0x51: ("Bag", Bag, Discrete), + 0xE0: ("Time of day", t.TimeOfDay, Analog), + 0xE1: ("Date", t.Date, Analog), + 0xE2: ("UTCTime", t.UTCTime, Analog), + 0xE8: ("Cluster ID", t.ClusterId, Discrete), + 0xE9: ("Attribute ID", t.AttributeId, Discrete), + 0xEA: ("BACNet OID", t.BACNetOid, Discrete), 0xF0: ("IEEE address", t.EUI64, Discrete), - 0xF1: ("128-bit security key", t.fixed_list(16, t.uint16_t), Discrete), + 0xF1: ("128-bit security key", t.KeyData, Discrete), 0xFF: ("Unknown", None, None), } -DATA_TYPE_IDX = { - t: tidx - for tidx, (tname, t, ad) in DATA_TYPES.items() - if ad is Analog or tname == "Enumeration" or tname == "Bitmap" -} -DATA_TYPE_IDX[t.uint32_t] = 0x23 -DATA_TYPE_IDX[t.EUI64] = 0xF0 -DATA_TYPE_IDX[t.Bool] = 0x10 +DATA_TYPE_IDX = {t: tidx for tidx, (tname, t, ad) in DATA_TYPES.items()} class ReadAttributeRecord(t.Struct): @@ -525,8 +534,11 @@ def __init__( manufacturer: t.uint16_t = None, ) -> None: """Initialize ZCL Frame instance.""" - self._cmd_id = Command(command_id) self._frc = frame_control + if frame_control.is_general: + self._cmd_id = Command(command_id) + else: + self._cmd_id = t.uint8_t(command_id) self._manufacturer = manufacturer if manufacturer is not None: self.frame_control.is_manufacturer_specific = True @@ -545,10 +557,13 @@ def command_id(self) -> Command: @command_id.setter def command_id(self, value: Command) -> None: """Setter for command identifier.""" - try: - self._cmd_id = Command(value) - except ValueError: - self._cmd_id = t.uint8_t(value) + if self.frame_control.is_general: + try: + self._cmd_id = Command(value) + return + except ValueError: + pass + self._cmd_id = t.uint8_t(value) @property def is_reply(self) -> bool: diff --git a/zigpy/zdo/__init__.py b/zigpy/zdo/__init__.py index 2b00c0007..a187a7c73 100644 --- a/zigpy/zdo/__init__.py +++ b/zigpy/zdo/__init__.py @@ -24,25 +24,19 @@ def _serialize(self, command, *args): return data def deserialize(self, cluster_id, data): - tsn, data = data[0], data[1:] - - is_reply = bool(cluster_id & 0x8000) - try: - cluster_id = types.ZDOCmd(cluster_id) - except ValueError: - self.warn("Unsupported ZDO cluster id 0x%04x", cluster_id) + hdr, data = types.ZDOHeader.deserialize(cluster_id, data) try: cluster_details = types.CLUSTERS[cluster_id] except KeyError: self.warn("Unknown ZDO cluster 0x%04x", cluster_id) - return tsn, cluster_id, is_reply, data + return hdr, data args, data = t.deserialize(data, cluster_details[1]) if data != b"": # TODO: Seems sane to check, but what should we do? self.warn("Data remains after deserializing ZDO frame") - return tsn, cluster_id, is_reply, args + return hdr, args @zigpy.util.retryable_request def request(self, command, *args, use_ieee=False): @@ -61,24 +55,24 @@ def reply(self, command, *args, tsn=None, use_ieee=False): self._device.reply(0, command, 0, 0, tsn, data, use_ieee=use_ieee) ) - def handle_message(self, profile, cluster, tsn, command_id, args): - self.debug("ZDO request %s: %s", command_id, args) + def handle_message(self, profile, cluster, hdr, args): + self.debug("ZDO request %s: %s", hdr.command_id, args) app = self._device.application - if command_id == types.ZDOCmd.NWK_addr_req: + if hdr.command_id == types.ZDOCmd.NWK_addr_req: if app.ieee == args[0]: - self.NWK_addr_rsp(0, app.ieee, app.nwk, 0, 0, [], tsn=tsn) - elif command_id == types.ZDOCmd.IEEE_addr_req: + self.NWK_addr_rsp(0, app.ieee, app.nwk, 0, 0, [], tsn=hdr.tsn) + elif hdr.command_id == types.ZDOCmd.IEEE_addr_req: broadcast = (0xFFFF, 0xFFFD, 0xFFFC) if args[0] in broadcast or app.nwk == args[0]: - self.IEEE_addr_rsp(0, app.ieee, app.nwk, 0, 0, [], tsn=tsn) - elif command_id == types.ZDOCmd.Match_Desc_req: - self.handle_match_desc(*args, tsn=tsn) - elif command_id == types.ZDOCmd.Device_annce: + self.IEEE_addr_rsp(0, app.ieee, app.nwk, 0, 0, [], tsn=hdr.tsn) + elif hdr.command_id == types.ZDOCmd.Match_Desc_req: + self.handle_match_desc(*args, tsn=hdr.tsn) + elif hdr.command_id == types.ZDOCmd.Device_annce: self.listener_event("device_announce", self._device) - elif command_id == types.ZDOCmd.Mgmt_Permit_Joining_req: + elif hdr.command_id == types.ZDOCmd.Mgmt_Permit_Joining_req: self.listener_event("permit_duration", args[0]) else: - self.warn("Unsupported ZDO request:%s", command_id) + self.warn("Unsupported ZDO request:%s", hdr.command_id) def handle_match_desc(self, addr, profile, in_clusters, out_clusters, *, tsn=None): local_addr = self._device.application.nwk diff --git a/zigpy/zdo/types.py b/zigpy/zdo/types.py index c5652bdc4..d76385d4c 100644 --- a/zigpy/zdo/types.py +++ b/zigpy/zdo/types.py @@ -1,4 +1,5 @@ import enum +import typing import zigpy.types as t @@ -528,3 +529,57 @@ class ZDOCmd(_CommandID, enum.Enum): param_names = [p[0] for p in schema] param_types = [p[1] for p in schema] CLUSTERS[command_id] = (param_names, param_types) + + +class ZDOHeader: + """Just a wrapper representing ZDO header, similar to ZCL header.""" + + def __init__(self, command_id: t.uint16_t = 0x0000, tsn: t.uint8_t = 0) -> None: + try: + self._command_id = ZDOCmd(command_id) + except ValueError: + self._command_id = t.uint16_t(command_id) + self._tsn = t.uint8_t(tsn) + + @property + def command_id(self) -> ZDOCmd: + """Return ZDO command.""" + return self._command_id + + @command_id.setter + def command_id(self, value: t.uint16_t) -> None: + """Command ID setter.""" + try: + self._command_id = ZDOCmd(value) + return + except ValueError: + pass + self._command_id = t.uint16_t(value) + + @property + def is_reply(self) -> bool: + """Return True if this is a reply.""" + return bool(self._command_id & 0x8000) + + @property + def tsn(self) -> t.uint8_t: + """Return transaction seq number.""" + return self._tsn + + @tsn.setter + def tsn(self, value: t.uint8_t) -> None: + """Set TSN.""" + self._tsn = t.uint8_t(value) + + @classmethod + def deserialize( + cls, command_id: t.uint16_t, data: bytes + ) -> typing.Tuple["ZDOHeader", bytes]: + """Deserialize data.""" + tsn, data = t.uint8_t.deserialize(data) + return cls(command_id, tsn), data + + def serialize(self) -> bytes: + """Serialize header.""" + + return self.tsn.serialize()