Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions bellows/ezsp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,37 @@ async def xncp_set_manual_source_route(
)
)

async def xncp_send_unicast(
self,
destination: t.NWK,
aps_frame: t.EmberApsFrame,
message_tag: t.uint8_t,
data: bytes,
*,
source_route: list[t.NWK] | None = None,
extended_timeout: tuple[t.EUI64, bool] | None = None,
) -> tuple[t.sl_Status, t.uint8_t]:
"""Send a combined unicast command."""
flags = xncp.SendUnicastFlags.NONE
req = xncp.SendUnicastReq(
flags=flags,
destination=destination,
aps_frame=aps_frame,
message_tag=message_tag,
data=xncp.Bytes(data),
)

if extended_timeout is not None:
req.flags |= xncp.SendUnicastFlags.EXTENDED_TIMEOUT
req.ieee, req.extended_timeout = extended_timeout

if source_route is not None:
req.flags |= xncp.SendUnicastFlags.SOURCE_ROUTE
req.source_route = source_route

rsp = await self.send_xncp_frame(req)
return rsp.status, rsp.sequence

async def xncp_get_mfg_token_override(self, token: t.EzspMfgTokenId) -> bytes:
"""Get manufacturing token override."""
rsp = await self.send_xncp_frame(xncp.GetMfgTokenOverrideReq(token=token))
Expand Down
51 changes: 50 additions & 1 deletion bellows/ezsp/xncp.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@

import zigpy.types as t

from bellows.types import EmberStatus, EzspMfgTokenId, RouteRecordStatus
from bellows.types import (
EmberApsFrame,
EmberStatus,
EzspMfgTokenId,
RouteRecordStatus,
sl_Status,
)

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -43,6 +49,7 @@ class XncpCommandId(t.enum16):
SET_ROUTE_TABLE_ENTRY_REQ = 0x0006
GET_ROUTE_TABLE_ENTRY_REQ = 0x0007
GET_TX_POWER_INFO_REQ = 0x0008
SEND_UNICAST_REQ = 0x0009

GET_SUPPORTED_FEATURES_RSP = GET_SUPPORTED_FEATURES_REQ | 0x8000
SET_SOURCE_ROUTE_RSP = SET_SOURCE_ROUTE_REQ | 0x8000
Expand All @@ -53,6 +60,7 @@ class XncpCommandId(t.enum16):
SET_ROUTE_TABLE_ENTRY_RSP = SET_ROUTE_TABLE_ENTRY_REQ | 0x8000
GET_ROUTE_TABLE_ENTRY_RSP = GET_ROUTE_TABLE_ENTRY_REQ | 0x8000
GET_TX_POWER_INFO_RSP = GET_TX_POWER_INFO_REQ | 0x8000
SEND_UNICAST_RSP = SEND_UNICAST_REQ | 0x8000

UNKNOWN = 0xFFFF

Expand Down Expand Up @@ -123,6 +131,10 @@ class FirmwareFeatures(t.bitmap32):
# Recommended and maximum TX power can be queried by country code
TX_POWER_INFO = 1 << 7

# A unicast can be sent, its source route and extended timeout applied, in a
# single command
COMBINED_SEND = 1 << 8


class XncpCommandPayload(t.Struct):
pass
Expand Down Expand Up @@ -233,6 +245,43 @@ class GetTxPowerInfoRsp(XncpCommandPayload):
max_power_dbm: t.int8s


class SendUnicastFlags(t.bitmap8):
NONE = 0

# The extended timeout for the destination should be set before sending
EXTENDED_TIMEOUT = 1 << 0

# A manual source route should be installed before sending
SOURCE_ROUTE = 1 << 1


@register_command(XncpCommandId.SEND_UNICAST_REQ)
class SendUnicastReq(XncpCommandPayload):
flags: SendUnicastFlags
destination: t.NWK
aps_frame: EmberApsFrame
message_tag: t.uint8_t

ieee: t.EUI64 = t.StructField(
requires=lambda s: SendUnicastFlags.EXTENDED_TIMEOUT in s.flags
)
extended_timeout: t.Bool = t.StructField(
requires=lambda s: SendUnicastFlags.EXTENDED_TIMEOUT in s.flags
)

source_route: t.LVList[t.NWK, t.uint8_t] = t.StructField(
requires=lambda s: SendUnicastFlags.SOURCE_ROUTE in s.flags
)

data: Bytes


@register_command(XncpCommandId.SEND_UNICAST_RSP)
class SendUnicastRsp(XncpCommandPayload):
status: sl_Status
sequence: t.uint8_t


