Skip to content
Merged
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
18 changes: 18 additions & 0 deletions bellows/ezsp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -778,3 +778,21 @@ async def xncp_get_flow_control_type(self) -> FlowControlType:
"""Get flow control type."""
rsp = await self.send_xncp_frame(xncp.GetFlowControlTypeReq())
return rsp.flow_control_type

async def xncp_get_chip_info(self) -> xncp.GetChipInfoRsp:
"""Get the part number."""
return await self.send_xncp_frame(xncp.GetChipInfoReq())

async def get_default_adapter_concurrency(self) -> int:
"""Get the recommended concurrency based on chip information."""
if FirmwareFeatures.CHIP_INFO not in self._xncp_features:
return 8

chip_info = await self.xncp_get_chip_info()

# Usually 98304 bytes for MG21
if chip_info.ram_size < 100000:

Copilot AI Jul 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic number 100000 should be defined as a named constant to improve maintainability and make the threshold explicit.

Suggested change
if chip_info.ram_size < 100000:
if chip_info.ram_size < RAM_SIZE_THRESHOLD:

Copilot uses AI. Check for mistakes.
return 8
Comment on lines +789 to +795

Copilot AI Jul 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return value 8 should be defined as a named constant (e.g., DEFAULT_CONCURRENCY) to match the pattern used elsewhere and improve maintainability.

Suggested change
return 8
chip_info = await self.xncp_get_chip_info()
# Usually 98304 bytes for MG21
if chip_info.ram_size < 100000:
return 8
return DEFAULT_CONCURRENCY
chip_info = await self.xncp_get_chip_info()
# Usually 98304 bytes for MG21
if chip_info.ram_size < 100000:
return DEFAULT_CONCURRENCY

Copilot uses AI. Check for mistakes.

# Usually 262144 bytes for MG24
return 32

Copilot AI Jul 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return value 32 should be defined as a named constant (e.g., HIGH_RAM_CONCURRENCY) to make the concurrency levels explicit and configurable.

Suggested change
return 32
return HIGH_RAM_CONCURRENCY

Copilot uses AI. Check for mistakes.
16 changes: 16 additions & 0 deletions bellows/ezsp/xncp.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,14 @@ class XncpCommandId(t.enum16):
GET_MFG_TOKEN_OVERRIDE_REQ = 0x0002
GET_BUILD_STRING_REQ = 0x0003
GET_FLOW_CONTROL_TYPE_REQ = 0x0004
GET_CHIP_INFO_REQ = 0x0005

GET_SUPPORTED_FEATURES_RSP = GET_SUPPORTED_FEATURES_REQ | 0x8000
SET_SOURCE_ROUTE_RSP = SET_SOURCE_ROUTE_REQ | 0x8000
GET_MFG_TOKEN_OVERRIDE_RSP = GET_MFG_TOKEN_OVERRIDE_REQ | 0x8000
GET_BUILD_STRING_RSP = GET_BUILD_STRING_REQ | 0x8000
GET_FLOW_CONTROL_TYPE_RSP = GET_FLOW_CONTROL_TYPE_REQ | 0x8000
GET_CHIP_INFO_RSP = GET_CHIP_INFO_REQ | 0x8000

UNKNOWN = 0xFFFF

Expand Down Expand Up @@ -106,6 +108,9 @@ class FirmwareFeatures(t.bitmap32):
# The flow control type (software or hardware) can be queried
FLOW_CONTROL_TYPE = 1 << 4

# Chip info (e.g. name, RAM size) can be read
CHIP_INFO = 1 << 5


class XncpCommandPayload(t.Struct):
pass
Expand Down Expand Up @@ -167,6 +172,17 @@ class GetFlowControlTypeRsp(XncpCommandPayload):
flow_control_type: FlowControlType


@register_command(XncpCommandId.GET_CHIP_INFO_REQ)
class GetChipInfoReq(XncpCommandPayload):
pass


@register_command(XncpCommandId.GET_CHIP_INFO_RSP)
class GetChipInfoRsp(XncpCommandPayload):
ram_size: t.uint32_t
part_number: t.CharacterString


