From e05dfd8e52636e54bfb04cda3e2aca97cfd0b244 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 22 Mar 2023 11:56:18 -0400 Subject: [PATCH 1/5] Only wait for send status confirmation for unicasts (#537) --- bellows/zigbee/application.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bellows/zigbee/application.py b/bellows/zigbee/application.py index 9ddc98d6..8a18bb95 100644 --- a/bellows/zigbee/application.py +++ b/bellows/zigbee/application.py @@ -785,17 +785,18 @@ async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None: status, ) + # Only throw a delivery exception for packets sent with NWK addressing. + # https://github.com/home-assistant/core/issues/79832 + # Broadcasts/multicasts don't have ACKs or confirmations either. + if packet.dst.addr_mode != zigpy.types.AddrMode.NWK: + return + # Wait for `messageSentHandler` message send_status, _ = await asyncio.wait_for( req.result, timeout=APS_ACK_TIMEOUT ) - # Only throw a delivery exception for packets sent with NWK addressing - # https://github.com/home-assistant/core/issues/79832 - if ( - send_status != t.EmberStatus.SUCCESS - and packet.dst.addr_mode == zigpy.types.AddrMode.NWK - ): + if send_status != t.EmberStatus.SUCCESS: raise zigpy.exceptions.DeliveryError( f"Failed to deliver message: {send_status!r}", send_status ) From ef8f6917960b10b361b1c9f4c5380678df01957a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 28 Mar 2023 16:51:34 -0400 Subject: [PATCH 2/5] EZSP v11 (#539) * Clone EZSP v10 -> v11 * Implement changed `pollHandler` command definition * Fix broken unit test * Bump latest EZSP version * Inherit types and definition from EZSP v10 --- bellows/ezsp/__init__.py | 5 +- bellows/ezsp/v11/__init__.py | 20 +++ bellows/ezsp/v11/commands.py | 11 ++ bellows/ezsp/v11/config.py | 16 ++ bellows/ezsp/v11/types.py | 1 + tests/test_ezsp_v11.py | 316 +++++++++++++++++++++++++++++++++++ 6 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 bellows/ezsp/v11/__init__.py create mode 100644 bellows/ezsp/v11/commands.py create mode 100644 bellows/ezsp/v11/config.py create mode 100644 bellows/ezsp/v11/types.py create mode 100644 tests/test_ezsp_v11.py diff --git a/bellows/ezsp/__init__.py b/bellows/ezsp/__init__.py index 28fed1e3..630a199e 100644 --- a/bellows/ezsp/__init__.py +++ b/bellows/ezsp/__init__.py @@ -15,9 +15,9 @@ import bellows.types as t import bellows.uart -from . import v4, v5, v6, v7, v8, v9, v10 +from . import v4, v5, v6, v7, v8, v9, v10, v11 -EZSP_LATEST = v10.EZSP_VERSION +EZSP_LATEST = v11.EZSP_VERSION PROBE_TIMEOUT = 3 NETWORK_OPS_TIMEOUT = 10 LOGGER = logging.getLogger(__name__) @@ -38,6 +38,7 @@ class EZSP: v8.EZSP_VERSION: v8.EZSPv8, v9.EZSP_VERSION: v9.EZSPv9, v10.EZSP_VERSION: v10.EZSPv10, + v11.EZSP_VERSION: v11.EZSPv11, } def __init__(self, device_config: Dict): diff --git a/bellows/ezsp/v11/__init__.py b/bellows/ezsp/v11/__init__.py new file mode 100644 index 00000000..158f520e --- /dev/null +++ b/bellows/ezsp/v11/__init__.py @@ -0,0 +1,20 @@ +""""EZSP Protocol version 11 protocol handler.""" +import voluptuous as vol + +import bellows.config + +from . import commands, config, types as v11_types +from ..v10 import EZSPv10 + +EZSP_VERSION = 11 + + +class EZSPv11(EZSPv10): + """EZSP Version 11 Protocol version handler.""" + + COMMANDS = commands.COMMANDS + SCHEMAS = { + bellows.config.CONF_EZSP_CONFIG: vol.Schema(config.EZSP_SCHEMA), + bellows.config.CONF_EZSP_POLICIES: vol.Schema(config.EZSP_POLICIES_SCH), + } + types = v11_types diff --git a/bellows/ezsp/v11/commands.py b/bellows/ezsp/v11/commands.py new file mode 100644 index 00000000..a6675fa6 --- /dev/null +++ b/bellows/ezsp/v11/commands.py @@ -0,0 +1,11 @@ +from . import types as t +from ..v10.commands import COMMANDS as COMMANDS_v10 + +COMMANDS = { + **COMMANDS_v10, + "pollHandler": ( + 0x0044, + (), + tuple({"childId": t.EmberNodeId, "transmitExpected": t.Bool}.values()), + ), +} diff --git a/bellows/ezsp/v11/config.py b/bellows/ezsp/v11/config.py new file mode 100644 index 00000000..8e2b66d0 --- /dev/null +++ b/bellows/ezsp/v11/config.py @@ -0,0 +1,16 @@ +import voluptuous as vol + +from bellows.config import cv_uint16 + +from ..v4.config import EZSP_POLICIES_SHARED +from ..v10 import config as v10_config +from .types import EzspPolicyId + +EZSP_SCHEMA = { + **v10_config.EZSP_SCHEMA, +} + +EZSP_POLICIES_SCH = { + **EZSP_POLICIES_SHARED, + **{vol.Optional(policy.name): cv_uint16 for policy in EzspPolicyId}, +} diff --git a/bellows/ezsp/v11/types.py b/bellows/ezsp/v11/types.py new file mode 100644 index 00000000..9b88dd8a --- /dev/null +++ b/bellows/ezsp/v11/types.py @@ -0,0 +1 @@ +from ..v10.types import * # noqa: F401, F403 diff --git a/tests/test_ezsp_v11.py b/tests/test_ezsp_v11.py new file mode 100644 index 00000000..97b81c5d --- /dev/null +++ b/tests/test_ezsp_v11.py @@ -0,0 +1,316 @@ +import pytest + +import bellows.ezsp.v11 + +from .async_mock import AsyncMock, MagicMock, patch + + +@pytest.fixture +def ezsp_f(): + """EZSP v11 protocol handler.""" + return bellows.ezsp.v11.EZSPv11(MagicMock(), MagicMock()) + + +def test_ezsp_frame(ezsp_f): + ezsp_f._seq = 0x22 + data = ezsp_f._ezsp_frame("version", 11) + assert data == b"\x22\x00\x01\x00\x00\x0b" + + +def test_ezsp_frame_rx(ezsp_f): + """Test receiving a version frame.""" + ezsp_f(b"\x01\x01\x80\x00\x00\x01\x02\x34\x12") + assert ezsp_f._handle_callback.call_count == 1 + assert ezsp_f._handle_callback.call_args[0][0] == "version" + assert ezsp_f._handle_callback.call_args[0][1] == [0x01, 0x02, 0x1234] + + +async def test_set_source_routing(ezsp_f): + """Test setting source routing.""" + with patch.object( + ezsp_f, "setSourceRouteDiscoveryMode", new=AsyncMock() + ) as src_mock: + await ezsp_f.set_source_routing() + assert src_mock.await_count == 1 + + +async def test_pre_permit(ezsp_f): + """Test pre permit.""" + p1 = patch.object(ezsp_f, "setPolicy", new=AsyncMock()) + p2 = patch.object(ezsp_f, "addTransientLinkKey", new=AsyncMock()) + with p1 as pre_permit_mock, p2 as tclk_mock: + await ezsp_f.pre_permit(-1.9) + assert pre_permit_mock.await_count == 2 + assert tclk_mock.await_count == 1 + + +def test_command_frames(ezsp_f): + """Test alphabetical list of frames matches the commands.""" + assert set(ezsp_f.COMMANDS) == set(command_frames) + for name, frame_id in command_frames.items(): + assert ezsp_f.COMMANDS[name][0] == frame_id + assert ezsp_f.COMMANDS_BY_ID[frame_id][0] == name + + +command_frames = { + "addEndpoint": 0x0002, + "addOrUpdateKeyTableEntry": 0x0066, + "addTransientLinkKey": 0x00AF, + "addressTableEntryIsActive": 0x005B, + "aesEncrypt": 0x0094, + "aesMmoHash": 0x006F, + "becomeTrustCenter": 0x0077, + "bindingIsActive": 0x002E, + "bootloadTransmitCompleteHandler": 0x0093, + "broadcastNetworkKeySwitch": 0x0074, + "broadcastNextNetworkKey": 0x0073, + "calculateSmacs": 0x009F, + "calculateSmacs283k1": 0x00EA, + "calculateSmacsHandler": 0x00A0, + "calculateSmacsHandler283k1": 0x00EB, + "callback": 0x0006, + "incomingNetworkStatusHandler": 0x00C4, + "childJoinHandler": 0x0023, + "clearBindingTable": 0x002A, + "clearKeyTable": 0x00B1, + "clearStoredBeacons": 0x003C, + "clearTemporaryDataMaybeStoreLinkKey": 0x00A1, + "clearTemporaryDataMaybeStoreLinkKey283k1": 0x00EE, + "clearTransientLinkKeys": 0x006B, + "counterRolloverHandler": 0x00F2, + "customFrame": 0x0047, + "customFrameHandler": 0x0054, + "dGpSend": 0x00C6, + "dGpSentHandler": 0x00C7, + "debugWrite": 0x0012, + "delayTest": 0x009D, + "deleteBinding": 0x002D, + "dsaSign": 0x00A6, + "dsaSignHandler": 0x00A7, + "dsaVerify": 0x00A3, + "dsaVerify283k1": 0x00B0, + "dsaVerifyHandler": 0x0078, + "dutyCycleHandler": 0x004D, + "echo": 0x0081, + "energyScanRequest": 0x009C, + "energyScanResultHandler": 0x0048, + "eraseKeyTableEntry": 0x0076, + "findAndRejoinNetwork": 0x0021, + "findKeyTableEntry": 0x0075, + "findUnusedPanId": 0x00D3, + "formNetwork": 0x001E, + "generateCbkeKeys": 0x00A4, + "generateCbkeKeys283k1": 0x00E8, + "generateCbkeKeysHandler": 0x009E, + "generateCbkeKeysHandler283k1": 0x00E9, + "getAddressTableRemoteEui64": 0x005E, + "getAddressTableRemoteNodeId": 0x005F, + "getBinding": 0x002C, + "getBeaconClassificationParams": 0x00F3, + "getBindingRemoteNodeId": 0x002F, + "getCertificate": 0x00A5, + "getCertificate283k1": 0x00EC, + "getChildData": 0x004A, + "setChildData": 0x00AC, + "getConfigurationValue": 0x0052, + "getCurrentDutyCycle": 0x004C, + "getCurrentSecurityState": 0x0069, + "getEui64": 0x0026, + "getDutyCycleLimits": 0x004B, + "getDutyCycleState": 0x0035, + "getExtendedTimeout": 0x007F, + "getExtendedValue": 0x0003, + "getFirstBeacon": 0x003D, + "getKey": 0x006A, + "getKeyTableEntry": 0x0071, + "getLibraryStatus": 0x0001, + "getLogicalChannel": 0x00BA, + "getMfgToken": 0x000B, + "getMulticastTableEntry": 0x0063, + "getNeighbor": 0x0079, + "getNeighborFrameCounter": 0x003E, + "setNeighborFrameCounter": 0x00AD, + "getNetworkParameters": 0x0028, + "getNextBeacon": 0x0004, + "getNodeId": 0x0027, + "getNumStoredBeacons": 0x0008, + "getParentChildParameters": 0x0029, + "getParentClassificationEnabled": 0x00F0, + "getPhyInterfaceCount": 0x00FC, + "getPolicy": 0x0056, + "getRadioParameters": 0x00FD, + "setRadioIeee802154CcaMode": 0x0095, + "getRandomNumber": 0x0049, + "getRouteTableEntry": 0x007B, + "getRoutingShortcutThreshold": 0x00D1, + "getSecurityKeyStatus": 0x00CD, + "getSourceRouteTableEntry": 0x00C1, + "getSourceRouteTableFilledSize": 0x00C2, + "getSourceRouteTableTotalSize": 0x00C3, + "getStandaloneBootloaderVersionPlatMicroPhy": 0x0091, + "getTimer": 0x004E, + "getToken": 0x000A, + "getTokenCount": 0x0100, + "getTokenInfo": 0x0101, + "getTokenData": 0x0102, + "setTokenData": 0x0103, + "resetNode": 0x0104, + "getTransientKeyTableEntry": 0x006D, + "getTransientLinkKey": 0x00CE, + "getTrueRandomEntropySource": 0x004F, + "getValue": 0x00AA, + "getXncpInfo": 0x0013, + "getZllPrimaryChannelMask": 0x00D9, + "getZllSecondaryChannelMask": 0x00DA, + "gpProxyTableGetEntry": 0x00C8, + "gpProxyTableLookup": 0x00C0, + "gpProxyTableProcessGpPairing": 0x00C9, + "gpSinkTableClearAll": 0x00E2, + "gpSinkTableFindOrAllocateEntry": 0x00E1, + "gpSinkTableGetEntry": 0x00DD, + "gpSinkTableInit": 0x0070, + "gpSinkTableLookup": 0x00DE, + "gpSinkTableRemoveEntry": 0x00E0, + "gpSinkTableSetEntry": 0x00DF, + "gpepIncomingMessageHandler": 0x00C5, + "idConflictHandler": 0x007C, + "incomingBootloadMessageHandler": 0x0092, + "incomingManyToOneRouteRequestHandler": 0x007D, + "incomingMessageHandler": 0x0045, + "incomingRouteErrorHandler": 0x0080, + "incomingRouteRecordHandler": 0x0059, + "incomingSenderEui64Handler": 0x0062, + "invalidCommand": 0x0058, + "isHubConnected": 0x00E6, + "isUpTimeLong": 0x00E5, + "isZllNetwork": 0x00BE, + "joinNetwork": 0x001F, + "joinNetworkDirectly": 0x003B, + "launchStandaloneBootloader": 0x008F, + "leaveNetwork": 0x0020, + "lookupEui64ByNodeId": 0x0061, + "lookupNodeIdByEui64": 0x0060, + "macFilterMatchMessageHandler": 0x0046, + "macPassthroughMessageHandler": 0x0097, + "maximumPayloadLength": 0x0033, + "messageSentHandler": 0x003F, + "mfglibEnd": 0x0084, + "mfglibGetChannel": 0x008B, + "mfglibGetPower": 0x008D, + "mfglibRxHandler": 0x008E, + "mfglibSendPacket": 0x0089, + "mfglibSetChannel": 0x008A, + "mfglibSetPower": 0x008C, + "mfglibStart": 0x0083, + "mfglibStartStream": 0x0087, + "mfglibStartTone": 0x0085, + "mfglibStopStream": 0x0088, + "mfglibStopTone": 0x0086, + "multiPhySetRadioChannel": 0x00FB, + "multiPhySetRadioPower": 0x00FA, + "multiPhyStart": 0x00F8, + "multiPhyStop": 0x00F9, + "neighborCount": 0x007A, + "networkFoundHandler": 0x001B, + "networkInit": 0x0017, + "networkState": 0x0018, + "noCallbacks": 0x0007, + "nop": 0x0005, + "permitJoining": 0x0022, + "pollCompleteHandler": 0x0043, + "pollForData": 0x0042, + "pollHandler": 0x0044, + "proxyBroadcast": 0x0037, + "rawTransmitCompleteHandler": 0x0098, + "readAndClearCounters": 0x0065, + "readCounters": 0x00F1, + "remoteDeleteBindingHandler": 0x0032, + "remoteSetBindingHandler": 0x0031, + "removeDevice": 0x00A8, + "replaceAddressTableEntry": 0x0082, + "requestLinkKey": 0x0014, + "resetToFactoryDefaults": 0x00CC, + "scanCompleteHandler": 0x001C, + "sendBootloadMessage": 0x0090, + "sendBroadcast": 0x0036, + "sendLinkPowerDeltaRequest": 0x00F7, + "sendManyToOneRouteRequest": 0x0041, + "sendMulticast": 0x0038, + "sendMulticastWithAlias": 0x003A, + "sendPanIdUpdate": 0x0057, + "sendRawMessage": 0x0096, + "sendRawMessageExtended": 0x0051, + "sendReply": 0x0039, + "sendTrustCenterLinkKey": 0x0067, + "sendUnicast": 0x0034, + "setAddressTableRemoteEui64": 0x005C, + "setAddressTableRemoteNodeId": 0x005D, + "setBeaconClassificationParams": 0x00EF, + "setBinding": 0x002B, + "setBindingRemoteNodeId": 0x0030, + "setBrokenRouteErrorCode": 0x0011, + "setChildData": 0x00AC, + "setConcentrator": 0x0010, + "setConfigurationValue": 0x0053, + "setDutyCycleLimitsInStack": 0x0040, + "setExtendedTimeout": 0x007E, + "setHubConnectivity": 0x00E4, + "setInitialSecurityState": 0x0068, + "setKeyTableEntry": 0x0072, + "setLogicalAndRadioChannel": 0x00B9, + "setLongUpTime": 0x00E3, + "setMacPollFailureWaitTime": 0x00F4, + "setManufacturerCode": 0x0015, + "setMfgToken": 0x000C, + "setMulticastTableEntry": 0x0064, + "setNeighborFrameCounter": 0x00AD, + "setParentClassificationEnabled": 0x00E7, + "setPolicy": 0x0055, + "setPowerDescriptor": 0x0016, + "setPreinstalledCbkeData": 0x00A2, + "setPreinstalledCbkeData283k1": 0x00ED, + "setRadioChannel": 0x009A, + "setRadioIeee802154CcaMode": 0x0095, + "setRadioPower": 0x0099, + "setRoutingShortcutThreshold": 0x00D0, + "setSecurityKey": 0x00CA, + "setSecurityParameters": 0x00CB, + "setSourceRouteDiscoveryMode": 0x005A, + "setTimer": 0x000E, + "setToken": 0x0009, + "setValue": 0x00AB, + "setZllAdditionalState": 0x00D6, + "setZllNodeType": 0x00D5, + "setZllPrimaryChannelMask": 0x00DB, + "setZllSecondaryChannelMask": 0x00DC, + "stackStatusHandler": 0x0019, + "stackTokenChangedHandler": 0x000D, + "startScan": 0x001A, + "stopScan": 0x001D, + "switchNetworkKeyHandler": 0x006E, + "timerHandler": 0x000F, + "trustCenterJoinHandler": 0x0024, + "unicastCurrentNetworkKey": 0x0050, + "unicastNwkKeyUpdate": 0x00A9, + "unusedPanIdFoundHandler": 0x00D2, + "updateTcLinkKey": 0x006C, + "version": 0x0000, + "writeNodeData": 0x00FE, + "zigbeeKeyEstablishmentHandler": 0x009B, + "zllAddressAssignmentHandler": 0x00B8, + "zllClearTokens": 0x0025, + "zllGetTokens": 0x00BC, + "zllNetworkFoundHandler": 0x00B6, + "zllNetworkOps": 0x00B2, + "zllOperationInProgress": 0x00D7, + "zllRxOnWhenIdleGetActive": 0x00D8, + "zllScanCompleteHandler": 0x00B7, + "zllSetDataToken": 0x00BD, + "zllSetInitialSecurityState": 0x00B3, + "zllSetNonZllNetwork": 0x00BF, + "zllSetRadioIdleMode": 0x00D4, + "zllSetRxOnWhenIdle": 0x00B5, + "zllSetSecurityStateWithoutKey": 0x00CF, + "zllStartScan": 0x00B4, + "zllTouchLinkTargetHandler": 0x00BB, +} From 0164b5ce1813f83d79a8217fbafad280fcc54db8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Mar 2023 11:30:49 -0400 Subject: [PATCH 3/5] Clean up `formNetwork` callback (#540) --- bellows/ezsp/__init__.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/bellows/ezsp/__init__.py b/bellows/ezsp/__init__.py index 630a199e..a9bab33d 100644 --- a/bellows/ezsp/__init__.py +++ b/bellows/ezsp/__init__.py @@ -272,16 +272,20 @@ def cb(frame_name, response): if frame_name == "stackStatusHandler": fut.set_result(response) - self.add_callback(cb) - v = await self._command("formNetwork", parameters) - if v[0] != self.types.EmberStatus.SUCCESS: - raise Exception(f"Failure forming network: {v}") + cb_id = self.add_callback(cb) + + try: + v = await self._command("formNetwork", parameters) + if v[0] != self.types.EmberStatus.SUCCESS: + raise Exception(f"Failure forming network: {v}") - v = await fut - if v[0] != self.types.EmberStatus.NETWORK_UP: - raise Exception(f"Failure forming network: {v}") + v = await fut + if v[0] != self.types.EmberStatus.NETWORK_UP: + raise Exception(f"Failure forming network: {v}") - return v + return v + finally: + self.remove_callback(cb_id) def frame_received(self, data: bytes) -> None: """Handle a received EZSP frame From 8ad0c59d04f049a537232149da6af6fb20580ee4 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Mar 2023 20:31:21 -0400 Subject: [PATCH 4/5] Logistic LQI calculation (#538) * Use a logistic mapping for RSSI -> LQI instead of truncated linear * Remove defaults from energy scan function * Properly compute the mean * Bump minimum zigpy version * Adjust the midpoint and rate constant * Add unit tests * Fix unit tests --- bellows/zigbee/application.py | 22 +++++++++++++++ bellows/zigbee/device.py | 48 -------------------------------- bellows/zigbee/util.py | 25 +++++++++++++++++ setup.py | 2 +- tests/test_application.py | 44 +++++++++++++++++++++++++++++ tests/test_application_device.py | 47 ------------------------------- tests/test_util.py | 5 ++++ 7 files changed, 97 insertions(+), 96 deletions(-) diff --git a/bellows/zigbee/application.py b/bellows/zigbee/application.py index 8a18bb95..0ebe585b 100644 --- a/bellows/zigbee/application.py +++ b/bellows/zigbee/application.py @@ -3,6 +3,7 @@ import asyncio import logging import os +import statistics from typing import Dict, Optional import zigpy.application @@ -672,6 +673,27 @@ async def _set_source_route( (res,) = await self._ezsp.setSourceRoute(nwk, relays) return res == t.EmberStatus.SUCCESS + async def energy_scan( + self, channels: t.Channels, duration_exp: int, count: int + ) -> dict[int, float]: + all_results = {} + + for _ in range(count): + results = await self._ezsp.startScan( + t.EzspNetworkScanType.ENERGY_SCAN, + channels, + duration_exp, + ) + + for channel, rssi in results: + all_results.setdefault(channel, []).append(rssi) + + # Remap RSSI to LQI + return { + channel: util.remap_rssi_to_lqi(statistics.mean(rssis)) + for channel, rssis in all_results.items() + } + async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None: if not self.is_controller_running: raise ControllerError("ApplicationController is not running") diff --git a/bellows/zigbee/device.py b/bellows/zigbee/device.py index de5efb2f..69952488 100644 --- a/bellows/zigbee/device.py +++ b/bellows/zigbee/device.py @@ -14,25 +14,9 @@ if typing.TYPE_CHECKING: import zigpy.application # pragma: no cover -# Test tone at 8dBm power level produced a max RSSI of -3dB -# -21dB corresponds to 100% LQI on the ZZH! -RSSI_MAX = -21 - -# Grounded antenna and then shielded produced a min RSSI of -92 -# -89dB corresponds to 0% LQI on the ZZH! -RSSI_MIN = -89 - LOGGER = logging.getLogger(__name__) -def clamp(v: float, minimum: float, maximum: float) -> float: - """ - Restricts `v` to be between `minimum` and `maximum`. - """ - - return min(max(minimum, v), maximum) - - class EZSPEndpoint(zigpy.endpoint.Endpoint): @property def manufacturer(self) -> str: @@ -83,38 +67,6 @@ def make_zdo_reply(self, cmd: zdo_t.ZDOCmd, **kwargs): """ return list(kwargs.values()) - @zigpy.util.retryable_request - async def request(self, command, *args, use_ieee=False): - if ( - command == zdo_t.ZDOCmd.Mgmt_NWK_Update_req - and args[0].ScanDuration < zdo_t.NwkUpdate.CHANNEL_CHANGE_REQ - ): - return await self._ezsp_energy_scan(*args) - - return await super().request(command, *args, use_ieee=use_ieee) - - async def _ezsp_energy_scan(self, NwkUpdate: zdo_t.NwkUpdate): - results = await self.app._ezsp.startScan( - t.EzspNetworkScanType.ENERGY_SCAN, - NwkUpdate.ScanChannels, - NwkUpdate.ScanDuration, - ) - - # Linearly remap RSSI to LQI - rescaled_values = [ - int(100 * (clamp(v, RSSI_MIN, RSSI_MAX) - RSSI_MIN) / (RSSI_MAX - RSSI_MIN)) - for _, v in results - ] - - return self.make_zdo_reply( - cmd=zdo_t.ZDOCmd.Mgmt_NWK_Update_rsp, - Status=zdo_t.Status.SUCCESS, - ScannedChannels=NwkUpdate.ScanChannels, - TotalTransmissions=0, - TransmissionFailures=0, - EnergyValues=rescaled_values, - ) - class EZSPCoordinator(zigpy.device.Device): """Zigpy Device representing Coordinator.""" diff --git a/bellows/zigbee/util.py b/bellows/zigbee/util.py index 040ae8d6..33aa7005 100644 --- a/bellows/zigbee/util.py +++ b/bellows/zigbee/util.py @@ -1,4 +1,5 @@ import logging +import math import zigpy.state import zigpy.types as zigpy_t @@ -8,6 +9,14 @@ LOGGER = logging.getLogger(__name__) +# Test tone at 8dBm power level produced a max RSSI of -3dB +# -21dB corresponds to 100% LQI on the ZZH! +RSSI_MAX = -5 + +# Grounded antenna and then shielded produced a min RSSI of -92 +# -89dB corresponds to 0% LQI on the ZZH! +RSSI_MIN = -92 + def zha_security( *, @@ -94,3 +103,19 @@ def zigpy_key_to_ezsp_key(zigpy_key: zigpy.state.Key, ezsp): key.bitmask |= ezsp.types.EmberKeyStructBitmask.KEY_HAS_PARTNER_EUI64 return key + + +def logistic(x: float, *, L: float = 1, x_0: float = 0, k: float = 1) -> float: + """Logistic function.""" + return L / (1 + math.exp(-k * (x - x_0))) + + +def remap_rssi_to_lqi(rssi: int) -> float: + """Remaps RSSI (in dBm) to LQI (0-255).""" + + return logistic( + x=rssi, + L=255, + x_0=RSSI_MIN + 0.45 * (RSSI_MAX - RSSI_MIN), + k=0.13, + ) diff --git a/setup.py b/setup.py index e979dfc2..05b610d0 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ "click-log>=0.2.1", "pure_pcapy3==1.0.1", "voluptuous", - "zigpy>=0.52.0", + "zigpy>=0.54.0", ], dependency_links=[ "https://codeload.github.com/rcloran/pure-pcapy-3/zip/master", diff --git a/tests/test_application.py b/tests/test_application.py index 78f01839..58c12c96 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -125,6 +125,7 @@ async def mock_leave(*args, **kwargs): ezsp_mock.networkInit = AsyncMock(return_value=[init]) ezsp_mock.getNetworkParameters = AsyncMock(return_value=[0, nwk_type, nwk_params]) ezsp_mock.can_write_custom_eui64 = AsyncMock(return_value=True) + ezsp_mock.startScan = AsyncMock(return_value=[[c, 1] for c in range(11, 26 + 1)]) if board_info: ezsp_mock.get_board_info = AsyncMock( @@ -1659,3 +1660,46 @@ async def test_startup_source_routing(make_app, ieee, enable_source_routing): assert mock_device.relays is None else: assert mock_device.relays is sentinel.relays + + +@pytest.mark.parametrize( + "scan_results", + [ + # Normal + [ + -39, + -30, + -26, + -33, + -23, + -53, + -42, + -46, + -69, + -75, + -49, + -67, + -57, + -81, + -29, + -40, + ], + # Maximum + [10] * (26 - 11 + 1), + # Minimum + [-200] * (26 - 11 + 1), + ], +) +async def test_energy_scanning(app, scan_results): + app._ezsp.startScan = AsyncMock( + return_value=list(zip(range(11, 26 + 1), scan_results)) + ) + + results = await app.energy_scan( + channels=t.Channels.ALL_CHANNELS, + duration_exp=2, + count=1, + ) + + assert set(results.keys()) == set(t.Channels.ALL_CHANNELS) + assert all(0 <= v <= 255 for v in results.values()) diff --git a/tests/test_application_device.py b/tests/test_application_device.py index 56fbf8a9..7814cdae 100644 --- a/tests/test_application_device.py +++ b/tests/test_application_device.py @@ -30,50 +30,3 @@ def app_and_coordinator(app): coordinator.endpoints[1].status = zigpy.endpoint.Status.ZDO_INIT return app, coordinator - - -@pytest.mark.parametrize( - "scan_results", - [ - # Normal - [ - -39, - -30, - -26, - -33, - -23, - -53, - -42, - -46, - -69, - -75, - -49, - -67, - -57, - -81, - -29, - -40, - ], - # Maximum - [1] * (26 - 11), - # Minimum - [-200] * (26 - 11), - ], -) -async def test_energy_scanning(app_and_coordinator, scan_results): - app, coordinator = app_and_coordinator - - app._ezsp.startScan = AsyncMock( - return_value=list(zip(range(11, 26 + 1), scan_results)) - ) - - _, scanned_channels, *_, energy_values = await coordinator.zdo.Mgmt_NWK_Update_req( - zdo_t.NwkUpdate( - ScanChannels=t.Channels.ALL_CHANNELS, - ScanDuration=0x02, - ScanCount=1, - ) - ) - - assert scanned_channels == t.Channels.ALL_CHANNELS - assert len(energy_values) == len(scan_results) diff --git a/tests/test_util.py b/tests/test_util.py index 1f10a050..80e1e59c 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -143,3 +143,8 @@ def test_ezsp_key_to_zigpy_key(zigpy_key, ezsp_key, ezsp_mock): def test_zigpy_key_to_ezsp_key(zigpy_key, ezsp_key, ezsp_mock): assert util.zigpy_key_to_ezsp_key(zigpy_key, ezsp_mock) == ezsp_key + + +def test_remap_rssi_to_lqi(): + assert 0 <= util.remap_rssi_to_lqi(-200) <= 0.01 + assert 254 <= util.remap_rssi_to_lqi(100) <= 255 From 76a79f515f406f1a32bbad98a7fc4bdaafce727e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Mar 2023 21:31:04 -0400 Subject: [PATCH 5/5] 0.35.0 version bump --- bellows/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bellows/__init__.py b/bellows/__init__.py index 52cdb99d..a0e39e79 100644 --- a/bellows/__init__.py +++ b/bellows/__init__.py @@ -1,5 +1,5 @@ MAJOR_VERSION = 0 MINOR_VERSION = 35 -PATCH_VERSION = "0.dev0" +PATCH_VERSION = "0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}"