@register_command(XncpCommandId.UNKNOWN)
class Unknown(XncpCommandPayload):
pass
91 changes: 64 additions & 27 deletions bellows/zigbee/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@
MESSAGE_SEND_TIMEOUT_MAINS = 3
MESSAGE_SEND_TIMEOUT_BATTERY = 8

# Largest unicast payload that fits in a single combined XNCP send command. The
# `customFrame` payload is bounded (EZSP_MAX_FRAME_LENGTH), so oversized packets
# fall back to the multi-command send path.
MAX_COMBINED_SEND_DATA_LENGTH = 128

COUNTER_EZSP_BUFFERS = "EZSP_FREE_BUFFERS"
COUNTER_NWK_CONFLICTS = "nwk_conflicts"
COUNTER_RESET_REQ = "reset_requests"
Expand Down Expand Up @@ -1026,37 +1031,69 @@ async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None:
try:
async with self._req_lock:
if packet.dst.addr_mode == zigpy.types.AddrMode.NWK:
if device is not None:
await self._ezsp.set_extended_timeout(
nwk=device.nwk,
ieee=device.ieee,
extended_timeout=extended_timeout,
)

if packet.source_route is not None:
if (
# Manual source routes are installed through XNCP; native
# source routes cannot be folded into the combined command
use_manual_source_route = (
packet.source_route is not None
and (
FirmwareFeatures.MANUAL_SOURCE_ROUTE
in self._ezsp._xncp_features
and self.config[CONF_BELLOWS_CONFIG][
CONF_MANUAL_SOURCE_ROUTING
]
):
await self._ezsp.xncp_set_manual_source_route(
destination=packet.dst.address,
route=packet.source_route,
)
else:
await self._ezsp.set_source_route(
nwk=packet.dst.address,
relays=packet.source_route,
)
and self.config[CONF_BELLOWS_CONFIG][
CONF_MANUAL_SOURCE_ROUTING
]
)

if (
FirmwareFeatures.COMBINED_SEND in self._ezsp._xncp_features
and (packet.source_route is None or use_manual_source_route)
and (
len(packet.data.serialize())
<= MAX_COMBINED_SEND_DATA_LENGTH
)
):
status, _ = await self._ezsp.xncp_send_unicast(
destination=packet.dst.address,
aps_frame=aps_frame,
message_tag=message_tag,
data=packet.data.serialize(),
source_route=(
packet.source_route
if use_manual_source_route
else None
),
extended_timeout=(
(device.ieee, extended_timeout)
if device is not None
else None
),
)
else:
if device is not None:
await self._ezsp.set_extended_timeout(
nwk=device.nwk,
ieee=device.ieee,
extended_timeout=extended_timeout,
)

status, _ = await self._ezsp.send_unicast(
nwk=packet.dst.address,
aps_frame=aps_frame,
message_tag=message_tag,
data=packet.data.serialize(),
)
if packet.source_route is not None:
if use_manual_source_route:
await self._ezsp.xncp_set_manual_source_route(
destination=packet.dst.address,
route=packet.source_route,
)
else:
await self._ezsp.set_source_route(
nwk=packet.dst.address,
relays=packet.source_route,
)

status, _ = await self._ezsp.send_unicast(
nwk=packet.dst.address,
aps_frame=aps_frame,
message_tag=message_tag,
data=packet.data.serialize(),
)
elif packet.dst.addr_mode == zigpy.types.AddrMode.Group:
status, _ = await self._ezsp.send_multicast(
aps_frame=aps_frame,
Expand Down
146 changes: 144 additions & 2 deletions tests/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@
import bellows.types.struct
import bellows.uart as uart
import bellows.zigbee.application
from bellows.zigbee.application import DEFAULT_TX_POWER, ControllerApplication
from bellows.zigbee.application import (
DEFAULT_TX_POWER,
MAX_COMBINED_SEND_DATA_LENGTH,
ControllerApplication,
)
import bellows.zigbee.device
from bellows.zigbee.device import EZSPEndpoint, EZSPGroupEndpoint
from bellows.zigbee.util import map_rssi_to_energy
Expand Down Expand Up @@ -827,7 +831,7 @@ def send_unicast(*args, **kwargs):
sequence=packet.tsn,
),
message_tag=sentinel.msg_tag,
data=b"some data",
data=packet.data.serialize(),
)
]

Expand Down Expand Up @@ -918,6 +922,144 @@ async def test_send_packet_unicast_manual_source_route(make_app, packet):
)


