Skip to content

Commit ecaf376

Browse files
authored
Conbee III support (#232)
* Initial support for the Conbee III * Add a delay when changing network state * Do not write security mode for Conbee III * Skip restoring neighbors for current CB3 firmwares * Account for EmberZNet ZDO energy scanning bug * Fix logic for neighbor restoration for CB2 * Fix existing unit tests * Add a new unit test for CB3 energy scanning * Add unit test for wrong device state callback handling * Fix new unit tests * Remove unnecessary `_change_network_state` from `load_network_info` * Add a `probe` method * Fix probing schema * Handle state change polling failures gracefully * Bump minimum zigpy version to 0.60.0 * Use new zigpy probing methods * Use zigpy watchdog * Fix API using removed config * Implement `permit_with_link_key` * Remove watchdog from unit tests * Fix unit tests * Parse model info during `load_network_info`
1 parent 7264e6a commit ecaf376

File tree

9 files changed

+274
-248
lines changed

9 files changed

+274
-248
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ license = {text = "GPL-3.0"}
1515
requires-python = ">=3.8"
1616
dependencies = [
1717
"voluptuous",
18-
"zigpy>=0.54.1",
18+
"zigpy>=0.60.0",
1919
'async-timeout; python_version<"3.11"',
2020
]
2121

tests/test_api.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,3 +949,47 @@ async def test_add_neighbour(api, mock_command_rsp):
949949
mac_capability_flags=0x12,
950950
)
951951
]
952+
953+
954+
async def test_cb3_device_state_callback_bug(api, mock_command_rsp):
955+
mock_command_rsp(
956+
command_id=deconz_api.CommandId.version,
957+
params={"reserved": t.uint8_t(0)},
958+
rsp={
959+
"status": deconz_api.Status.SUCCESS,
960+
"frame_length": t.uint16_t(9),
961+
"version": deconz_api.FirmwareVersion(0x26450900),
962+
},
963+
replace=True,
964+
)
965+
966+
await api.connect()
967+
968+
device_state = deconz_api.DeviceState(
969+
network_state=deconz_api.NetworkState2.CONNECTED,
970+
device_state=deconz_api.DeviceStateFlags.APSDE_DATA_CONFIRM,
971+
)
972+
973+
assert api._device_state != device_state
974+
975+
_, rx_schema = deconz_api.COMMAND_SCHEMAS[deconz_api.CommandId.device_state]
976+
api.data_received(
977+
deconz_api.Command(
978+
command_id=deconz_api.CommandId.device_state,
979+
seq=api._seq,
980+
payload=t.serialize_dict(
981+
{
982+
"status": deconz_api.Status.SUCCESS,
983+
"frame_length": t.uint16_t(8),
984+
"device_state": device_state,
985+
"reserved1": t.uint8_t(0),
986+
"reserved2": t.uint8_t(0),
987+
},
988+
rx_schema,
989+
),
990+
).serialize()
991+
)
992+
993+
await asyncio.sleep(0.01)
994+
995+
assert api._device_state == device_state

tests/test_application.py

Lines changed: 81 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import zigpy.application
99
import zigpy.config
1010
import zigpy.device
11-
from zigpy.types import EUI64, Channels
11+
from zigpy.types import EUI64, Channels, KeyData
1212
import zigpy.zdo.types as zdo_t
1313

1414
from zigpy_deconz import types as t
@@ -41,6 +41,7 @@ def api():
4141
return_value=deconz_api.DeviceState(deconz_api.NetworkState.CONNECTED)
4242
)
4343
api.write_parameter = AsyncMock()
44+
api.firmware_version = deconz_api.FirmwareVersion(0x26580700)
4445

4546
# So the protocol version is effectively infinite
4647
api._protocol_version.__ge__.return_value = True
@@ -112,7 +113,7 @@ def addr_nwk_and_ieee(nwk, ieee):
112113
return addr
113114

114115

