1
mirror of https://github.com/home-assistant/core synced 2024-08-02 23:40:32 +02:00

ZHA network backup and restore API (#75791)

* Implement WS API endpoints for zigpy backups

* Implement backup restoration

* Display error messages caused by invalid backup JSON

* Indicate to the frontend when a backup is incomplete

* Perform a coordinator backup before HA performs a backup

* Fix `backup.async_post_backup` docstring

* Rename `data` to `backup` in restore command

* Add unit tests for new websocket APIs

* Unit test backup platform

* Move code to overwrite EZSP EUI64 into ZHA

* Include the radio type in the network settings API response
This commit is contained in:
puddly 2022-07-28 11:24:31 -04:00 committed by GitHub
parent 91180923ae
commit 8e2f0497ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 286 additions and 3 deletions

View File

@ -6,6 +6,8 @@ import logging
from typing import TYPE_CHECKING, Any, NamedTuple
import voluptuous as vol
import zigpy.backups
from zigpy.backups import NetworkBackup
from zigpy.config.validators import cv_boolean
from zigpy.types.named import EUI64
from zigpy.zcl.clusters.security import IasAce
@ -43,6 +45,7 @@ from .core.const import (
CLUSTER_COMMANDS_SERVER,
CLUSTER_TYPE_IN,
CLUSTER_TYPE_OUT,
CONF_RADIO_TYPE,
CUSTOM_CONFIGURATION,
DATA_ZHA,
DATA_ZHA_GATEWAY,
@ -229,6 +232,15 @@ def _cv_cluster_binding(value: dict[str, Any]) -> ClusterBinding:
)
def _cv_zigpy_network_backup(value: dict[str, Any]) -> zigpy.backups.NetworkBackup:
"""Transform a zigpy network backup."""
try:
return zigpy.backups.NetworkBackup.from_dict(value)
except ValueError as err:
raise vol.Invalid(str(err)) from err
GROUP_MEMBER_SCHEMA = vol.All(
vol.Schema(
{
@ -302,7 +314,7 @@ async def websocket_permit_devices(
)
else:
await zha_gateway.application_controller.permit(time_s=duration, node=ieee)
connection.send_result(msg["id"])
connection.send_result(msg[ID])
@websocket_api.require_admin
@ -989,7 +1001,7 @@ async def websocket_get_configuration(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Get ZHA configuration."""
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
import voluptuous_serialize # pylint: disable=import-outside-toplevel
def custom_serializer(schema: Any) -> Any:
@ -1047,6 +1059,99 @@ async def websocket_update_zha_configuration(
connection.send_result(msg[ID], status)
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/settings"})
@websocket_api.async_response
async def websocket_get_network_settings(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Get ZHA network settings."""
zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
application_controller = zha_gateway.application_controller
# Serialize the current network settings
backup = NetworkBackup(
node_info=application_controller.state.node_info,
network_info=application_controller.state.network_info,
)
connection.send_result(
msg[ID],
{
"radio_type": zha_gateway.config_entry.data[CONF_RADIO_TYPE],
"settings": backup.as_dict(),
},
)
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/list"})
@websocket_api.async_response
async def websocket_list_network_backups(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Get ZHA network settings."""
zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
application_controller = zha_gateway.application_controller
# Serialize known backups
connection.send_result(
msg[ID], [backup.as_dict() for backup in application_controller.backups]
)
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/create"})
@websocket_api.async_response
async def websocket_create_network_backup(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Create a ZHA network backup."""
zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
application_controller = zha_gateway.application_controller
# This can take 5-30s
backup = await application_controller.backups.create_backup(load_devices=True)
connection.send_result(
msg[ID],
{
"backup": backup.as_dict(),
"is_complete": backup.is_complete(),
},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zha/network/backups/restore",
vol.Required("backup"): _cv_zigpy_network_backup,
vol.Optional("ezsp_force_write_eui64", default=False): cv.boolean,
}
)
@websocket_api.async_response
async def websocket_restore_network_backup(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Restore a ZHA network backup."""
zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
application_controller = zha_gateway.application_controller
backup = msg["backup"]
if msg["ezsp_force_write_eui64"]:
backup.network_info.stack_specific.setdefault("ezsp", {})[
"i_understand_i_can_update_eui64_only_once_and_i_still_want_to_do_it"
] = True
# This can take 30-40s
try:
await application_controller.backups.restore_backup(backup)
except ValueError as err:
connection.send_error(msg[ID], websocket_api.const.ERR_INVALID_FORMAT, str(err))
else:
connection.send_result(msg[ID])
@callback
def async_load_api(hass: HomeAssistant) -> None:
"""Set up the web socket API."""
@ -1356,6 +1461,10 @@ def async_load_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_update_topology)
websocket_api.async_register_command(hass, websocket_get_configuration)
websocket_api.async_register_command(hass, websocket_update_zha_configuration)
websocket_api.async_register_command(hass, websocket_get_network_settings)
websocket_api.async_register_command(hass, websocket_list_network_backups)
websocket_api.async_register_command(hass, websocket_create_network_backup)
websocket_api.async_register_command(hass, websocket_restore_network_backup)
@callback

View File

@ -0,0 +1,21 @@
"""Backup platform for the ZHA integration."""
import logging
from homeassistant.core import HomeAssistant
from .core import ZHAGateway
from .core.const import DATA_ZHA, DATA_ZHA_GATEWAY
_LOGGER = logging.getLogger(__name__)
async def async_pre_backup(hass: HomeAssistant) -> None:
"""Perform operations before a backup starts."""
_LOGGER.debug("Performing coordinator backup")
zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
await zha_gateway.application_controller.backups.create_backup(load_devices=True)
async def async_post_backup(hass: HomeAssistant) -> None:
"""Perform operations after a backup finishes."""

View File

@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
import pytest
import zigpy
from zigpy.application import ControllerApplication
import zigpy.backups
import zigpy.config
from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
import zigpy.device
@ -54,7 +55,16 @@ def zigpy_app_controller():
app.ieee.return_value = zigpy.types.EUI64.convert("00:15:8d:00:02:32:4f:32")
type(app).nwk = PropertyMock(return_value=zigpy.types.NWK(0x0000))
type(app).devices = PropertyMock(return_value={})
type(app).state = PropertyMock(return_value=State())
type(app).backups = zigpy.backups.BackupManager(app)
state = State()
state.node_info.ieee = app.ieee.return_value
state.network_info.extended_pan_id = app.ieee.return_value
state.network_info.pan_id = 0x1234
state.network_info.channel = 15
state.network_info.network_key.key = zigpy.types.KeyData(range(16))
type(app).state = PropertyMock(return_value=state)
return app

View File

@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch
import pytest
import voluptuous as vol
import zigpy.backups
import zigpy.profiles.zha
import zigpy.types
import zigpy.zcl.clusters.general as general
@ -620,3 +621,125 @@ async def test_ws_permit_ha12(app_controller, zha_client, params, duration, node
assert app_controller.permit.await_args[1]["time_s"] == duration
assert app_controller.permit.await_args[1]["node"] == node
assert app_controller.permit_with_key.call_count == 0
async def test_get_network_settings(app_controller, zha_client):
"""Test current network settings are returned."""
await app_controller.backups.create_backup()
await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/settings"})
msg = await zha_client.receive_json()
assert msg["id"] == 6
assert msg["type"] == const.TYPE_RESULT
assert msg["success"]
assert "radio_type" in msg["result"]
assert "network_info" in msg["result"]["settings"]
async def test_list_network_backups(app_controller, zha_client):
"""Test backups are serialized."""
await app_controller.backups.create_backup()
await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/list"})
msg = await zha_client.receive_json()
assert msg["id"] == 6
assert msg["type"] == const.TYPE_RESULT
assert msg["success"]
assert "network_info" in msg["result"][0]
async def test_create_network_backup(app_controller, zha_client):
"""Test creating backup."""
assert not app_controller.backups.backups
await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/create"})
msg = await zha_client.receive_json()
assert len(app_controller.backups.backups) == 1
assert msg["id"] == 6
assert msg["type"] == const.TYPE_RESULT
assert msg["success"]
assert "backup" in msg["result"] and "is_complete" in msg["result"]
async def test_restore_network_backup_success(app_controller, zha_client):
"""Test successfully restoring a backup."""
backup = zigpy.backups.NetworkBackup()
with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p:
await zha_client.send_json(
{
ID: 6,
TYPE: f"{DOMAIN}/network/backups/restore",
"backup": backup.as_dict(),
}
)
msg = await zha_client.receive_json()
p.assert_called_once_with(backup)
assert "ezsp" not in backup.network_info.stack_specific
assert msg["id"] == 6
assert msg["type"] == const.TYPE_RESULT
assert msg["success"]
async def test_restore_network_backup_force_write_eui64(app_controller, zha_client):
"""Test successfully restoring a backup."""
backup = zigpy.backups.NetworkBackup()
with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p:
await zha_client.send_json(
{
ID: 6,
TYPE: f"{DOMAIN}/network/backups/restore",
"backup": backup.as_dict(),
"ezsp_force_write_eui64": True,
}
)
msg = await zha_client.receive_json()
# EUI64 will be overwritten
p.assert_called_once_with(
backup.replace(
network_info=backup.network_info.replace(
stack_specific={
"ezsp": {
"i_understand_i_can_update_eui64_only_once_and_i_still_want_to_do_it": True
}
}
)
)
)
assert msg["id"] == 6
assert msg["type"] == const.TYPE_RESULT
assert msg["success"]
@patch("zigpy.backups.NetworkBackup.from_dict", new=lambda v: v)
async def test_restore_network_backup_failure(app_controller, zha_client):
"""Test successfully restoring a backup."""
with patch.object(
app_controller.backups,
"restore_backup",
new=AsyncMock(side_effect=ValueError("Restore failed")),
) as p:
await zha_client.send_json(
{ID: 6, TYPE: f"{DOMAIN}/network/backups/restore", "backup": "a backup"}
)
msg = await zha_client.receive_json()
p.assert_called_once_with("a backup")
assert msg["id"] == 6
assert msg["type"] == const.TYPE_RESULT
assert not msg["success"]
assert msg["error"]["code"] == const.ERR_INVALID_FORMAT

View File

@ -0,0 +1,20 @@
"""Unit tests for ZHA backup platform."""
from unittest.mock import AsyncMock, patch
from homeassistant.components.zha.backup import async_post_backup, async_pre_backup
async def test_pre_backup(hass, setup_zha):
"""Test backup creation when `async_pre_backup` is called."""
with patch("zigpy.backups.BackupManager.create_backup", AsyncMock()) as backup_mock:
await setup_zha()
await async_pre_backup(hass)
backup_mock.assert_called_once_with(load_devices=True)
async def test_post_backup(hass, setup_zha):
"""Test no-op `async_post_backup`."""
await setup_zha()
await async_post_backup(hass)