async def _test_send_packet_unicast_combined(
app,
packet,
*,
status=bellows.types.sl_Status.OK,
source_route=None,
extended_timeout=None,
options=(
t.EmberApsOption.APS_OPTION_ENABLE_ROUTE_DISCOVERY
| t.EmberApsOption.APS_OPTION_RETRY
),
):
def xncp_send_unicast(*args, **kwargs):
asyncio.get_running_loop().call_later(
0.01,
app.ezsp_callback_handler,
"messageSentHandler",
list(
dict(
type=t.EmberOutgoingMessageType.OUTGOING_DIRECT,
indexOrDestination=0x1234,
apsFrame=sentinel.aps,
messageTag=sentinel.msg_tag,
status=bellows.types.sl_Status.OK,
message=b"",
).values()
),
)

return [status, 0x12]

app._ezsp.xncp_send_unicast = AsyncMock(
side_effect=xncp_send_unicast, spec=app._ezsp.xncp_send_unicast
)
app.get_sequence = MagicMock(return_value=sentinel.msg_tag)

await app.send_packet(packet)

assert app._ezsp.xncp_send_unicast.mock_calls == [
call(
destination=t.EmberNodeId(0x1234),
aps_frame=t.EmberApsFrame(
profileId=packet.profile_id,
clusterId=packet.cluster_id,
sourceEndpoint=packet.src_ep,
destinationEndpoint=packet.dst_ep,
options=options,
groupId=0x0000,
sequence=packet.tsn,
),
message_tag=sentinel.msg_tag,
data=packet.data.serialize(),
source_route=source_route,
extended_timeout=extended_timeout,
)
]

assert len(app._pending_requests) == 0


async def test_send_packet_unicast_combined(app, packet):
app._ezsp._xncp_features |= FirmwareFeatures.COMBINED_SEND
await _test_send_packet_unicast_combined(app, packet)


async def test_send_packet_unicast_combined_extended_timeout(app, ieee, packet):
app._ezsp._xncp_features |= FirmwareFeatures.COMBINED_SEND
app.add_device(nwk=packet.dst.address, ieee=ieee)

# The APS ACK in the packet's tx_options disables the extended timeout
await _test_send_packet_unicast_combined(
app, packet, extended_timeout=(ieee, False)
)


async def test_send_packet_unicast_combined_manual_source_route(make_app, packet):
app = make_app(
{
zigpy.config.CONF_SOURCE_ROUTING: True,
config.CONF_BELLOWS_CONFIG: {config.CONF_MANUAL_SOURCE_ROUTING: True},
}
)
app._ezsp._xncp_features |= (
FirmwareFeatures.COMBINED_SEND | FirmwareFeatures.MANUAL_SOURCE_ROUTE
)

packet.source_route = [0x0001, 0x0002]
await _test_send_packet_unicast_combined(
app,
packet,
source_route=[0x0001, 0x0002],
options=(
t.EmberApsOption.APS_OPTION_RETRY
| t.EmberApsOption.APS_OPTION_ENABLE_ADDRESS_DISCOVERY
),
)


async def test_send_packet_unicast_combined_native_source_route_fallback(
make_app, packet
):
# A native (non-manual) source route cannot be folded into the combined command
app = make_app({zigpy.config.CONF_SOURCE_ROUTING: True})
app._ezsp._xncp_features |= FirmwareFeatures.COMBINED_SEND
app._ezsp._protocol.set_source_route = AsyncMock(
return_value=t.sl_Status.OK, spec=app._ezsp._protocol.set_source_route
)
app._ezsp.xncp_send_unicast = AsyncMock(spec=app._ezsp.xncp_send_unicast)

packet.source_route = [0x0001, 0x0002]
await _test_send_packet_unicast(
app,
packet,
options=(
t.EmberApsOption.APS_OPTION_RETRY
| t.EmberApsOption.APS_OPTION_ENABLE_ADDRESS_DISCOVERY
),
)

assert len(app._ezsp.xncp_send_unicast.mock_calls) == 0
assert app._ezsp._protocol.set_source_route.mock_calls == [
call(nwk=packet.dst.address, relays=[0x0001, 0x0002])
]


async def test_send_packet_unicast_combined_oversized_fallback(app, packet):
# Payloads that do not fit in a custom frame fall back to the multi-command path
app._ezsp._xncp_features |= FirmwareFeatures.COMBINED_SEND
app._ezsp.xncp_send_unicast = AsyncMock(spec=app._ezsp.xncp_send_unicast)

packet = packet.replace(
data=zigpy_t.SerializableBytes(b"a" * (MAX_COMBINED_SEND_DATA_LENGTH + 1))
)
await _test_send_packet_unicast(app, packet)

assert len(app._ezsp.xncp_send_unicast.mock_calls) == 0


async def test_send_packet_unicast_extended_timeout_with_acks(app, ieee, packet):
app.add_device(nwk=packet.dst.address, ieee=ieee)

Expand Down
Loading
Loading