115-
@patch("zigpy_deconz.zigbee.application.CHANGE_NETWORK_WAIT", 0.001)
116+
@patch("zigpy_deconz.zigbee.application.CHANGE_NETWORK_POLL_TIME", 0.001)
116117
@pytest.mark.parametrize(
117118
"proto_ver, target_state, returned_state",
118119
[
@@ -200,16 +201,12 @@ async def test_connect_failure(app):
200201

201202

202203
async def test_disconnect(app):
203-
reset_watchdog_task = app._reset_watchdog_task = MagicMock()
204204
api_close = app._api.close = MagicMock()
205205

206206
await app.disconnect()
207207

208208
assert app._api is None
209-
assert app._reset_watchdog_task is None
210-
211209
assert api_close.call_count == 1
212-
assert reset_watchdog_task.cancel.call_count == 1
213210

214211

215212
async def test_disconnect_no_api(app):
@@ -224,19 +221,34 @@ async def test_disconnect_close_error(app):
224221
await app.disconnect()
225222

226223

227-
async def test_permit_with_key_not_implemented(app):
228-
with pytest.raises(NotImplementedError):
229-
await app.permit_with_key(node=MagicMock(), code=b"abcdef")
224+
async def test_permit_with_link_key(app):
225+
app._api.write_parameter = AsyncMock()
226+
app.permit = AsyncMock()
227+
228+
await app.permit_with_link_key(
229+
node=t.EUI64.convert("00:11:22:33:44:55:66:77"),
230+
link_key=KeyData.convert("aa:bb:cc:dd:aa:bb:cc:dd:aa:bb:cc:dd:aa:bb:cc:dd"),
231+
)
232+
233+
assert app._api.write_parameter.mock_calls == [
234+
mock.call(
235+
deconz_api.NetworkParameter.link_key,
236+
deconz_api.LinkKey(
237+
ieee=t.EUI64.convert("00:11:22:33:44:55:66:77"),
238+
key=KeyData.convert("aa:bb:cc:dd:aa:bb:cc:dd:aa:bb:cc:dd:aa:bb:cc:dd"),
239+
),
240+
)
241+
]
242+
243+
assert app.permit.mock_calls == [mock.call(mock.ANY)]
230244

231245

232246
async def test_deconz_dev_add_to_group(app, nwk, device_path):
233247
group = MagicMock()
234248
app._groups = MagicMock()
235249
app._groups.add_group.return_value = group
236250

237-
deconz = application.DeconzDevice(
238-
deconz_api.FirmwareVersion(0), device_path, app, sentinel.ieee, nwk
239-
)
251+
deconz = application.DeconzDevice("Conbee II", app, sentinel.ieee, nwk)
240252
deconz.endpoints = {
241253
0: sentinel.zdo,
242254
1: sentinel.ep1,
@@ -254,9 +266,7 @@ async def test_deconz_dev_add_to_group(app, nwk, device_path):
254266
async def test_deconz_dev_remove_from_group(app, nwk, device_path):
255267
group = MagicMock()
256268
app.groups[sentinel.grp_id] = group
257-
deconz = application.DeconzDevice(
258-
deconz_api.FirmwareVersion(0), device_path, app, sentinel.ieee, nwk
259-
)
269+
deconz = application.DeconzDevice("Conbee II", app, sentinel.ieee, nwk)
260270
deconz.endpoints = {
261271
0: sentinel.zdo,
262272
1: sentinel.ep1,
@@ -268,38 +278,16 @@ async def test_deconz_dev_remove_from_group(app, nwk, device_path):
268278

269279

270280
def test_deconz_props(nwk, device_path):
271-
deconz = application.DeconzDevice(
272-
deconz_api.FirmwareVersion(0), device_path, app, sentinel.ieee, nwk
273-
)
281+
deconz = application.DeconzDevice("Conbee II", app, sentinel.ieee, nwk)
274282
assert deconz.manufacturer is not None
275283
assert deconz.model is not None
276284

277285

278-
@pytest.mark.parametrize(
279-
"name, firmware_version, device_path",
280-
[
281-
("ConBee", deconz_api.FirmwareVersion(0x00000500), "/dev/ttyUSB0"),
282-
("ConBee II", deconz_api.FirmwareVersion(0x00000700), "/dev/ttyUSB0"),
283-
("RaspBee", deconz_api.FirmwareVersion(0x00000500), "/dev/ttyS0"),
284-
("RaspBee II", deconz_api.FirmwareVersion(0x00000700), "/dev/ttyS0"),
285-
("RaspBee", deconz_api.FirmwareVersion(0x00000500), "/dev/ttyAMA0"),
286-
("RaspBee II", deconz_api.FirmwareVersion(0x00000700), "/dev/ttyAMA0"),
287-
],
288-
)
289-
def test_deconz_name(nwk, name, firmware_version, device_path):
290-
deconz = application.DeconzDevice(
291-
firmware_version, device_path, app, sentinel.ieee, nwk
292-
)
293-
assert deconz.model == name
294-
295-
296286
async def test_deconz_new(app, nwk, device_path, monkeypatch):
297287
mock_init = AsyncMock()
298288
monkeypatch.setattr(zigpy.device.Device, "_initialize", mock_init)
299289

300-
deconz = await application.DeconzDevice.new(
301-
app, sentinel.ieee, nwk, deconz_api.FirmwareVersion(0), device_path
302-
)
290+
deconz = await application.DeconzDevice.new(app, sentinel.ieee, nwk, "Conbee II")
303291
assert isinstance(deconz, application.DeconzDevice)
304292
assert mock_init.call_count == 1
305293
mock_init.reset_mock()
@@ -311,9 +299,7 @@ async def test_deconz_new(app, nwk, device_path, monkeypatch):
311299
22: MagicMock(),
312300
}
313301
app.devices[sentinel.ieee] = mock_dev
314-
deconz = await application.DeconzDevice.new(
315-
app, sentinel.ieee, nwk, deconz_api.FirmwareVersion(0), device_path
316-
)
302+
deconz = await application.DeconzDevice.new(app, sentinel.ieee, nwk, "Conbee II")
317303
assert isinstance(deconz, application.DeconzDevice)
318304
assert mock_init.call_count == 0
319305

@@ -346,18 +332,21 @@ def test_tx_confirm_unexpcted(app, caplog):
346332

347333
async def test_reset_watchdog(app):
348334
"""Test watchdog."""
349-
with patch.object(app._api, "write_parameter") as mock_api:
350-
dog = asyncio.create_task(app._reset_watchdog())
351-
await asyncio.sleep(0.3)
352-
dog.cancel()
353-
assert mock_api.call_count == 1
335+
app._api.protocol_version = application.PROTO_VER_WATCHDOG
336+
app._api.get_device_state = AsyncMock()
337+
app._api.write_parameter = AsyncMock()
354338

355-
with patch.object(app._api, "write_parameter") as mock_api:
356-
mock_api.side_effect = zigpy_deconz.exception.CommandError
357-
dog = asyncio.create_task(app._reset_watchdog())
358-
await asyncio.sleep(0.3)
359-
dog.cancel()
360-
assert mock_api.call_count == 1
339+
await app._watchdog_feed()
340+
assert len(app._api.get_device_state.mock_calls) == 1
341+
assert len(app._api.write_parameter.mock_calls) == 1
342+
343+
app._api.protocol_version = application.PROTO_VER_WATCHDOG - 1
344+
app._api.get_device_state.reset_mock()
345+
app._api.write_parameter.reset_mock()
346+
347+
await app._watchdog_feed()
348+
assert len(app._api.get_device_state.mock_calls) == 1
349+
assert len(app._api.write_parameter.mock_calls) == 0
361350

362351

363352
async def test_force_remove(app):
@@ -426,11 +415,8 @@ async def test_delayed_scan():
426415
app.topology.scan.assert_called_once_with(devices=[coord])
427416

428417

429-
@patch("zigpy_deconz.zigbee.application.CHANGE_NETWORK_WAIT", 0.001)
430-
@pytest.mark.parametrize("support_watchdog", [False, True])
431-
async def test_change_network_state(app, support_watchdog):
432-
app._reset_watchdog_task = MagicMock()
433-
418+
@patch("zigpy_deconz.zigbee.application.CHANGE_NETWORK_POLL_TIME", 0.001)
419+
async def test_change_network_state(app):
434420
app._api.get_device_state = AsyncMock(
435421
side_effect=[
436422
deconz_api.DeviceState(deconz_api.NetworkState.OFFLINE),
@@ -439,25 +425,11 @@ async def test_change_network_state(app, support_watchdog):
439425
]
440426
)
441427

442-
if support_watchdog:
443-
app._api._protocol_version = application.PROTO_VER_WATCHDOG
444-
app._api.protocol_version = application.PROTO_VER_WATCHDOG
445-
else:
446-
app._api._protocol_version = application.PROTO_VER_WATCHDOG - 1
447-
app._api.protocol_version = application.PROTO_VER_WATCHDOG - 1
448-
449-
old_watchdog_task = app._reset_watchdog_task
450-
cancel_mock = app._reset_watchdog_task.cancel = MagicMock()
428+
app._api._protocol_version = application.PROTO_VER_WATCHDOG
429+
app._api.protocol_version = application.PROTO_VER_WATCHDOG
451430

452431
await app._change_network_state(deconz_api.NetworkState.CONNECTED, timeout=0.01)
453432

454-
if support_watchdog:
455-
assert cancel_mock.call_count == 1
456-
assert app._reset_watchdog_task is not old_watchdog_task
457-
else:
458-
assert cancel_mock.call_count == 0
459-
assert app._reset_watchdog_task is old_watchdog_task
460-
461433

462434
ENDPOINT = zdo_t.SimpleDescriptor(
463435
endpoint=None,
@@ -552,43 +524,14 @@ async def read_param(param_id, index):
552524
)
553525

554526

555-
@patch("zigpy_deconz.zigbee.application.asyncio.sleep", new_callable=AsyncMock)
556-
@patch(
557-
"zigpy_deconz.zigbee.application.ControllerApplication.initialize",
558-
side_effect=[RuntimeError(), None],
559-
)
560-
@patch(
561-
"zigpy_deconz.zigbee.application.ControllerApplication.connect",
562-
side_effect=[RuntimeError(), None, None],
563-
)
564-
async def test_reconnect(mock_connect, mock_initialize, mock_sleep, app):
565-
assert app._reconnect_task is None
566-
app.connection_lost(RuntimeError())
567-
568-
assert app._reconnect_task is not None
569-
await app._reconnect_task
570-
571-
assert mock_connect.call_count == 3
572-
assert mock_initialize.call_count == 2
573-
574-
575-
async def test_disconnect_during_reconnect(app):
576-
assert app._reconnect_task is None
577-
app.connection_lost(RuntimeError())
578-
await asyncio.sleep(0)
579-
await app.disconnect()
580-
581-
assert app._reconnect_task is None
582-
583-
584527
async def test_reset_network_info(app):
585528
app.form_network = AsyncMock()
586529
await app.reset_network_info()
587530

588531
app.form_network.assert_called_once()
589532

590533

591-
async def test_energy_scan(app):
534+
async def test_energy_scan_conbee_2(app):
592535
with mock.patch.object(
593536
zigpy.application.ControllerApplication,
594537
"energy_scan",
@@ -601,6 +544,40 @@ async def test_energy_scan(app):
601544
assert results == {c: c * 3 for c in Channels.ALL_CHANNELS}
602545

603546

547+
async def test_energy_scan_conbee_3(app):
548+
app._api.firmware_version = deconz_api.FirmwareVersion(0x26580900)
549+
550+
type(app)._device = AsyncMock()
551+
552+
app._device.zdo.Mgmt_NWK_Update_req = AsyncMock(
553+
side_effect=zigpy.exceptions.DeliveryError("error")
554+
)
555+
556+
with pytest.raises(zigpy.exceptions.DeliveryError):
557+
await app.energy_scan(channels=Channels.ALL_CHANNELS, duration_exp=0, count=1)
558+
559+
app._device.zdo.Mgmt_NWK_Update_req = AsyncMock(
560+
side_effect=[
561+
asyncio.TimeoutError(),
562+
list(
563+
{
564+
"Status": zdo_t.Status.SUCCESS,
565+
"ScannedChannels": Channels.ALL_CHANNELS,
566+
"TotalTransmissions": 0,
567+
"TransmissionFailures": 0,
568+
"EnergyValues": [i for i in range(11, 26 + 1)],
569+
}.values()
570+
),
571+
]
572+
)
573+
574+
results = await app.energy_scan(
575+
channels=Channels.ALL_CHANNELS, duration_exp=0, count=1
576+
)
577+
578+
assert results == {c: c for c in Channels.ALL_CHANNELS}
579+
580+
604581
async def test_channel_migration(app):
605582
app._api.write_parameter = AsyncMock()
606583
app._change_network_state = AsyncMock()

0 commit comments

Comments
 (0)