@register_command(XncpCommandId.UNKNOWN)
class Unknown(XncpCommandPayload):
pass
25 changes: 24 additions & 1 deletion bellows/zigbee/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,19 @@ async def start_network(self):
self._multicast = bellows.multicast.Multicast(ezsp)
await self._multicast.startup(ezsp_device)

if self._config[zigpy.config.CONF_MAX_CONCURRENT_REQUESTS] in (
None,
zigpy.config.defaults.CONF_MAX_CONCURRENT_REQUESTS_DEFAULT,
):
max_concurrent_requests = await self._ezsp.get_default_adapter_concurrency()
else:
max_concurrent_requests = self._config[
zigpy.config.CONF_MAX_CONCURRENT_REQUESTS
]

LOGGER.debug("Setting adapter concurrency to %d", max_concurrent_requests)
self._concurrent_requests_semaphore.max_concurrency = max_concurrent_requests

async def load_network_info(self, *, load_devices=False) -> None:
ezsp = self._ezsp

Expand Down Expand Up @@ -310,6 +323,11 @@ async def load_network_info(self, *, load_devices=False) -> None:
else:
flow_control = None

if FirmwareFeatures.CHIP_INFO in ezsp._xncp_features:
chip_info = await ezsp.xncp_get_chip_info()
else:
chip_info = None

self.state.network_info = zigpy.state.NetworkInfo(
source=f"bellows@{LIB_VERSION}",
extended_pan_id=zigpy.types.ExtendedPanId(nwk_params.extendedPanId),
Expand All @@ -327,13 +345,18 @@ async def load_network_info(self, *, load_devices=False) -> None:
stack_specific=stack_specific,
metadata={
"ezsp": {
"chip_info": (
chip_info.as_dict(recursive=True)
if chip_info is not None
else None
),
"stack_version": ezsp.ezsp_version,
"can_burn_userdata_custom_eui64": can_burn_userdata_custom_eui64,
"can_rewrite_custom_eui64": can_rewrite_custom_eui64,
"flow_control": (
flow_control.name.lower() if flow_control is not None else None
),
}
},
},
)

Expand Down
53 changes: 52 additions & 1 deletion tests/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from bellows.exception import ControllerError, EzspError
import bellows.ezsp as ezsp
from bellows.ezsp.v9.commands import GetTokenDataRsp
from bellows.ezsp.xncp import FirmwareFeatures, FlowControlType
from bellows.ezsp.xncp import FirmwareFeatures, FlowControlType, GetChipInfoRsp
import bellows.types
import bellows.types as t
import bellows.types.struct
Expand Down Expand Up @@ -545,7 +545,7 @@
),
)
def test_send_failure(app, aps, ieee, msg_type):
fut = app._pending_requests[(0xBEED, 254)] = asyncio.Future()

Check warning on line 548 in tests/test_application.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.10.8

There is no current event loop

Check warning on line 548 in tests/test_application.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.10.8

There is no current event loop

Check warning on line 548 in tests/test_application.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.10.8

There is no current event loop

Check warning on line 548 in tests/test_application.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.10.8

There is no current event loop

Check warning on line 548 in tests/test_application.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.11.0

There is no current event loop

Check warning on line 548 in tests/test_application.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.11.0

There is no current event loop

Check warning on line 548 in tests/test_application.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.11.0

There is no current event loop

Check warning on line 548 in tests/test_application.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.11.0

There is no current event loop
app.ezsp_callback_handler(
"messageSentHandler", [msg_type, 0xBEED, aps, 254, t.EmberStatus.SUCCESS, b""]
)
Expand Down Expand Up @@ -1786,6 +1786,36 @@
assert app._ezsp._protocol.setMulticastTableEntry.mock_calls == []


@pytest.mark.parametrize(
("concurrency_config", "chip_concurrency", "expected_concurrency"),
[
(None, 32, 32), # Default config (None) uses chip
(8, 16, 16), # Default fallback (8) uses chip
(16, 32, 16), # Explicit config overrides chip
(12, 32, 12), # Low explicit config overrides chip
],
)
async def test_startup_concurrency_setting(
app, ieee, concurrency_config, chip_concurrency, expected_concurrency
):
"""Test that adapter concurrency is set correctly based on configuration."""
app._config[zigpy.config.CONF_MAX_CONCURRENT_REQUESTS] = concurrency_config

