Skip to content

Commit

Permalink
Merge pull request #541 from puddly/rc
Browse files Browse the repository at this point in the history
0.35.0 Release
  • Loading branch information
puddly authored Mar 30, 2023
2 parents eec5e1c + 3a09cde commit 06590da
Show file tree
Hide file tree
Showing 14 changed files with 485 additions and 114 deletions.
4 changes: 2 additions & 2 deletions bellows/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
MAJOR_VERSION = 0
MINOR_VERSION = 34
PATCH_VERSION = "10"
MINOR_VERSION = 35
PATCH_VERSION = "0"
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__ = f"{__short_version__}.{PATCH_VERSION}"
25 changes: 15 additions & 10 deletions bellows/ezsp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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):
Expand Down Expand Up @@ -271,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
Expand Down
20 changes: 20 additions & 0 deletions bellows/ezsp/v11/__init__.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions bellows/ezsp/v11/commands.py
Original file line number Diff line number Diff line change
@@ -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()),
),
}
16 changes: 16 additions & 0 deletions bellows/ezsp/v11/config.py
Original file line number Diff line number Diff line change
@@ -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},
}
1 change: 1 addition & 0 deletions bellows/ezsp/v11/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from ..v10.types import * # noqa: F401, F403
35 changes: 29 additions & 6 deletions bellows/zigbee/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import logging
import os
import statistics
from typing import Dict, Optional

import zigpy.application
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -785,17 +807,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
)
Expand Down
48 changes: 0 additions & 48 deletions bellows/zigbee/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
25 changes: 25 additions & 0 deletions bellows/zigbee/util.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import math

import zigpy.state
import zigpy.types as zigpy_t
Expand All @@ -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(
*,
Expand Down Expand Up @@ -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,
)
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions tests/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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())
47 changes: 0 additions & 47 deletions tests/test_application_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading

0 comments on commit 06590da

Please sign in to comment.