Skip to content

Commit f27b67b

Browse files
authored
Implement new radio API (#2)
* Initial implementation of cross-adapter network backup/restore * Use new zigpy JSON state serialization format * Increment frame counter during restore * Use old frame counter attribute * Make the baudrate configurable * Add an energy scan tool * Upgrade pre-commit deps * Allow energy scans to be performed using non-coordinator devices * Explicitly handle missing modules * Do not unnecessarily call `connect` * Remove `source`, since radio libraries will now provide it * Remove `click` from `setup.py` runtime dependencies * Explicitly import `importlib.util` * Always install the common radio libraries * Provide a way to increment the frame counter during restore * Indicate the current channel in the energy scan
1 parent e919f80 commit f27b67b

File tree

6 files changed

+142
-120
lines changed

6 files changed

+142
-120
lines changed

setup.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from setuptools import setup, find_packages
44

55
import zigpy_cli
6-
import zigpy_cli.common
76

87
setup(
98
name="zigpy-cli",
@@ -18,14 +17,16 @@
1817
entry_points={"console_scripts": ["zigpy=zigpy_cli.__main__:cli"]},
1918
packages=find_packages(exclude=["tests", "tests.*"]),
2019
install_requires=[
21-
"zigpy",
2220
"click",
2321
"coloredlogs",
2422
"scapy",
23+
"zigpy>=0.47.1",
24+
"bellows>=0.31.0",
25+
"zigpy-deconz>=0.18.0",
26+
"zigpy-znp>=0.8.0",
2527
],
2628
extras_require={
2729
# [all] pulls in all radio libraries
28-
"all": zigpy_cli.common.RADIO_TO_PYPI.values(),
2930
"testing": [
3031
"pytest>=5.4.5",
3132
"pytest-asyncio>=0.12.0",

zigpy_cli/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import click
88
import coloredlogs
99

10-
from zigpy_cli.common import LOG_LEVELS
10+
from zigpy_cli.const import LOG_LEVELS
1111

1212
LOGGER = logging.getLogger(__name__)
1313

zigpy_cli/common.py

Lines changed: 0 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,5 @@
1-
import logging
2-
31
import click
42

5-
TRACE = logging.DEBUG - 5
6-
logging.addLevelName(TRACE, "TRACE")
7-
8-
9-
LOG_LEVELS = [logging.WARNING, logging.INFO, logging.DEBUG, TRACE]
10-
11-
12-
RADIO_TO_PACKAGE = {
13-
"ezsp": "bellows",
14-
"deconz": "zigpy_deconz",
15-
"xbee": "zigpy_xbee",
16-
"zigate": "zigpy_zigate",
17-
"znp": "zigpy_znp",
18-
}
19-
20-
21-
RADIO_LOGGING_CONFIGS = {
22-
"ezsp": [
23-
{
24-
"bellows.zigbee.application": logging.INFO,
25-
"bellows.ezsp": logging.INFO,
26-
},
27-
{
28-
"bellows.zigbee.application": logging.DEBUG,
29-
"bellows.ezsp": logging.DEBUG,
30-
},
31-
],
32-
"deconz": [
33-
{
34-
"zigpy_deconz.zigbee.application": logging.INFO,
35-
"zigpy_deconz.api": logging.INFO,
36-
},
37-
{
38-
"zigpy_deconz.zigbee.application": logging.DEBUG,
39-
"zigpy_deconz.api": logging.DEBUG,
40-
},
41-
],
42-
"xbee": [
43-
{
44-
"zigpy_xbee.zigbee.application": logging.INFO,
45-
"zigpy_xbee.api": logging.INFO,
46-
},
47-
{
48-
"zigpy_xbee.zigbee.application": logging.DEBUG,
49-
"zigpy_xbee.api": logging.DEBUG,
50-
},
51-
],
52-
"zigate": [
53-
{
54-
"zigpy_zigate": logging.INFO,
55-
},
56-
{
57-
"zigpy_zigate": logging.DEBUG,
58-
},
59-
],
60-
"znp": [
61-
{
62-
"zigpy_znp": logging.INFO,
63-
},
64-
{
65-
"zigpy_znp": logging.DEBUG,
66-
},
67-
{
68-
"zigpy_znp": TRACE,
69-
},
70-
],
71-
}
72-
73-
RADIO_TO_PYPI = {name: mod.replace("_", "-") for name, mod in RADIO_TO_PACKAGE.items()}
74-
753

764
class HexOrDecIntParamType(click.ParamType):
775
name = "integer"

zigpy_cli/const.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import logging
2+
3+
TRACE = logging.DEBUG - 5
4+
logging.addLevelName(TRACE, "TRACE")
5+
6+
7+
LOG_LEVELS = [logging.WARNING, logging.INFO, logging.DEBUG, TRACE]
8+
9+
10+
RADIO_TO_PACKAGE = {
11+
"ezsp": "bellows",
12+
"deconz": "zigpy_deconz",
13+
"xbee": "zigpy_xbee",
14+
"zigate": "zigpy_zigate",
15+
"znp": "zigpy_znp",
16+
}
17+
18+
19+
RADIO_LOGGING_CONFIGS = {
20+
"ezsp": [
21+
{
22+
"bellows.zigbee.application": logging.INFO,
23+
"bellows.ezsp": logging.INFO,
24+
},
25+
{
26+
"bellows.zigbee.application": logging.DEBUG,
27+
"bellows.ezsp": logging.DEBUG,
28+
},
29+
],
30+
"deconz": [
31+
{
32+
"zigpy_deconz.zigbee.application": logging.INFO,
33+
"zigpy_deconz.api": logging.INFO,
34+
},
35+
{
36+
"zigpy_deconz.zigbee.application": logging.DEBUG,
37+
"zigpy_deconz.api": logging.DEBUG,
38+
},
39+
],
40+
"xbee": [
41+
{
42+
"zigpy_xbee.zigbee.application": logging.INFO,
43+
"zigpy_xbee.api": logging.INFO,
44+
},
45+
{
46+
"zigpy_xbee.zigbee.application": logging.DEBUG,
47+
"zigpy_xbee.api": logging.DEBUG,
48+
},
49+
],
50+
"zigate": [
51+
{
52+
"zigpy_zigate": logging.INFO,
53+
},
54+
{
55+
"zigpy_zigate": logging.DEBUG,
56+
},
57+
],
58+
"znp": [
59+
{
60+
"zigpy_znp": logging.INFO,
61+
},
62+
{
63+
"zigpy_znp": logging.DEBUG,
64+
},
65+
{
66+
"zigpy_znp": TRACE,
67+
},
68+
],
69+
}
70+
71+
RADIO_TO_PYPI = {name: mod.replace("_", "-") for name, mod in RADIO_TO_PACKAGE.items()}

zigpy_cli/radio.py

Lines changed: 66 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,19 @@
11
from __future__ import annotations
22

3+
import json
34
import logging
45
import importlib
56
import collections
7+
import importlib.util
68

79
import click
810
import zigpy.state
911
import zigpy.types
10-
import zigpy.config as conf
1112
import zigpy.zdo.types
1213

1314
from zigpy_cli.cli import cli, click_coroutine
14-
from zigpy_cli.utils import format_bytes
15-
from zigpy_cli.common import (
16-
RADIO_TO_PYPI,
17-
HEX_OR_DEC_INT,
18-
RADIO_TO_PACKAGE,
19-
RADIO_LOGGING_CONFIGS,
20-
)
15+
from zigpy_cli.const import RADIO_TO_PYPI, RADIO_TO_PACKAGE, RADIO_LOGGING_CONFIGS
16+
from zigpy_cli.common import HEX_OR_DEC_INT
2117

2218
LOGGER = logging.getLogger(__name__)
2319

@@ -26,8 +22,9 @@
2622
@click.pass_context
2723
@click.argument("radio", type=click.Choice(list(RADIO_TO_PACKAGE.keys())))
2824
@click.argument("port", type=str)
25+
@click.option("--baudrate", type=int, default=None)
2926
@click_coroutine
30-
async def radio(ctx, radio, port):
27+
async def radio(ctx, radio, port, baudrate=None):
3128
# Setup logging for the radio
3229
verbose = ctx.parent.params["verbose"]
3330
logging_configs = RADIO_LOGGING_CONFIGS[radio]
@@ -36,26 +33,25 @@ async def radio(ctx, radio, port):
3633
for logger, level in logging_config.items():
3734
logging.getLogger(logger).setLevel(level)
3835

39-
# Import the radio library
4036
module = RADIO_TO_PACKAGE[radio] + ".zigbee.application"
4137

42-
try:
43-
radio_module = importlib.import_module(module)
44-
except ImportError:
38+
# Catching just `ImportError` masks dependency errors and is annoying
39+
if importlib.util.find_spec(module) is None:
4540
raise click.ClickException(
4641
f"Radio module for {radio!r} is not installed."
4742
f" Install it with `pip install {RADIO_TO_PYPI[radio]}`."
4843
)
4944

45+
# Import the radio library
46+
radio_module = importlib.import_module(module)
47+
5048
# Start the radio
5149
app_cls = radio_module.ControllerApplication
52-
config = app_cls.SCHEMA(
53-
{
54-
conf.CONF_DEVICE: {
55-
conf.CONF_DEVICE_PATH: port,
56-
},
57-
}
58-
)
50+
config = app_cls.SCHEMA({"device": {"path": port}})
51+
52+
if baudrate is not None:
53+
config["device"]["baudrate"] = baudrate
54+
5955
app = app_cls(config)
6056

6157
ctx.obj = app
@@ -66,36 +62,59 @@ async def radio(ctx, radio, port):
6662
@click_coroutine
6763
async def radio_cleanup(app):
6864
try:
69-
await app.pre_shutdown()
65+
await app.shutdown()
7066
except RuntimeError:
7167
LOGGER.warning("Caught an exception when shutting down app", exc_info=True)
7268

7369

74-
def dump_app_info(app):
75-
if app.pan_id is not None:
76-
print(f"PAN ID: 0x{app.pan_id:04X}")
70+
@radio.command()
71+
@click.pass_obj
72+
@click_coroutine
73+
async def info(app):
74+
await app.connect()
75+
await app.load_network_info(load_devices=False)
76+
77+
print(f"PAN ID: 0x{app.state.network_info.pan_id:04X}")
78+
print(f"Extended PAN ID: {app.state.network_info.extended_pan_id}")
79+
print(f"Channel: {app.state.network_info.channel}")
80+
print(f"Channel mask: {list(app.state.network_info.channel_mask)}")
81+
print(f"NWK update ID: {app.state.network_info.nwk_update_id}")
82+
print(f"Device IEEE: {app.state.node_info.ieee}")
83+
print(f"Device NWK: 0x{app.state.node_info.nwk:04X}")
84+
print(f"Network key: {app.state.network_info.network_key.key}")
85+
print(f"Network key sequence: {app.state.network_info.network_key.seq}")
86+
print(f"Network key counter: {app.state.network_info.network_key.tx_counter}")
7787

78-
print(f"Extended PAN ID: {app.extended_pan_id}")
79-
print(f"Channel: {app.channel}")
8088

81-
if app.channels is not None:
82-
print(f"Channel mask: {list(app.channels)}")
89+
@radio.command()
90+
@click.argument("output", type=click.File("w"))
91+
@click.pass_obj
92+
@click_coroutine
93+
async def backup(app, output):
94+
await app.connect()
95+
await app.load_network_info(load_devices=True)
8396

84-
print(f"NWK update ID: {app.nwk_update_id}")
85-
print(f"Device IEEE: {app.ieee}")
86-
print(f"Device NWK: 0x{app.nwk:04X}")
97+
obj = zigpy.state.network_state_to_json(
98+
network_info=app.state.network_info,
99+
node_info=app.state.node_info,
100+
)
87101

88-
if getattr(app, "network_key", None) is not None:
89-
print(f"Network key: {format_bytes(app.network_key)}")
90-
print(f"Network key sequence: {app.network_key_seq}")
102+
output.write(json.dumps(obj, indent=4))
91103

92104

93105
@radio.command()
106+
@click.argument("input", type=click.File("r"))
107+
@click.option("-c", "--frame-counter-increment", type=int, default=5000)
94108
@click.pass_obj
95109
@click_coroutine
96-
async def info(app):
97-
await app.startup(auto_form=False)
98-
dump_app_info(app)
110+
async def restore(app, frame_counter_increment, input):
111+
obj = json.load(input)
112+
113+
network_info, node_info = zigpy.state.json_to_network_state(obj)
114+
network_info.network_key.tx_counter += frame_counter_increment
115+
116+
await app.connect()
117+
await app.write_network_info(network_info=network_info, node_info=node_info)
99118

100119

101120
@radio.command()
@@ -104,7 +123,6 @@ async def info(app):
104123
async def form(app):
105124
await app.startup(auto_form=True)
106125
await app.form_network()
107-
dump_app_info(app)
108126

109127

110128
@radio.command()
@@ -149,14 +167,23 @@ async def energy_scan(app, nwk):
149167
print(" + TX on 26 in North America may be with lower power due to regulations")
150168
print(" + Zigbee channels 15, 20, 25 fall between WiFi channels 1, 6, 11")
151169
print(" + Some Zigbee devices only join networks on channels 15, 20, and 25")
170+
print(" + Current channel is enclosed in [square brackets]")
152171
print("------------------------------------------------")
153172

154173
for channel, energies in channel_energies.items():
155174
count = sum(energies)
156175
asterisk = "*" if channel == 26 else " "
157176

177+
if channel == app.state.network_info.channel:
178+
bracket_open = "["
179+
bracket_close = "]"
180+
else:
181+
bracket_open = " "
182+
bracket_close = " "
183+
158184
print(
159-
f" - {channel:>02}{asterisk} {count / total:>7.2%} "
185+
f" - {bracket_open}{channel:>02}{asterisk}{bracket_close}"
186+
+ f" {count / total:>7.2%} "
160187
+ "#" * int(100 * count / total)
161188
)
162189

zigpy_cli/utils.py

Lines changed: 0 additions & 5 deletions
This file was deleted.

0 commit comments

Comments
 (0)