with mock_for_startup(app, ieee) as ezsp:
ezsp._xncp_features |= FirmwareFeatures.CHIP_INFO
ezsp.get_default_adapter_concurrency = AsyncMock(return_value=chip_concurrency)
ezsp.xncp_get_chip_info = AsyncMock(
return_value=GetChipInfoRsp(ram_size=0, part_number="")
)

await app.connect()
await app.start_network()

assert (
app._concurrent_requests_semaphore.max_concurrency == expected_concurrency
)


@pytest.mark.parametrize(
"scan_results",
[
Expand Down Expand Up @@ -1964,6 +1994,7 @@
"flow_control": "hardware",
"can_burn_userdata_custom_eui64": True,
"can_rewrite_custom_eui64": True,
"chip_info": None,
}
},
),
Expand Down Expand Up @@ -1999,6 +2030,26 @@
assert app.state.network_info == zigpy_backup.network_info


async def test_load_network_info_chip_info(
app: ControllerApplication,
ieee: zigpy_t.EUI64,
) -> None:
"""Test that chip info is included in network metadata when available."""
app._ezsp._xncp_features |= FirmwareFeatures.CHIP_INFO
expected_chip_info = GetChipInfoRsp(
ram_size=262144, part_number="EFR32MG24A020F1536IM48"
)
app._ezsp.xncp_get_chip_info = AsyncMock(return_value=expected_chip_info)

await app.load_network_info(load_devices=True)

# Check that chip info is included in the metadata
assert app.state.network_info.metadata["ezsp"]["chip_info"] == {
"ram_size": 262144,
"part_number": "EFR32MG24A020F1536IM48",
}


async def test_write_network_info(
app: ControllerApplication,
ieee: zigpy_t.EUI64,
Expand Down
46 changes: 46 additions & 0 deletions tests/test_ezsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -972,3 +972,49 @@ def test_frame_parsing_error_doesnt_disconnect(ezsp_f, caplog):
ezsp_f.frame_received(b"test")

assert "Failed to parse frame" in caplog.text


async def test_xncp_get_chip_info(ezsp_f):
"""Test getting chip info via XNCP."""
ezsp_f._xncp_features = xncp.FirmwareFeatures.CHIP_INFO

# Mock the XNCP response
expected_response = xncp.GetChipInfoRsp(
ram_size=262144, part_number="EFR32MG24A020F1536IM48"
)

with patch.object(
ezsp_f, "send_xncp_frame", new=AsyncMock(return_value=expected_response)
) as mock_send:
result = await ezsp_f.xncp_get_chip_info()

assert result == expected_response
assert mock_send.mock_calls == [call(xncp.GetChipInfoReq())]


@pytest.mark.parametrize(
"chip_info_available,ram_size,part_number,expected_concurrency",
[
(False, None, None, 8), # No chip info feature
(True, 98304, "EFR32MG21A020F1024IM32", 8), # MG21 (low RAM)
(True, 262144, "EFR32MG24A020F1536IM48", 32), # MG24 (high RAM)
],
)
async def test_get_default_adapter_concurrency(
ezsp_f,
chip_info_available: bool,
ram_size: int,
part_number: str,
expected_concurrency: int,
) -> None:
"""Test default concurrency based on chip info availability and RAM size."""
if chip_info_available:
ezsp_f._xncp_features = xncp.FirmwareFeatures.CHIP_INFO
chip_info = xncp.GetChipInfoRsp(ram_size=ram_size, part_number=part_number)
with patch.object(ezsp_f, "xncp_get_chip_info", return_value=chip_info):
result = await ezsp_f.get_default_adapter_concurrency()
else:
ezsp_f._xncp_features = xncp.FirmwareFeatures(0) # No CHIP_INFO feature
result = await ezsp_f.get_default_adapter_concurrency()

assert result == expected_concurrency
Loading