Skip to content

Commit adb1577

Browse files
authored
Implement network scanning using the standard zigpy interface (#648)
* Implement existing scan commands on top of `callback_for_commands` * Implement `network_scan` * `duration` -> `duration_exp` * Use underscored method * Add a unit test
1 parent 0948eef commit adb1577

File tree

3 files changed

+176
-14
lines changed

3 files changed

+176
-14
lines changed

bellows/ezsp/__init__.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -223,28 +223,44 @@ async def _list_command(
223223
self, name, item_frames, completion_frame, spos, *args, **kwargs
224224
):
225225
"""Run a command, returning result callbacks as a list"""
226-
fut = asyncio.Future()
226+
queue = asyncio.Queue()
227227
results = []
228228

229-
def cb(frame_name, response):
230-
if frame_name in item_frames:
229+
with self.callback_for_commands(
230+
commands=set(item_frames) | {completion_frame},
231+
callback=lambda command, response: queue.put_nowait((command, response)),
232+
):
233+
v = await self._command(name, *args, **kwargs)
234+
if t.sl_Status.from_ember_status(v[0]) != t.sl_Status.OK:
235+
raise Exception(v)
236+
237+
while True:
238+
command, response = await queue.get()
239+
if command == completion_frame:
240+
if t.sl_Status.from_ember_status(response[spos]) != t.sl_Status.OK:
241+
raise Exception(response)
242+
243+
break
244+
231245
results.append(response)
232-
elif frame_name == completion_frame:
233-
fut.set_result(response)
246+
247+
return results
248+
249+
@contextlib.contextmanager
250+
def callback_for_commands(
251+
self, commands: set[str], callback: Callable
252+
) -> Generator[None]:
253+
def cb(frame_name, response):
254+
if frame_name in commands:
255+
callback(frame_name, response)
234256

235257
cbid = self.add_callback(cb)
258+
236259
try:
237-
v = await self._command(name, *args, **kwargs)
238-
if t.sl_Status.from_ember_status(v[0]) != t.sl_Status.OK:
239-
raise Exception(v)
240-
v = await fut
241-
if t.sl_Status.from_ember_status(v[spos]) != t.sl_Status.OK:
242-
raise Exception(v)
260+
yield
243261
finally:
244262
self.remove_callback(cbid)
245263

246-
return results
247-
248264
startScan = functools.partialmethod(
249265
_list_command,
250266
"startScan",
@@ -253,7 +269,11 @@ def cb(frame_name, response):
253269
1,
254270
)
255271
pollForData = functools.partialmethod(
256-
_list_command, "pollForData", ["pollHandler"], "pollCompleteHandler", 0
272+
_list_command,
273+
"pollForData",
274+
["pollHandler"],
275+
"pollCompleteHandler",
276+
0,
257277
)
258278
zllStartScan = functools.partialmethod(
259279
_list_command,

bellows/zigbee/application.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import statistics
77
import sys
8+
from typing import AsyncGenerator
89

910
if sys.version_info[:2] < (3, 11):
1011
from async_timeout import timeout as asyncio_timeout # pragma: no cover
@@ -711,6 +712,46 @@ async def energy_scan(
711712
for channel in list(channels)
712713
}
713714

715+
async def _network_scan(
716+
self, channels: t.Channels, duration_exp: int
717+
) -> AsyncGenerator[zigpy.types.NetworkBeacon]:
718+
"""Scans for networks and yields network beacons."""
719+
queue = asyncio.Queue()
720+
721+
with self._ezsp.callback_for_commands(
722+
{"networkFoundHandler", "scanCompleteHandler"},
723+
callback=lambda command, response: queue.put_nowait((command, response)),
724+
):
725+
# XXX: replace with normal command invocation once overload is removed
726+
(status,) = await self._ezsp._command(
727+
"startScan",
728+
scanType=t.EzspNetworkScanType.ACTIVE_SCAN,
729+
channelMask=channels,
730+
duration=duration_exp,
731+
)
732+
733+
if t.sl_Status.from_ember_status(status) != t.sl_Status.OK:
734+
raise ControllerError(f"Failed to start scan: {status!r}")
735+
736+
while True:
737+
command, response = await queue.get()
738+
739+
if command == "scanCompleteHandler":
740+
break
741+
742+
(networkFound, lastHopLqi, lastHopRssi) = response
743+
744+
yield zigpy.types.NetworkBeacon(
745+
pan_id=networkFound.panId,
746+
extended_pan_id=networkFound.extendedPanId,
747+
channel=networkFound.channel,
748+
nwk_update_id=networkFound.nwkUpdateId,
749+
permit_joining=bool(networkFound.allowingJoin),
750+
stack_profile=networkFound.stackProfile,
751+
lqi=lastHopLqi,
752+
rssi=lastHopRssi,
753+
)
754+
714755
async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None:
715756
if not self.is_controller_running:
716757
raise ControllerError("ApplicationController is not running")

tests/test_application.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1943,3 +1943,104 @@ async def test_write_network_info(
19431943
)
19441944
)
19451945
]
1946+
1947+
1948+
async def test_network_scan(app: ControllerApplication) -> None:
1949+
app._ezsp._protocol.startScan.return_value = [t.sl_Status.OK]
1950+
1951+
def run_scan() -> None:
1952+
app._ezsp._protocol._handle_callback(
1953+
"networkFoundHandler",
1954+
list(
1955+
{
1956+
"networkFound": t.EmberZigbeeNetwork(
1957+
channel=11,
1958+
panId=zigpy_t.PanId(0x1D13),
1959+
extendedPanId=t.EUI64.convert("00:07:81:00:00:9a:8f:3b"),
1960+
allowingJoin=False,
1961+
stackProfile=2,
1962+
nwkUpdateId=0,
1963+
),
1964+
"lastHopLqi": 152,
1965+
"lastHopRssi": -62,
1966+
}.values()
1967+
),
1968+
)
1969+
app._ezsp._protocol._handle_callback(
1970+
"networkFoundHandler",
1971+
list(
1972+
{
1973+
"networkFound": t.EmberZigbeeNetwork(
1974+
channel=11,
1975+
panId=zigpy_t.PanId(0x2857),
1976+
extendedPanId=t.EUI64.convert("00:07:81:00:00:9a:34:1b"),
1977+
allowingJoin=False,
1978+
stackProfile=2,
1979+
nwkUpdateId=0,
1980+
),
1981+
"lastHopLqi": 136,
1982+
"lastHopRssi": -66,
1983+
}.values()
1984+
),
1985+
)
1986+
app._ezsp._protocol._handle_callback(
1987+
"scanCompleteHandler",
1988+
list(
1989+
{
1990+
"channel": 26,
1991+
"status": t.sl_Status.OK,
1992+
}.values()
1993+
),
1994+
)
1995+
1996+
asyncio.get_running_loop().call_soon(run_scan)
1997+
1998+
results = [
1999+
beacon
2000+
async for beacon in app.network_scan(
2001+
channels=t.Channels.from_channel_list([11, 15, 26]), duration_exp=4
2002+
)
2003+
]
2004+
2005+
assert results == [
2006+
zigpy_t.NetworkBeacon(
2007+
pan_id=0x1D13,
2008+
extended_pan_id=t.EUI64.convert("00:07:81:00:00:9a:8f:3b"),
2009+
channel=11,
2010+
permit_joining=False,
2011+
stack_profile=2,
2012+
nwk_update_id=0,
2013+
lqi=152,
2014+
src=None,
2015+
rssi=-62,
2016+
depth=None,
2017+
router_capacity=None,
2018+
device_capacity=None,
2019+
protocol_version=None,
2020+
),
2021+
zigpy_t.NetworkBeacon(
2022+
pan_id=0x2857,
2023+
extended_pan_id=t.EUI64.convert("00:07:81:00:00:9a:34:1b"),
2024+
channel=11,
2025+
permit_joining=False,
2026+
stack_profile=2,
2027+
nwk_update_id=0,
2028+
lqi=136,
2029+
src=None,
2030+
rssi=-66,
2031+
depth=None,
2032+
router_capacity=None,
2033+
device_capacity=None,
2034+
protocol_version=None,
2035+
),
2036+
]
2037+
2038+
2039+
async def test_network_scan_failure(app: ControllerApplication) -> None:
2040+
app._ezsp._protocol.startScan.return_value = [t.sl_Status.FAIL]
2041+
2042+
with pytest.raises(zigpy.exceptions.ControllerException):
2043+
async for beacon in app.network_scan(
2044+
channels=t.Channels.from_channel_list([11, 15, 26]), duration_exp=4
2045+
):
2046+
pass

0 commit comments

Comments
 (0)