Skip to content

Commit

Permalink
Implement new radio API (#2)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
puddly authored Jun 28, 2022
1 parent e919f80 commit f27b67b
Show file tree
Hide file tree
Showing 6 changed files with 142 additions and 120 deletions.
7 changes: 4 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from setuptools import setup, find_packages

import zigpy_cli
import zigpy_cli.common

setup(
name="zigpy-cli",
Expand All @@ -18,14 +17,16 @@
entry_points={"console_scripts": ["zigpy=zigpy_cli.__main__:cli"]},
packages=find_packages(exclude=["tests", "tests.*"]),
install_requires=[
"zigpy",
"click",
"coloredlogs",
"scapy",
"zigpy>=0.47.1",
"bellows>=0.31.0",
"zigpy-deconz>=0.18.0",
"zigpy-znp>=0.8.0",
],
extras_require={
# [all] pulls in all radio libraries
"all": zigpy_cli.common.RADIO_TO_PYPI.values(),
"testing": [
"pytest>=5.4.5",
"pytest-asyncio>=0.12.0",
Expand Down
2 changes: 1 addition & 1 deletion zigpy_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import click
import coloredlogs

from zigpy_cli.common import LOG_LEVELS
from zigpy_cli.const import LOG_LEVELS

LOGGER = logging.getLogger(__name__)

Expand Down
72 changes: 0 additions & 72 deletions zigpy_cli/common.py
Original file line number Diff line number Diff line change
@@ -1,77 +1,5 @@
import logging

import click

TRACE = logging.DEBUG - 5
logging.addLevelName(TRACE, "TRACE")


LOG_LEVELS = [logging.WARNING, logging.INFO, logging.DEBUG, TRACE]


RADIO_TO_PACKAGE = {
"ezsp": "bellows",
"deconz": "zigpy_deconz",
"xbee": "zigpy_xbee",
"zigate": "zigpy_zigate",
"znp": "zigpy_znp",
}


RADIO_LOGGING_CONFIGS = {
"ezsp": [
{
"bellows.zigbee.application": logging.INFO,
"bellows.ezsp": logging.INFO,
},
{
"bellows.zigbee.application": logging.DEBUG,
"bellows.ezsp": logging.DEBUG,
},
],
"deconz": [
{
"zigpy_deconz.zigbee.application": logging.INFO,
"zigpy_deconz.api": logging.INFO,
},
{
"zigpy_deconz.zigbee.application": logging.DEBUG,
"zigpy_deconz.api": logging.DEBUG,
},
],
"xbee": [
{
"zigpy_xbee.zigbee.application": logging.INFO,
"zigpy_xbee.api": logging.INFO,
},
{
"zigpy_xbee.zigbee.application": logging.DEBUG,
"zigpy_xbee.api": logging.DEBUG,
},
],
"zigate": [
{
"zigpy_zigate": logging.INFO,
},
{
"zigpy_zigate": logging.DEBUG,
},
],
"znp": [
{
"zigpy_znp": logging.INFO,
},
{
"zigpy_znp": logging.DEBUG,
},
{
"zigpy_znp": TRACE,
},
],
}

RADIO_TO_PYPI = {name: mod.replace("_", "-") for name, mod in RADIO_TO_PACKAGE.items()}


class HexOrDecIntParamType(click.ParamType):
name = "integer"
Expand Down
71 changes: 71 additions & 0 deletions zigpy_cli/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import logging

TRACE = logging.DEBUG - 5
logging.addLevelName(TRACE, "TRACE")


LOG_LEVELS = [logging.WARNING, logging.INFO, logging.DEBUG, TRACE]


RADIO_TO_PACKAGE = {
"ezsp": "bellows",
"deconz": "zigpy_deconz",
"xbee": "zigpy_xbee",
"zigate": "zigpy_zigate",
"znp": "zigpy_znp",
}


RADIO_LOGGING_CONFIGS = {
"ezsp": [
{
"bellows.zigbee.application": logging.INFO,
"bellows.ezsp": logging.INFO,
},
{
"bellows.zigbee.application": logging.DEBUG,
"bellows.ezsp": logging.DEBUG,
},
],
"deconz": [
{
"zigpy_deconz.zigbee.application": logging.INFO,
"zigpy_deconz.api": logging.INFO,
},
{
"zigpy_deconz.zigbee.application": logging.DEBUG,
"zigpy_deconz.api": logging.DEBUG,
},
],
"xbee": [
{
"zigpy_xbee.zigbee.application": logging.INFO,
"zigpy_xbee.api": logging.INFO,
},
{
"zigpy_xbee.zigbee.application": logging.DEBUG,
"zigpy_xbee.api": logging.DEBUG,
},
],
"zigate": [
{
"zigpy_zigate": logging.INFO,
},
{
"zigpy_zigate": logging.DEBUG,
},
],
"znp": [
{
"zigpy_znp": logging.INFO,
},
{
"zigpy_znp": logging.DEBUG,
},
{
"zigpy_znp": TRACE,
},
],
}

RADIO_TO_PYPI = {name: mod.replace("_", "-") for name, mod in RADIO_TO_PACKAGE.items()}
105 changes: 66 additions & 39 deletions zigpy_cli/radio.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
from __future__ import annotations

import json
import logging
import importlib
import collections
import importlib.util

import click
import zigpy.state
import zigpy.types
import zigpy.config as conf
import zigpy.zdo.types

from zigpy_cli.cli import cli, click_coroutine
from zigpy_cli.utils import format_bytes
from zigpy_cli.common import (
RADIO_TO_PYPI,
HEX_OR_DEC_INT,
RADIO_TO_PACKAGE,
RADIO_LOGGING_CONFIGS,
)
from zigpy_cli.const import RADIO_TO_PYPI, RADIO_TO_PACKAGE, RADIO_LOGGING_CONFIGS
from zigpy_cli.common import HEX_OR_DEC_INT

LOGGER = logging.getLogger(__name__)

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

# Import the radio library
module = RADIO_TO_PACKAGE[radio] + ".zigbee.application"

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

# Import the radio library
radio_module = importlib.import_module(module)

# Start the radio
app_cls = radio_module.ControllerApplication
config = app_cls.SCHEMA(
{
conf.CONF_DEVICE: {
conf.CONF_DEVICE_PATH: port,
},
}
)
config = app_cls.SCHEMA({"device": {"path": port}})

if baudrate is not None:
config["device"]["baudrate"] = baudrate

app = app_cls(config)

ctx.obj = app
Expand All @@ -66,36 +62,59 @@ async def radio(ctx, radio, port):
@click_coroutine
async def radio_cleanup(app):
try:
await app.pre_shutdown()
await app.shutdown()
except RuntimeError:
LOGGER.warning("Caught an exception when shutting down app", exc_info=True)


def dump_app_info(app):
if app.pan_id is not None:
print(f"PAN ID: 0x{app.pan_id:04X}")
@radio.command()
@click.pass_obj
@click_coroutine
async def info(app):
await app.connect()
await app.load_network_info(load_devices=False)

print(f"PAN ID: 0x{app.state.network_info.pan_id:04X}")
print(f"Extended PAN ID: {app.state.network_info.extended_pan_id}")
print(f"Channel: {app.state.network_info.channel}")
print(f"Channel mask: {list(app.state.network_info.channel_mask)}")
print(f"NWK update ID: {app.state.network_info.nwk_update_id}")
print(f"Device IEEE: {app.state.node_info.ieee}")
print(f"Device NWK: 0x{app.state.node_info.nwk:04X}")
print(f"Network key: {app.state.network_info.network_key.key}")
print(f"Network key sequence: {app.state.network_info.network_key.seq}")
print(f"Network key counter: {app.state.network_info.network_key.tx_counter}")

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

if app.channels is not None:
print(f"Channel mask: {list(app.channels)}")
@radio.command()
@click.argument("output", type=click.File("w"))
@click.pass_obj
@click_coroutine
async def backup(app, output):
await app.connect()
await app.load_network_info(load_devices=True)

print(f"NWK update ID: {app.nwk_update_id}")
print(f"Device IEEE: {app.ieee}")
print(f"Device NWK: 0x{app.nwk:04X}")
obj = zigpy.state.network_state_to_json(
network_info=app.state.network_info,
node_info=app.state.node_info,
)

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


@radio.command()
@click.argument("input", type=click.File("r"))
@click.option("-c", "--frame-counter-increment", type=int, default=5000)
@click.pass_obj
@click_coroutine
async def info(app):
await app.startup(auto_form=False)
dump_app_info(app)
async def restore(app, frame_counter_increment, input):
obj = json.load(input)

network_info, node_info = zigpy.state.json_to_network_state(obj)
network_info.network_key.tx_counter += frame_counter_increment

await app.connect()
await app.write_network_info(network_info=network_info, node_info=node_info)


@radio.command()
Expand All @@ -104,7 +123,6 @@ async def info(app):
async def form(app):
await app.startup(auto_form=True)
await app.form_network()
dump_app_info(app)


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

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

if channel == app.state.network_info.channel:
bracket_open = "["
bracket_close = "]"
else:
bracket_open = " "
bracket_close = " "

print(
f" - {channel:>02}{asterisk} {count / total:>7.2%} "
f" - {bracket_open}{channel:>02}{asterisk}{bracket_close}"
+ f" {count / total:>7.2%} "
+ "#" * int(100 * count / total)
)

Expand Down
5 changes: 0 additions & 5 deletions zigpy_cli/utils.py

This file was deleted.

0 comments on commit f27b67b

Please sign in to comment.