Skip to content

Commit 7d0d18f

Browse files
authored
Zigpy packet capture interface (#664)
* Zigpy packet capture interface * Keep track of the packet capture channel * Compute the timestamp immediately * Bump zigpy * Add a unit test
1 parent adb1577 commit 7d0d18f

File tree

3 files changed

+117
-1
lines changed

3 files changed

+117
-1
lines changed

bellows/zigbee/application.py

+44
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
from datetime import datetime, timezone
45
import logging
56
import os
67
import statistics
@@ -93,6 +94,7 @@ def __init__(self, config: dict):
9394
self._watchdog_feed_counter = 0
9495

9596
self._req_lock = asyncio.Lock()
97+
self._packet_capture_channel: int | None = None
9698

9799
@property
98100
def controller_event(self):
@@ -752,6 +754,48 @@ async def _network_scan(
752754
rssi=lastHopRssi,
753755
)
754756

757+
def _check_status(self, status: t.sl_Status | t.EmberStatus) -> None:
758+
if t.sl_Status.from_ember_status(status) != t.sl_Status.OK:
759+
raise ControllerError(f"Command failed: {status!r}")
760+
761+
async def _packet_capture(self, channel: int):
762+
(status,) = await self._ezsp.mfglibStart(rxCallback=True)
763+
self._check_status(status)
764+
765+
try:
766+
await self._packet_capture_change_channel(channel=channel)
767+
assert self._packet_capture_channel is not None
768+
769+
queue = asyncio.Queue()
770+
771+
with self._ezsp.callback_for_commands(
772+
{"mfglibRxHandler"},
773+
callback=lambda _, response: queue.put_nowait(
774+
(datetime.now(timezone.utc), response)
775+
),
776+
):
777+
while True:
778+
timestamp, (linkQuality, rssi, packetContents) = await queue.get()
779+
780+
# The last two bytes are not a FCS
781+
packetContents = packetContents[:-2]
782+
783+
yield zigpy.types.CapturedPacket(
784+
timestamp=timestamp,
785+
rssi=rssi,
786+
lqi=linkQuality,
787+
channel=self._packet_capture_channel,
788+
data=packetContents,
789+
)
790+
finally:
791+
(status,) = await self._ezsp.mfglibEnd()
792+
self._check_status(status)
793+
794+
async def _packet_capture_change_channel(self, channel: int):
795+
(status,) = await self._ezsp.mfglibSetChannel(channel=channel)
796+
self._check_status(status)
797+
self._packet_capture_channel = channel
798+
755799
async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None:
756800
if not self.is_controller_running:
757801
raise ControllerError("ApplicationController is not running")

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ dependencies = [
1818
"click-log>=0.2.1",
1919
"pure_pcapy3==1.0.1",
2020
"voluptuous",
21-
"zigpy>=0.70.0",
21+
"zigpy>=0.75.0",
2222
'async-timeout; python_version<"3.11"',
2323
]
2424

tests/test_application.py

+72
Original file line numberDiff line numberDiff line change
@@ -2044,3 +2044,75 @@ async def test_network_scan_failure(app: ControllerApplication) -> None:
20442044
channels=t.Channels.from_channel_list([11, 15, 26]), duration_exp=4
20452045
):
20462046
pass
2047+
2048+
2049+
async def test_packet_capture(app: ControllerApplication) -> None:
2050+
app._ezsp._protocol.mfglibStart.return_value = [t.sl_Status.OK]
2051+
app._ezsp._protocol.mfglibSetChannel.return_value = [t.sl_Status.OK]
2052+
app._ezsp._protocol.mfglibEnd.return_value = [t.sl_Status.OK]
2053+
2054+
async def receive_packets() -> None:
2055+
app._ezsp._protocol._handle_callback(
2056+
"mfglibRxHandler",
2057+
list(
2058+
{
2059+
"linkQuality": 150,
2060+
"rssi": -70,
2061+
"packetContents": b"packet 1\xAB\xCD",
2062+
}.values()
2063+
),
2064+
)
2065+
2066+
await asyncio.sleep(0.5)
2067+
2068+
app._ezsp._protocol._handle_callback(
2069+
"mfglibRxHandler",
2070+
list(
2071+
{
2072+
"linkQuality": 200,
2073+
"rssi": -50,
2074+
"packetContents": b"packet 2\xAB\xCD",
2075+
}.values()
2076+
),
2077+
)
2078+
2079+
task = asyncio.create_task(receive_packets())
2080+
packets = []
2081+
2082+
async for packet in app.packet_capture(channel=15):
2083+
packets.append(packet)
2084+
2085+
if len(packets) == 1:
2086+
await app.packet_capture_change_channel(channel=20)
2087+
elif len(packets) == 2:
2088+
break
2089+
2090+
assert packets == [
2091+
zigpy_t.CapturedPacket(
2092+
timestamp=packets[0].timestamp,
2093+
rssi=-70,
2094+
lqi=150,
2095+
channel=15,
2096+
data=b"packet 1",
2097+
),
2098+
zigpy_t.CapturedPacket(
2099+
timestamp=packets[1].timestamp,
2100+
rssi=-50,
2101+
lqi=200,
2102+
channel=20, # The second packet's channel was changed
2103+
data=b"packet 2",
2104+
),
2105+
]
2106+
2107+
await task
2108+
await asyncio.sleep(0.1)
2109+
2110+
assert app._ezsp._protocol.mfglibEnd.mock_calls == [call()]
2111+
2112+
2113+
async def test_packet_capture_failure(app: ControllerApplication) -> None:
2114+
app._ezsp._protocol.mfglibStart.return_value = [t.sl_Status.FAIL]
2115+
2116+
with pytest.raises(zigpy.exceptions.ControllerException):
2117+
async for packet in app.packet_capture(channel=15):
2118+
pass

0 commit comments

Comments
 (0)