diff --git a/setup.py b/setup.py index 4ac26b6..5ad03bd 100644 --- a/setup.py +++ b/setup.py @@ -2,9 +2,11 @@ from setuptools import find_packages, setup +import zigpy_xbee.const as xbee_const + setup( name="zigpy-xbee-homeassistant", - version="0.1.3", + version=xbee_const.__version__, description="A library which communicates with XBee radios for zigpy", url="http://github.com/zigpy/zigpy-xbee", author="Russell Cloran", diff --git a/tests/test_api.py b/tests/test_api.py index 3cc3df5..b1f4a1f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -33,8 +33,9 @@ def test_close(api): def test_commands(): import string anum = string.ascii_letters + string.digits + '_' + commands = {**xbee_api.COMMAND_REQUESTS, **xbee_api.COMMAND_RESPONSES} - for cmd_name, cmd_opts in xbee_api.COMMANDS.items(): + for cmd_name, cmd_opts in commands.items(): assert isinstance(cmd_name, str) is True assert all([c in anum for c in cmd_name]), cmd_name assert len(cmd_opts) == 3 @@ -47,12 +48,12 @@ def test_commands(): @pytest.mark.asyncio async def test_command(api): def mock_api_frame(name, *args): - c = xbee_api.COMMANDS[name] + c = xbee_api.COMMAND_REQUESTS[name] return mock.sentinel.api_frame_data, c[2] api._api_frame = mock.MagicMock(side_effect=mock_api_frame) api._uart.send = mock.MagicMock() - for cmd_name, cmd_opts in xbee_api.COMMANDS.items(): + for cmd_name, cmd_opts in xbee_api.COMMAND_REQUESTS.items(): cmd_id, schema, expect_reply = cmd_opts ret = api._command(cmd_name, mock.sentinel.cmd_data) if expect_reply: @@ -62,28 +63,30 @@ def mock_api_frame(name, *args): assert ret is None assert api._api_frame.call_count == 1 assert api._api_frame.call_args[0][0] == cmd_name - assert api._api_frame.call_args[0][1] == mock.sentinel.cmd_data + assert api._api_frame.call_args[0][1] == api._seq - 1 + assert api._api_frame.call_args[0][2] == mock.sentinel.cmd_data assert api._uart.send.call_count == 1 assert api._uart.send.call_args[0][0] == mock.sentinel.api_frame_data api._api_frame.reset_mock() api._uart.send.reset_mock() - -def test_seq_command(api): - api._command = mock.MagicMock() - api._seq = mock.sentinel.seq - api._seq_command(mock.sentinel.cmd_name, mock.sentinel.args) - assert api._command.call_count == 1 - assert api._command.call_args[0][0] == mock.sentinel.cmd_name - assert api._command.call_args[0][1] == mock.sentinel.seq - assert api._command.call_args[0][2] == mock.sentinel.args + ret = api._command(cmd_name, mock.sentinel.cmd_data, mask_frame_id=True) + assert ret is None + assert api._api_frame.call_count == 1 + assert api._api_frame.call_args[0][0] == cmd_name + assert api._api_frame.call_args[0][1] == 0 + assert api._api_frame.call_args[0][2] == mock.sentinel.cmd_data + assert api._uart.send.call_count == 1 + assert api._uart.send.call_args[0][0] == mock.sentinel.api_frame_data + api._api_frame.reset_mock() + api._uart.send.reset_mock() async def _test_at_or_queued_at_command(api, cmd, monkeypatch, do_reply=True): monkeypatch.setattr(t, 'serialize', mock.MagicMock(return_value=mock.sentinel.serialize)) def mock_command(name, *args): - rsp = xbee_api.COMMANDS[name][2] + rsp = xbee_api.COMMAND_REQUESTS[name][2] ret = None if rsp: ret = asyncio.Future() @@ -99,9 +102,8 @@ def mock_command(name, *args): assert t.serialize.call_count == 1 assert api._command.call_count == 1 assert api._command.call_args[0][0] in ('at', 'queued_at') - assert api._command.call_args[0][1] == mock.sentinel.seq - assert api._command.call_args[0][2] == at_cmd.encode('ascii') - assert api._command.call_args[0][3] == mock.sentinel.serialize + assert api._command.call_args[0][1] == at_cmd.encode('ascii') + assert api._command.call_args[0][2] == mock.sentinel.serialize assert res == mock.sentinel.at_result t.serialize.reset_mock() api._command.reset_mock() @@ -125,12 +127,56 @@ async def test_queued_at_command(api, monkeypatch): await _test_at_or_queued_at_command(api, api._queued_at, monkeypatch) +async def _test_remote_at_command(api, monkeypatch, do_reply=True): + monkeypatch.setattr(t, 'serialize', mock.MagicMock(return_value=mock.sentinel.serialize)) + + def mock_command(name, *args): + rsp = xbee_api.COMMAND_REQUESTS[name][2] + ret = None + if rsp: + ret = asyncio.Future() + if do_reply: + ret.set_result(mock.sentinel.at_result) + return ret + + api._command = mock.MagicMock(side_effect=mock_command) + api._seq = mock.sentinel.seq + + for at_cmd in xbee_api.AT_COMMANDS: + res = await api._remote_at_command( + mock.sentinel.ieee, mock.sentinel.nwk, mock.sentinel.opts, at_cmd, + mock.sentinel.args) + assert t.serialize.call_count == 1 + assert api._command.call_count == 1 + assert api._command.call_args[0][0] == 'remote_at' + assert api._command.call_args[0][1] == mock.sentinel.ieee + assert api._command.call_args[0][2] == mock.sentinel.nwk + assert api._command.call_args[0][3] == mock.sentinel.opts + assert api._command.call_args[0][4] == at_cmd.encode('ascii') + assert api._command.call_args[0][5] == mock.sentinel.serialize + assert res == mock.sentinel.at_result + t.serialize.reset_mock() + api._command.reset_mock() + + +@pytest.mark.asyncio +async def test_remote_at_cmd(api, monkeypatch): + await _test_remote_at_command(api, monkeypatch) + + +@pytest.mark.asyncio +async def test_remote_at_cmd_no_rsp(api, monkeypatch): + monkeypatch.setattr(xbee_api, 'REMOTE_AT_COMMAND_TIMEOUT', 0.1) + with pytest.raises(asyncio.TimeoutError): + await _test_remote_at_command(api, monkeypatch, do_reply=False) + + def test_api_frame(api): ieee = t.EUI64([t.uint8_t(a) for a in range(0, 8)]) - for cmd_name, cmd_opts in xbee_api.COMMANDS.items(): + for cmd_name, cmd_opts in xbee_api.COMMAND_REQUESTS.items(): cmd_id, schema, repl = cmd_opts if schema: - args = [ieee if isinstance(a(), t.EUI64) else a() for a in schema] + args = [ieee if issubclass(a, t.EUI64) else a() for a in schema] frame, repl = api._api_frame(cmd_name, *args) else: frame, repl = api._api_frame(cmd_name) @@ -138,10 +184,22 @@ def test_api_frame(api): def test_frame_received(api, monkeypatch): monkeypatch.setattr(t, 'deserialize', mock.MagicMock( - return_value=(mock.sentinel.deserialize_data, b''))) + return_value=( + [ + mock.sentinel.arg_0, + mock.sentinel.arg_1, + mock.sentinel.arg_2, + mock.sentinel.arg_3, + mock.sentinel.arg_4, + mock.sentinel.arg_5, + mock.sentinel.arg_6, + mock.sentinel.arg_7, + mock.sentinel.arg_8, + ], b'') + )) my_handler = mock.MagicMock() - for cmd, cmd_opts in xbee_api.COMMANDS.items(): + for cmd, cmd_opts in xbee_api.COMMAND_RESPONSES.items(): cmd_id = cmd_opts[0] payload = b'\x01\x02\x03\x04' data = cmd_id.to_bytes(1, 'big') + payload @@ -150,7 +208,10 @@ def test_frame_received(api, monkeypatch): assert t.deserialize.call_count == 1 assert t.deserialize.call_args[0][0] == payload assert my_handler.call_count == 1 - assert my_handler.call_args[0][0] == mock.sentinel.deserialize_data + assert my_handler.call_args[0][0] == mock.sentinel.arg_0 + assert my_handler.call_args[0][1] == mock.sentinel.arg_1 + assert my_handler.call_args[0][2] == mock.sentinel.arg_2 + assert my_handler.call_args[0][3] == mock.sentinel.arg_3 t.deserialize.reset_mock() my_handler.reset_mock() @@ -161,10 +222,10 @@ def test_frame_received_no_handler(api, monkeypatch): my_handler = mock.MagicMock() cmd = 'no_handler' cmd_id = 0x00 - xbee_api.COMMANDS[cmd] = (cmd_id, (), None) + xbee_api.COMMAND_RESPONSES[cmd] = (cmd_id, (), None) api._commands_by_id[cmd_id] = cmd - cmd_opts = xbee_api.COMMANDS[cmd] + cmd_opts = xbee_api.COMMAND_RESPONSES[cmd] cmd_id = cmd_opts[0] payload = b'\x01\x02\x03\x04' data = cmd_id.to_bytes(1, 'big') + payload @@ -178,7 +239,7 @@ def _handle_at_response(api, tsn, status, at_response=b''): data = (tsn, 'AI'.encode('ascii'), status, at_response) response = asyncio.Future() api._awaiting[tsn] = (response, ) - api._handle_at_response(data) + api._handle_at_response(*data) return response @@ -215,9 +276,21 @@ def test_handle_at_response_undef_error(api): assert fut.exception() is not None +def test_handle_remote_at_rsp(api): + api._handle_at_response = mock.MagicMock() + s = mock.sentinel + api._handle_remote_at_response(s.frame_id, s.ieee, s.nwk, s.cmd, + s.status, s.data) + assert api._handle_at_response.call_count == 1 + assert api._handle_at_response.call_args[0][0] == s.frame_id + assert api._handle_at_response.call_args[0][1] == s.cmd + assert api._handle_at_response.call_args[0][2] == s.status + assert api._handle_at_response.call_args[0][3] == s.data + + def _send_modem_event(api, event): api._app = mock.MagicMock(spec=ControllerApplication) - api._handle_modem_status([event]) + api._handle_modem_status(event) assert api._app.handle_modem_status.call_count == 1 assert api._app.handle_modem_status.call_args[0][0] == event @@ -243,15 +316,60 @@ def test_handle_modem_status(api): def test_handle_explicit_rx_indicator(api): - data = b'\x00\x01\x02\x03\x04\x05\x06\x07' + s = mock.sentinel + data = [s.src_ieee, s.src_nwk, s.src_ep, s.dst_ep, s.cluster_id, s.profile, + s.opts, b'abcdef'] api._app = mock.MagicMock() api._app.handle_rx = mock.MagicMock() - api._handle_explicit_rx_indicator(data) + api._handle_explicit_rx_indicator(*data) assert api._app.handle_rx.call_count == 1 -def test_handle_tx_status(api): - api._handle_tx_status(b'\x01\x02\x03\x04') +def _handle_tx_status(api, status, wrong_frame_id=False): + status = t.TXStatus(status) + frame_id = 0x12 + send_fut = mock.MagicMock(spec=asyncio.Future) + api._awaiting[frame_id] = (send_fut, ) + s = mock.sentinel + if wrong_frame_id: + frame_id += 1 + api._handle_tx_status(frame_id, s.dst_nwk, s.retries, status, + t.DiscoveryStatus()) + return send_fut + + +def test_handle_tx_status_success(api): + fut = _handle_tx_status(api, t.TXStatus.SUCCESS) + assert len(api._awaiting) == 0 + assert fut.set_result.call_count == 1 + assert fut.set_exception.call_count == 0 + + +def test_handle_tx_status_except(api): + fut = _handle_tx_status(api, t.TXStatus.ADDRESS_NOT_FOUND) + assert len(api._awaiting) == 0 + assert fut.set_result.call_count == 0 + assert fut.set_exception.call_count == 1 + + +def test_handle_tx_status_unexpected(api): + fut = _handle_tx_status(api, 1, wrong_frame_id=True) + assert len(api._awaiting) == 1 + assert fut.set_result.call_count == 0 + assert fut.set_exception.call_count == 0 + + +def test_handle_tx_status_duplicate(api): + status = t.TXStatus.SUCCESS + frame_id = 0x12 + send_fut = mock.MagicMock(spec=asyncio.Future) + send_fut.set_result.side_effect = asyncio.InvalidStateError + api._awaiting[frame_id] = (send_fut, ) + s = mock.sentinel + api._handle_tx_status(frame_id, s.dst_nwk, s.retries, status, s.disc) + assert len(api._awaiting) == 0 + assert send_fut.set_result.call_count == 1 + assert send_fut.set_exception.call_count == 0 @pytest.mark.asyncio @@ -364,10 +482,11 @@ def test_set_application(api): def test_handle_route_record_indicator(api): - api._handle_route_record_indicator(mock.sentinel.ri) + s = mock.sentinel + api._handle_route_record_indicator(s.ieee, s.src, s.rx_opts, s.hops) def test_handle_many_to_one_rri(api): ieee = t.EUI64([t.uint8_t(a) for a in range(0, 8)]) nwk = 0x1234 - api._handle_many_to_one_rri([ieee, nwk, 0]) + api._handle_many_to_one_rri(ieee, nwk, 0) diff --git a/tests/test_application.py b/tests/test_application.py index 123057a..8bb2ae7 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -3,14 +3,17 @@ import pytest +from zigpy.exceptions import DeliveryError from zigpy.types import EUI64, uint16_t from zigpy_xbee.api import ModemStatus, XBee -from zigpy_xbee.zigbee.application import ControllerApplication +from zigpy_xbee.zigbee import application @pytest.fixture -def app(database_file=None): - return ControllerApplication(XBee(), database_file=database_file) +def app(monkeypatch, database_file=None): + monkeypatch.setattr(application, 'TIMEOUT_TX_STATUS', 0.1) + return application.ControllerApplication(XBee(), + database_file=database_file) def test_modem_status(app): @@ -76,14 +79,41 @@ def test_rx_nwk_0000(app): assert app._handle_reply.call_count == 0 -def test_rx_unknown_device(app): +def test_rx_unknown_device(app, device): + """Unknown NWK, but existing device.""" app._handle_reply = mock.MagicMock() app.handle_message = mock.MagicMock() app.handle_join = mock.MagicMock() - app.get_device = mock.MagicMock(side_effect=KeyError) + dev = device(nwk=0x1234) + app.devices[dev.ieee] = dev + app.get_device = mock.MagicMock(side_effect=[KeyError, dev]) + app.deserialize = mock.MagicMock(side_effect=ValueError) app.handle_rx( b'\x01\x02\x03\x04\x05\x06\x07\x08', - 0x1234, + 0x3334, + mock.sentinel.src_ep, + mock.sentinel.dst_ep, + mock.sentinel.cluster_id, + mock.sentinel.profile_id, + mock.sentinel.rxopts, + b'' + ) + assert app.handle_join.call_count == 1 + assert app.get_device.call_count == 2 + assert app.handle_message.call_count == 0 + assert app._handle_reply.call_count == 0 + + +def test_rx_unknown_device_iee(app): + """Unknown NWK, and unknown IEEE.""" + app._handle_reply = mock.MagicMock() + app.handle_message = mock.MagicMock() + app.handle_join = mock.MagicMock() + app.get_device = mock.MagicMock(side_effect=KeyError) + app.deserialize = mock.MagicMock(side_effect=ValueError) + app.handle_rx( + b'\xff\xff\xff\xff\xff\xff\xff\xff', + 0x3334, mock.sentinel.src_ep, mock.sentinel.dst_ep, mock.sentinel.cluster_id, @@ -223,17 +253,17 @@ async def test_broadcast(app): 0x260, 1, 2, 3, 0x0100, 0x06, 210, b'\x02\x01\x00' ) - app._api._seq_command = mock.MagicMock( + app._api._command = mock.MagicMock( side_effect=asyncio.coroutine(mock.MagicMock()) ) await app.broadcast( profile, cluster, src_ep, dst_ep, grpid, radius, tsn, data) - assert app._api._seq_command.call_count == 1 - assert app._api._seq_command.call_args[0][0] == 'tx_explicit' - assert app._api._seq_command.call_args[0][3] == src_ep - assert app._api._seq_command.call_args[0][4] == dst_ep - assert app._api._seq_command.call_args[0][9] == data + assert app._api._command.call_count == 1 + assert app._api._command.call_args[0][0] == 'tx_explicit' + assert app._api._command.call_args[0][3] == src_ep + assert app._api._command.call_args[0][4] == dst_ep + assert app._api._command.call_args[0][9] == data @pytest.mark.asyncio @@ -266,7 +296,7 @@ async def mock_at_command(cmd, *args): app._api._queued_at = mock.MagicMock(spec=XBee._at_command, side_effect=mock_at_command) app._get_association_state = mock.MagicMock( - spec=ControllerApplication._get_association_state, + spec=application.ControllerApplication._get_association_state, side_effect=asyncio.coroutine(mock.MagicMock(return_value=0x00)) ) @@ -407,19 +437,28 @@ async def test_permit(app): assert app._api._at_command.call_args_list[0][0][1] == time_s -async def _test_request(app, do_reply=True, expect_reply=True, **kwargs): +async def _test_request(app, do_reply=True, expect_reply=True, + send_success=True, send_timeout=False, **kwargs): seq = 123 nwk = 0x2345 ieee = EUI64(b'\x01\x02\x03\x04\x05\x06\x07\x08') app.add_device(ieee, nwk) - def _mock_seq_command(cmdname, ieee, nwk, src_ep, dst_ep, cluster, - profile, radius, options, data): + def _mock_command(cmdname, ieee, nwk, src_ep, dst_ep, cluster, + profile, radius, options, data): + send_fut = asyncio.Future() + if not send_timeout: + if send_success: + send_fut.set_result(True) + else: + send_fut.set_exception(DeliveryError()) + if expect_reply: if do_reply: app._pending[seq].set_result(mock.sentinel.reply_result) + return send_fut - app._api._seq_command = mock.MagicMock(side_effect=_mock_seq_command) + app._api._command = mock.MagicMock(side_effect=_mock_command) return await app.request(nwk, 0x0260, 1, 2, 3, seq, [4, 5, 6], expect_reply=expect_reply, **kwargs) @@ -430,15 +469,27 @@ async def test_request_with_reply(app): @pytest.mark.asyncio async def test_request_expect_no_reply(app): - assert await _test_request(app, False, False, tries=2, timeout=0.1) is None + assert await _test_request(app, False, False, tries=2, timeout=0.1) is True @pytest.mark.asyncio async def test_request_no_reply(app): - with pytest.raises(asyncio.TimeoutError): + with pytest.raises(DeliveryError): await _test_request(app, False, True, tries=2, timeout=0.1) +@pytest.mark.asyncio +async def test_request_send_timeout(app): + with pytest.raises(DeliveryError): + await _test_request(app, False, True, send_timeout=True, tries=2, timeout=0.1) + + +@pytest.mark.asyncio +async def test_request_send_fail(app): + with pytest.raises(DeliveryError): + await _test_request(app, False, True, send_success=False, tries=2, timeout=0.1) + + def _handle_reply(app, tsn): app.handle_message = mock.MagicMock() return app._handle_reply( @@ -497,3 +548,18 @@ async def test_shutdown(app): app._api.close = mock.MagicMock() await app.shutdown() assert app._api.close.call_count == 1 + + +def test_remote_at_cmd(app, device): + dev = device() + app.get_device = mock.MagicMock(return_value=dev) + app._api = mock.MagicMock(spec=XBee) + s = mock.sentinel + app.remote_at_command(s.nwk, s.cmd, s.data, + apply_changes=True, encryption=True) + assert app._api._remote_at_command.call_count == 1 + assert app._api._remote_at_command.call_args[0][0] is dev.ieee + assert app._api._remote_at_command.call_args[0][1] == s.nwk + assert app._api._remote_at_command.call_args[0][2] == 0x22 + assert app._api._remote_at_command.call_args[0][3] == s.cmd + assert app._api._remote_at_command.call_args[0][4] == s.data diff --git a/tests/test_const.py b/tests/test_const.py new file mode 100644 index 0000000..2ec6a1a --- /dev/null +++ b/tests/test_const.py @@ -0,0 +1,6 @@ +import zigpy_xbee.const + + +def test_version(): + assert isinstance(zigpy_xbee.const.__short_version__, str) + assert isinstance(zigpy_xbee.const.__version__, str) diff --git a/tests/test_types.py b/tests/test_types.py index 48cdcd7..b80a2a5 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -62,6 +62,9 @@ class undEnum(t.uint8_t, t.UndefinedEnum): i = undEnum(0xEE) assert i.name == 'UNDEFINED_VALUE' + i = undEnum() + assert i is undEnum.OK + def test_undefined_enum_undefinede(): class undEnum(t.uint8_t, t.UndefinedEnum): diff --git a/zigpy_xbee/api.py b/zigpy_xbee/api.py index dbce120..8c721fa 100644 --- a/zigpy_xbee/api.py +++ b/zigpy_xbee/api.py @@ -1,8 +1,10 @@ import asyncio import binascii import enum +import functools import logging +from zigpy.exceptions import DeliveryError from zigpy.types import LVList from . import uart @@ -11,6 +13,7 @@ LOGGER = logging.getLogger(__name__) AT_COMMAND_TIMEOUT = 2 +REMOTE_AT_COMMAND_TIMEOUT = 30 class ModemStatus(t.uint8_t, t.UndefinedEnum): @@ -28,23 +31,24 @@ class ModemStatus(t.uint8_t, t.UndefinedEnum): # https://www.digi.com/resources/documentation/digidocs/PDFs/90000976.pdf -COMMANDS = { - 'at': (0x08, (t.uint8_t, t.ATCommand, t.Bytes), 0x88), - 'queued_at': (0x09, (t.uint8_t, t.ATCommand, t.Bytes), 0x88), - 'remote_at': (0x17, (), None), +COMMAND_REQUESTS = { + 'at': (0x08, (t.FrameId, t.ATCommand, t.Bytes), 0x88), + 'queued_at': (0x09, (t.FrameId, t.ATCommand, t.Bytes), 0x88), + 'remote_at': (0x17, (t.FrameId, t.EUI64, t.NWK, t.uint8_t, t.ATCommand, t.Bytes), 0x97), 'tx': (0x10, (), None), - 'tx_explicit': (0x11, (t.uint8_t, t.EUI64, t.NWK, t.uint8_t, t.uint8_t, t.uint16_t, t.uint16_t, t.uint8_t, t.uint8_t, t.Bytes), None), - 'create_source_route': (0x21, (t.uint8_t, t.EUI64, t.NWK, t.uint8_t, LVList(t.NWK)), None), + 'tx_explicit': (0x11, (t.FrameId, t.EUI64, t.NWK, t.uint8_t, t.uint8_t, t.uint16_t, t.uint16_t, t.uint8_t, t.uint8_t, t.Bytes), 0x8b), + 'create_source_route': (0x21, (t.FrameId, t.EUI64, t.NWK, t.uint8_t, LVList(t.NWK)), None), 'register_joining_device': (0x24, (), None), - - 'at_response': (0x88, (t.uint8_t, t.ATCommand, t.uint8_t, t.Bytes), None), +} +COMMAND_RESPONSES = { + 'at_response': (0x88, (t.FrameId, t.ATCommand, t.uint8_t, t.Bytes), None), 'modem_status': (0x8A, (ModemStatus, ), None), - 'tx_status': (0x8B, (t.uint8_t, t.NWK, t.uint8_t, t.uint8_t, t.uint8_t), None), + 'tx_status': (0x8B, (t.FrameId, t.NWK, t.uint8_t, t.TXStatus, t.DiscoveryStatus), None), 'route_information': (0x8D, (), None), 'rx': (0x90, (), None), 'explicit_rx_indicator': (0x91, (t.EUI64, t.NWK, t.uint8_t, t.uint8_t, t.uint16_t, t.uint16_t, t.uint8_t, t.Bytes), None), 'rx_io_data_long_addr': (0x92, (), None), - 'remote_at_response': (0x97, (), None), + 'remote_at_response': (0x97, (t.FrameId, t.EUI64, t.NWK, t.ATCommand, t.uint8_t, t.Bytes), None), 'extended_status': (0x98, (), None), 'route_record_indicator': (0xA1, (t.EUI64, t.NWK, t.uint8_t, LVList(t.NWK)), None), 'many_to_one_rri': (0xA3, (t.EUI64, t.NWK, t.uint8_t), None), @@ -183,13 +187,14 @@ class ATCommandResult(enum.IntEnum): ERROR = 1 INVALID_COMMAND = 2 INVALID_PARAMETER = 3 + TX_FAILURE = 4 class XBee: def __init__(self): self._uart = None self._seq = 1 - self._commands_by_id = {v[0]: k for k, v in COMMANDS.items()} + self._commands_by_id = {v[0]: k for k, v in COMMAND_RESPONSES.items()} self._awaiting = {} self._app = None self._cmd_mode_future = None @@ -218,60 +223,62 @@ async def connect(self, device, baudrate=115200): def close(self): return self._uart.close() - def _command(self, name, *args): + def _command(self, name, *args, mask_frame_id=False): LOGGER.debug("Command %s %s", name, args) - data, needs_response = self._api_frame(name, *args) + frame_id = 0 if mask_frame_id else self._seq + data, needs_response = self._api_frame(name, frame_id, *args) self._uart.send(data) future = None - if needs_response: + if needs_response and frame_id: future = asyncio.Future() - self._awaiting[self._seq] = (future, ) + self._awaiting[frame_id] = (future, ) self._seq = (self._seq % 255) + 1 return future - def _seq_command(self, name, *args): - LOGGER.debug("Sequenced command: %s %s", name, args) - return self._command(name, self._seq, *args) - - def _queued_at(self, name, *args): - LOGGER.debug("Queue AT command: %s %s", name, args) + async def _remote_at_command(self, ieee, nwk, options, name, *args): + LOGGER.debug("Remote AT command: %s %s", name, args) data = t.serialize(args, (AT_COMMANDS[name], )) - return self._command( - 'queued_at', - self._seq, - name.encode('ascii'), - data, - ) - - async def _at_command(self, name, *args): - LOGGER.debug("AT command: %s %s", name, args) + try: + return await asyncio.wait_for( + self._command('remote_at', ieee, nwk, options, + name.encode('ascii'), data,), + timeout=REMOTE_AT_COMMAND_TIMEOUT) + except asyncio.TimeoutError: + LOGGER.warning("No response to %s command", name) + raise + + async def _at_partial(self, cmd_type, name, *args): + LOGGER.debug("%s command: %s %s", cmd_type, name, args) data = t.serialize(args, (AT_COMMANDS[name], )) try: return await asyncio.wait_for( - self._command('at', self._seq, name.encode('ascii'), data,), + self._command(cmd_type, name.encode('ascii'), data), timeout=AT_COMMAND_TIMEOUT) except asyncio.TimeoutError: - LOGGER.warning("No response to %s command", name) + LOGGER.warning("%s: No response to %s command", cmd_type, name) raise + _at_command = functools.partialmethod(_at_partial, 'at') + _queued_at = functools.partialmethod(_at_partial, 'queued_at') + def _api_frame(self, name, *args): - c = COMMANDS[name] + c = COMMAND_REQUESTS[name] return (bytes([c[0]]) + t.serialize(args, c[1])), c[2] def frame_received(self, data): command = self._commands_by_id[data[0]] LOGGER.debug("Frame received: %s", command) - data, rest = t.deserialize(data[1:], COMMANDS[command][1]) + data, rest = t.deserialize(data[1:], COMMAND_RESPONSES[command][1]) try: - getattr(self, '_handle_%s' % (command, ))(data) + getattr(self, '_handle_%s' % (command, ))(*data) except AttributeError: LOGGER.error("No '%s' handler. Data: %s", command, binascii.hexlify(data)) - def _handle_at_response(self, data): - fut, = self._awaiting.pop(data[0]) + def _handle_at_response(self, frame_id, cmd, status, value): + fut, = self._awaiting.pop(frame_id) try: - status = ATCommandResult(data[2]) + status = ATCommandResult(status) except ValueError: status = ATCommandResult.ERROR @@ -280,20 +287,26 @@ def _handle_at_response(self, data): RuntimeError("AT Command response: {}".format(status.name))) return - response_type = AT_COMMANDS[data[1].decode('ascii')] - if response_type is None or len(data[3]) == 0: + 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(data[3]) + response, remains = response_type.deserialize(value) fut.set_result(response) - def _handle_many_to_one_rri(self, data): - LOGGER.debug("_handle_many_to_one_rri: %s", data) + def _handle_remote_at_response(self, frame_id, ieee, nwk, cmd, status, value): + """Remote AT command response.""" + LOGGER.debug("Remote AT command response from: %s", + (frame_id, ieee, nwk, cmd, status, value)) + return self._handle_at_response(frame_id, cmd, status, value) + + def _handle_many_to_one_rri(self, ieee, nwk, reserved): + LOGGER.debug("_handle_many_to_one_rri: %s", (ieee, nwk, reserved)) - def _handle_modem_status(self, data): - LOGGER.debug("Handle modem status frame: %s", data) - status = data[0] + def _handle_modem_status(self, status): + LOGGER.debug("Handle modem status frame: %s", status) + status = status if status == ModemStatus.COORDINATOR_STARTED: self.coordinator_started_event.set() elif status in (ModemStatus.HARDWARE_RESET, ModemStatus.WATCHDOG_TIMER_RESET): @@ -305,16 +318,38 @@ def _handle_modem_status(self, data): if self._app: self._app.handle_modem_status(status) - def _handle_explicit_rx_indicator(self, data): - LOGGER.debug("_handle_explicit_rx: opts=%s", data[6]) - self._app.handle_rx(*data) + def _handle_explicit_rx_indicator(self, ieee, nwk, src_ep, + dst_ep, cluster, profile, rx_opts, data): + LOGGER.debug("_handle_explicit_rx: %s", + (ieee, nwk, dst_ep, cluster, rx_opts, + binascii.hexlify(data))) + self._app.handle_rx(ieee, nwk, src_ep, dst_ep, cluster, + profile, rx_opts, data) - def _handle_route_record_indicator(self, data): + def _handle_route_record_indicator(self, ieee, src, rx_opts, hops): """Handle Route Record indicator from a device.""" - LOGGER.debug("_handle_route_record_indicator: %s", data) + LOGGER.debug("_handle_route_record_indicator: %s", + (ieee, src, rx_opts, hops)) - def _handle_tx_status(self, data): - LOGGER.debug("tx_status: %s", data) + def _handle_tx_status(self, frame_id, nwk, tries, tx_status, dsc_status): + LOGGER.debug( + ("tx_explicit to 0x%04x: %s after %i tries. Discovery Status: %s," + " Frame #%i"), nwk, tx_status, tries, dsc_status, frame_id) + try: + fut, = self._awaiting.pop(frame_id) + except KeyError: + LOGGER.debug("unexpected tx_status report received") + return + + try: + if tx_status in (t.TXStatus.SUCCESS, + t.TXStatus.BROADCAST_APS_TX_ATTEMPT): + fut.set_result(tx_status) + else: + fut.set_exception( + DeliveryError('%s' % (tx_status, ))) + except asyncio.InvalidStateError as ex: + LOGGER.debug("duplicate tx_status for %s nwk? State: %s", nwk, ex) def set_application(self, app): self._app = app @@ -388,3 +423,8 @@ async def init_api_mode(self): LOGGER.debug(("Couldn't enter AT command mode at any known baudrate." "Configure XBee manually for escaped API mode ATAP2")) return False + + def __getattr__(self, item): + if item in COMMAND_REQUESTS: + return functools.partial(self._command, item) + raise AttributeError("Unknown command {}".format(item)) diff --git a/zigpy_xbee/const.py b/zigpy_xbee/const.py new file mode 100644 index 0000000..e7f5207 --- /dev/null +++ b/zigpy_xbee/const.py @@ -0,0 +1,6 @@ +"""Constants used by zigpy-xbee.""" +MAJOR_VERSION = 0 +MINOR_VERSION = 2 +PATCH_VERSION = '0' +__short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) +__version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) diff --git a/zigpy_xbee/types.py b/zigpy_xbee/types.py index be3ac13..4555502 100644 --- a/zigpy_xbee/types.py +++ b/zigpy_xbee/types.py @@ -149,9 +149,89 @@ class UndefinedEnum(enum.Enum, metaclass=UndefinedEnumMeta): pass +class FrameId(uint8_t): + pass + + class NWK(uint16_t): def __repr__(self): return '0x{:04x}'.format(self) def __str__(self): return '0x{:04x}'.format(self) + + +UNKNOWN_IEEE = EUI64([uint8_t(0xff) for i in range(0, 8)]) +UNKNOWN_NWK = NWK(0xfffe) + + +class TXStatus(uint8_t, UndefinedEnum): + """TX Status frame.""" + + SUCCESS = 0x00 # Standard + + # all retries are expired and no ACK is received. + # Not returned for Broadcasts + NO_ACK_RECEIVED = 0x01 + CCA_FAILURE = 0x02 + + # Transmission was purged because a coordinator tried to send to an end + # device, but it timed out waiting for a poll from the end device that + # never occurred, this haapens when Coordinator times out of an indirect + # transmission. Timeouse is defines ad 2.5 * 'SP' (Cyclic Sleep Period) + # parameter value + INDIRECT_TX_TIMEOUT = 0x03 + + # invalid destination endpoint + INVALID_DESTINATION_ENDPOINT = 0x15 + + # not returned for Broadcasts + NETWORK_ACK_FAILURE = 0x21 + + # TX failed because end device was not joined to the network + INDIRECT_TX_FAILURE = 0x22 + + # Self addressed + SELF_ADDRESSED = 0x23 + + # Address not found + ADDRESS_NOT_FOUND = 0x24 + + # Route not found + ROUTE_NOT_FOUND = 0x25 + + # Broadcast source failed to hear a neighbor relay the message + BROADCAST_RELAY_FAILURE = 0x26 + + # Invalid binding table index + INVALID_BINDING_IDX = 0x2B + + # Resource error lack of free buffers, timers, and so forth. + NO_RESOURCES = 0x2C + + # Attempted broadcast with APS transmission + BROADCAST_APS_TX_ATTEMPT = 0x2D + + # Attempted unicast with APS transmission, but EE=0 + UNICAST_APS_TX_ATTEMPT = 0x2E + + INTERNAL_ERROR = 0x31 + + # Transmission failed due to resource depletion (for example, out of + # buffers, especially for indirect messages from coordinator) + NO_RESOURCES_2 = 0x32 + + # The payload in the frame was larger than allowed + PAYLOAD_TOO_LARGE = 0x74 + _UNDEFINED = 0x2c + + +class DiscoveryStatus(uint8_t, UndefinedEnum): + """Discovery status of TX Status frame.""" + + SUCCESS = 0x00 + ADDRESS_DISCOVERY = 0x01 + ROUTE_DISCOVERY = 0x02 + ADDRESS_AND_ROUTE = 0x03 + EXTENDED_TIMEOUT = 0x40 + _UNDEFINED = 0x00 diff --git a/zigpy_xbee/uart.py b/zigpy_xbee/uart.py index d76b81f..539820b 100644 --- a/zigpy_xbee/uart.py +++ b/zigpy_xbee/uart.py @@ -22,6 +22,7 @@ def __init__(self, api, connected_future=None): def send(self, data): """Send data, taking care of escaping and framing""" + LOGGER.debug("Sending: %s", data) checksum = bytes([self._checksum(data)]) frame = self.START + self._escape(len(data).to_bytes(2, 'big') + data + checksum) self._transport.write(frame) diff --git a/zigpy_xbee/zigbee/application.py b/zigpy_xbee/zigbee/application.py index d8ff547..063ad43 100644 --- a/zigpy_xbee/zigbee/application.py +++ b/zigpy_xbee/zigbee/application.py @@ -3,14 +3,20 @@ import logging import zigpy.application +import zigpy.exceptions import zigpy.types import zigpy.util +import zigpy.zdo.types + +from zigpy_xbee.types import UNKNOWN_IEEE # how long coordinator would hold message for an end device in 10ms units CONF_CYCLIC_SLEEP_PERIOD = 0x0300 # end device poll timeout = 3 * SN * SP * 10ms CONF_POLL_TIMEOUT = 0x029b +TIMEOUT_TX_STATUS = 120 + LOGGER = logging.getLogger(__name__) @@ -72,6 +78,7 @@ async def startup(self, auto_form=False): LOGGER.debug("Coordinator %s", 'enabled' if ce else 'disabled') except RuntimeError as exc: LOGGER.debug("sending CE command: %s", exc) + self.add_device(self.ieee, self.nwk) async def force_remove(self, dev): """Forcibly remove device from NCP.""" @@ -120,8 +127,7 @@ async def request(self, nwk, profile, cluster, src_ep, dst_ep, sequence, data, e self._pending[sequence] = reply_fut dev = self.get_device(nwk=nwk) - self._api._seq_command( - 'tx_explicit', + send_req = self._api.tx_explicit( dev.ieee, nwk, src_ep, @@ -132,14 +138,41 @@ async def request(self, nwk, profile, cluster, src_ep, dst_ep, sequence, data, e 0x20, data, ) - if not expect_reply: - return try: - return await asyncio.wait_for(reply_fut, timeout) - except asyncio.TimeoutError: + v = await asyncio.wait_for(send_req, timeout=TIMEOUT_TX_STATUS) + except (asyncio.TimeoutError, zigpy.exceptions.DeliveryError) as ex: + LOGGER.debug( + "[0x%04x:%s:0x%04x]: Error sending message: %s", + nwk, dst_ep, cluster, ex) self._pending.pop(sequence, None) - raise + raise zigpy.exceptions.DeliveryError( + "[0x{:04x}:{}:0x{:04x}]: Delivery Error".format(nwk, dst_ep, + cluster)) + if expect_reply: + try: + return await asyncio.wait_for(reply_fut, timeout) + except asyncio.TimeoutError as ex: + LOGGER.debug("[0x%04x:%s:0x%04x]: no reply: %s", + nwk, dst_ep, cluster, ex) + raise zigpy.exceptions.DeliveryError( + "[0x{:04x}:{}:{:04x}]: no reply".format(nwk, dst_ep, + cluster)) + finally: + self._pending.pop(sequence, None) + return v + + @zigpy.util.retryable_request + def remote_at_command(self, nwk, cmd_name, *args, apply_changes=True, + encryption=True): + LOGGER.debug("Remote AT%s command: %s", cmd_name, args) + options = zigpy.types.uint8_t(0) + if apply_changes: + options |= 0x02 + if encryption: + options |= 0x20 + dev = self.get_device(nwk=nwk) + return self._api._remote_at_command(dev.ieee, nwk, options, cmd_name, *args) async def permit_ncp(self, time_s=60): assert 0 <= time_s <= 254 @@ -174,11 +207,15 @@ def handle_rx(self, src_ieee, src_nwk, src_ep, dst_ep, cluster_id, profile_id, r self.handle_join(nwk, ieee, 0) try: - device = self.get_device(ieee=ember_ieee) + device = self.get_device(nwk=src_nwk) except KeyError: - LOGGER.debug("Received frame from unknown device: 0x%04x/%s", - src_nwk, str(ember_ieee)) - return + if ember_ieee != UNKNOWN_IEEE and ember_ieee in self.devices: + self.handle_join(src_nwk, ember_ieee, 0) + device = self.get_device(ieee=ember_ieee) + else: + LOGGER.debug("Received frame from unknown device: 0x%04x/%s", + src_nwk, str(ember_ieee)) + return if device.status == zigpy.device.Status.NEW and dst_ep != 0: # only allow ZDO responses while initializing device @@ -192,7 +229,7 @@ def handle_rx(self, src_ieee, src_nwk, src_ep, dst_ep, cluster_id, profile_id, r try: tsn, command_id, is_reply, args = self.deserialize(device, src_ep, cluster_id, data) except ValueError as e: - LOGGER.error("Failed to parse message (%s) on cluster %d, because %s", binascii.hexlify(data), cluster_id, e) + LOGGER.error("Failed to parse message (%s) on cluster %s, because %s", binascii.hexlify(data), cluster_id, e) return if is_reply: @@ -224,8 +261,7 @@ async def broadcast(self, profile, cluster, src_ep, dst_ep, grpid, radius, broadcast_as_bytes = [ zigpy.types.uint8_t(b) for b in broadcast_address.to_bytes(8, 'big') ] - self._api._seq_command( - 'tx_explicit', + request = self._api.tx_explicit( zigpy.types.EUI64(broadcast_as_bytes), broadcast_address, src_ep, @@ -236,3 +272,4 @@ async def broadcast(self, profile, cluster, src_ep, dst_ep, grpid, radius, 0x20, data, ) + return await asyncio.wait_for(request, timeout=TIMEOUT_TX_STATUS)