Add support for zwave_js firmware update service (#77401)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
pull/77572/head
Raman Gupta 2022-08-30 12:49:27 -04:00 committed by GitHub
parent f78b39bdbf
commit df214c2d26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 491 additions and 11 deletions

View File

@ -19,8 +19,6 @@ from zwave_js_server.model.notification import (
)
from zwave_js_server.model.value import Value, ValueNotification
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
@ -28,6 +26,7 @@ from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_URL,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
@ -244,7 +243,7 @@ async def setup_driver( # noqa: C901
registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict(dict)
discovered_value_ids: dict[str, set[str]] = defaultdict(set)
async def async_setup_platform(platform: str) -> None:
async def async_setup_platform(platform: Platform) -> None:
"""Set up platform if needed."""
if platform not in platform_setup_tasks:
platform_setup_tasks[platform] = hass.async_create_task(
@ -353,17 +352,23 @@ async def setup_driver( # noqa: C901
# No need for a ping button or node status sensor for controller nodes
if not node.is_controller_node:
# Create a node status sensor for each device
await async_setup_platform(SENSOR_DOMAIN)
await async_setup_platform(Platform.SENSOR)
async_dispatcher_send(
hass, f"{DOMAIN}_{entry.entry_id}_add_node_status_sensor", node
)
# Create a ping button for each device
await async_setup_platform(BUTTON_DOMAIN)
await async_setup_platform(Platform.BUTTON)
async_dispatcher_send(
hass, f"{DOMAIN}_{entry.entry_id}_add_ping_button_entity", node
)
# Create a firmware update entity for each device
await async_setup_platform(Platform.UPDATE)
async_dispatcher_send(
hass, f"{DOMAIN}_{entry.entry_id}_add_firmware_update_entity", node
)
# we only want to run discovery when the node has reached ready state,
# otherwise we'll have all kinds of missing info issues.
if node.ready:

View File

@ -120,3 +120,7 @@ ENTITY_DESC_KEY_TEMPERATURE = "temperature"
ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature"
ENTITY_DESC_KEY_MEASUREMENT = "measurement"
ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing"
# This API key is only for use with Home Assistant. Reach out to Z-Wave JS to apply for
# your own (https://github.com/zwave-js/firmware-updates/).
API_KEY_FIRMWARE_UPDATE_SERVICE = "b48e74337db217f44e1e003abb1e9144007d260a17e2b2422e0a45d0eaf6f4ad86f2a9943f17fee6dde343941f238a64"

View File

@ -0,0 +1,190 @@
"""Representation of Z-Wave updates."""
from __future__ import annotations
from collections.abc import Callable
from datetime import timedelta
from typing import Any
from awesomeversion import AwesomeVersion
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.const import NodeStatus
from zwave_js_server.exceptions import BaseZwaveJSServerError
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.firmware import FirmwareUpdateInfo
from zwave_js_server.model.node import Node as ZwaveNode
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
from homeassistant.components.update.const import UpdateEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DATA_CLIENT, DOMAIN, LOGGER
from .helpers import get_device_id, get_valueless_base_unique_id
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(days=1)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Z-Wave button from config entry."""
client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
@callback
def async_add_firmware_update_entity(node: ZwaveNode) -> None:
"""Add firmware update entity."""
driver = client.driver
assert driver is not None # Driver is ready before platforms are loaded.
async_add_entities([ZWaveNodeFirmwareUpdate(driver, node)], True)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_{config_entry.entry_id}_add_firmware_update_entity",
async_add_firmware_update_entity,
)
)
class ZWaveNodeFirmwareUpdate(UpdateEntity):
"""Representation of a firmware update entity."""
_attr_entity_category = EntityCategory.CONFIG
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.RELEASE_NOTES
)
_attr_has_entity_name = True
def __init__(self, driver: Driver, node: ZwaveNode) -> None:
"""Initialize a Z-Wave device firmware update entity."""
self.driver = driver
self.node = node
self.available_firmware_updates: list[FirmwareUpdateInfo] = []
self._latest_version_firmware: FirmwareUpdateInfo | None = None
self._status_unsub: Callable[[], None] | None = None
# Entity class attributes
self._attr_name = "Firmware"
self._base_unique_id = get_valueless_base_unique_id(driver, node)
self._attr_unique_id = f"{self._base_unique_id}.firmware_update"
# device may not be precreated in main handler yet
self._attr_device_info = DeviceInfo(
identifiers={get_device_id(driver, node)},
sw_version=node.firmware_version,
name=node.name or node.device_config.description or f"Node {node.node_id}",
model=node.device_config.label,
manufacturer=node.device_config.manufacturer,
suggested_area=node.location if node.location else None,
)
self._attr_installed_version = self._attr_latest_version = node.firmware_version
def _update_on_wake_up(self, _: dict[str, Any]) -> None:
"""Update the entity when node is awake."""
self._status_unsub = None
self.hass.async_create_task(self.async_update(True))
async def async_update(self, write_state: bool = False) -> None:
"""Update the entity."""
if self.node.status == NodeStatus.ASLEEP:
if not self._status_unsub:
self._status_unsub = self.node.once("wake up", self._update_on_wake_up)
return
self.available_firmware_updates = (
await self.driver.controller.async_get_available_firmware_updates(
self.node, API_KEY_FIRMWARE_UPDATE_SERVICE
)
)
self._async_process_available_updates(write_state)
@callback
def _async_process_available_updates(self, write_state: bool = True) -> None:
"""
Process available firmware updates.
Sets latest version attribute and FirmwareUpdateInfo instance.
"""
# If we have an available firmware update that is a higher version than what's
# on the node, we should advertise it, otherwise we are on the latest version
if self.available_firmware_updates and AwesomeVersion(
(
firmware := max(
self.available_firmware_updates,
key=lambda x: AwesomeVersion(x.version),
)
).version
) > AwesomeVersion(self.node.firmware_version):
self._latest_version_firmware = firmware
self._attr_latest_version = firmware.version
else:
self._latest_version_firmware = None
self._attr_latest_version = self._attr_installed_version
if write_state:
self.async_write_ha_state()
async def async_release_notes(self) -> str | None:
"""Get release notes."""
if self._latest_version_firmware is None:
return None
return self._latest_version_firmware.changelog
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
firmware = self._latest_version_firmware
assert firmware
self._attr_in_progress = True
self.async_write_ha_state()
try:
for file in firmware.files:
await self.driver.controller.async_begin_ota_firmware_update(
self.node, file
)
except BaseZwaveJSServerError as err:
raise HomeAssistantError(err) from err
else:
self._attr_installed_version = firmware.version
self.available_firmware_updates.remove(firmware)
self._async_process_available_updates()
finally:
self._attr_in_progress = False
async def async_poll_value(self, _: bool) -> None:
"""Poll a value."""
LOGGER.error(
"There is no value to refresh for this entity so the zwave_js.refresh_value "
"service won't work for it"
)
async def async_added_to_hass(self) -> None:
"""Call when entity is added."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self.unique_id}_poll_value",
self.async_poll_value,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._base_unique_id}_remove_entity",
self.async_remove,
)
)
async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed."""
if self._status_unsub:
self._status_unsub()
self._status_unsub = None

View File

@ -844,6 +844,8 @@ async def integration_fixture(hass, client):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
client.async_send_command.reset_mock()
return entry

View File

@ -513,6 +513,8 @@ async def test_thermostat_fan(hass, client, climate_adc_t3000, integration):
await hass.config_entries.async_reload(integration.entry_id)
await hass.async_block_till_done()
client.async_send_command.reset_mock()
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON
@ -774,6 +776,8 @@ async def test_thermostat_fan_without_off(
await hass.config_entries.async_reload(integration.entry_id)
await hass.async_block_till_done()
client.async_send_command.reset_mock()
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNKNOWN

View File

@ -211,8 +211,8 @@ async def test_on_node_added_not_ready(
client.driver.receive_event(event)
await hass.async_block_till_done()
# the only entities are the node status sensor and ping button
assert len(hass.states.async_all()) == 2
# the only entities are the node status sensor, ping button, and firmware update
assert len(hass.states.async_all()) == 3
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device
@ -254,8 +254,8 @@ async def test_existing_node_not_ready(hass, zp3111_not_ready, client, integrati
assert not device.model
assert not device.sw_version
# the only entities are the node status sensor and ping button
assert len(hass.states.async_all()) == 2
# the only entities are the node status sensor, ping button, and firmware update
assert len(hass.states.async_all()) == 3
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device
@ -817,7 +817,7 @@ async def test_removed_device(
# Check how many entities there are
ent_reg = er.async_get(hass)
entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id)
assert len(entity_entries) == 29
assert len(entity_entries) == 31
# Remove a node and reload the entry
old_node = driver.controller.nodes.pop(13)
@ -829,7 +829,7 @@ async def test_removed_device(
device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id)
assert len(device_entries) == 1
entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id)
assert len(entity_entries) == 17
assert len(entity_entries) == 18
assert dev_reg.async_get_device({get_device_id(driver, old_node)}) is None

View File

@ -0,0 +1,275 @@
"""Test the Z-Wave JS update entities."""
from datetime import timedelta
import pytest
from zwave_js_server.event import Event
from zwave_js_server.exceptions import FailedZWaveCommand
from homeassistant.components.update.const import (
ATTR_AUTO_UPDATE,
ATTR_IN_PROGRESS,
ATTR_INSTALLED_VERSION,
ATTR_LATEST_VERSION,
ATTR_RELEASE_URL,
DOMAIN as UPDATE_DOMAIN,
SERVICE_INSTALL,
)
from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE
from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_registry import async_get
from homeassistant.util import datetime as dt_util
from tests.common import async_fire_time_changed
UPDATE_ENTITY = "update.z_wave_thermostat_firmware"
async def test_update_entity_success(
hass,
client,
climate_radio_thermostat_ct100_plus_different_endpoints,
controller_node,
integration,
caplog,
hass_ws_client,
):
"""Test update entity."""
ws_client = await hass_ws_client(hass)
await hass.async_block_till_done()
assert hass.states.get(UPDATE_ENTITY).state == STATE_OFF
client.async_send_command.return_value = {"updates": []}
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1))
await hass.async_block_till_done()
state = hass.states.get(UPDATE_ENTITY)
assert state
assert state.state == STATE_OFF
await ws_client.send_json(
{
"id": 1,
"type": "update/release_notes",
"entity_id": UPDATE_ENTITY,
}
)
result = await ws_client.receive_json()
assert result["result"] is None
client.async_send_command.return_value = {
"updates": [
{
"version": "10.11.1",
"changelog": "blah 1",
"files": [
{"target": 0, "url": "https://example1.com", "integrity": "sha1"}
],
},
{
"version": "11.2.4",
"changelog": "blah 2",
"files": [
{"target": 0, "url": "https://example2.com", "integrity": "sha2"}
],
},
{
"version": "11.1.5",
"changelog": "blah 3",
"files": [
{"target": 0, "url": "https://example3.com", "integrity": "sha3"}
],
},
]
}
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=2))
await hass.async_block_till_done()
state = hass.states.get(UPDATE_ENTITY)
assert state
assert state.state == STATE_ON
attrs = state.attributes
assert not attrs[ATTR_AUTO_UPDATE]
assert attrs[ATTR_INSTALLED_VERSION] == "10.7"
assert not attrs[ATTR_IN_PROGRESS]
assert attrs[ATTR_LATEST_VERSION] == "11.2.4"
assert attrs[ATTR_RELEASE_URL] is None
await ws_client.send_json(
{
"id": 2,
"type": "update/release_notes",
"entity_id": UPDATE_ENTITY,
}
)
result = await ws_client.receive_json()
assert result["result"] == "blah 2"
# Refresh value should not be supported by this entity
await hass.services.async_call(
DOMAIN,
SERVICE_REFRESH_VALUE,
{
ATTR_ENTITY_ID: UPDATE_ENTITY,
},
blocking=True,
)
assert "There is no value to refresh for this entity" in caplog.text
# Assert a node firmware update entity is not created for the controller
driver = client.driver
node = driver.controller.nodes[1]
assert node.is_controller_node
assert (
async_get(hass).async_get_entity_id(
DOMAIN,
"sensor",
f"{get_valueless_base_unique_id(driver, node)}.firmware_update",
)
is None
)
client.async_send_command.reset_mock()
# Test successful install call without a version
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: UPDATE_ENTITY,
},
blocking=True,
)
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "controller.begin_ota_firmware_update"
assert (
args["nodeId"]
== climate_radio_thermostat_ct100_plus_different_endpoints.node_id
)
assert args["update"] == {
"target": 0,
"url": "https://example2.com",
"integrity": "sha2",
}
client.async_send_command.reset_mock()
async def test_update_entity_failure(
hass,
client,
climate_radio_thermostat_ct100_plus_different_endpoints,
controller_node,
integration,
caplog,
hass_ws_client,
):
"""Test update entity failed install."""
client.async_send_command.return_value = {
"updates": [
{
"version": "10.11.1",
"changelog": "blah 1",
"files": [
{"target": 0, "url": "https://example1.com", "integrity": "sha1"}
],
},
{
"version": "11.2.4",
"changelog": "blah 2",
"files": [
{"target": 0, "url": "https://example2.com", "integrity": "sha2"}
],
},
{
"version": "11.1.5",
"changelog": "blah 3",
"files": [
{"target": 0, "url": "https://example3.com", "integrity": "sha3"}
],
},
]
}
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1))
await hass.async_block_till_done()
# Test failed installation by driver
client.async_send_command.side_effect = FailedZWaveCommand("test", 12, "test")
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: UPDATE_ENTITY,
},
blocking=True,
)
async def test_update_entity_sleep(
hass,
client,
multisensor_6,
integration,
):
"""Test update occurs when device is asleep after it wakes up."""
event = Event(
"sleep",
data={"source": "node", "event": "sleep", "nodeId": multisensor_6.node_id},
)
multisensor_6.receive_event(event)
client.async_send_command.reset_mock()
client.async_send_command.return_value = {
"updates": [
{
"version": "10.11.1",
"changelog": "blah 1",
"files": [
{"target": 0, "url": "https://example1.com", "integrity": "sha1"}
],
},
{
"version": "11.2.4",
"changelog": "blah 2",
"files": [
{"target": 0, "url": "https://example2.com", "integrity": "sha2"}
],
},
{
"version": "11.1.5",
"changelog": "blah 3",
"files": [
{"target": 0, "url": "https://example3.com", "integrity": "sha3"}
],
},
]
}
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1))
await hass.async_block_till_done()
# Because node is asleep we shouldn't attempt to check for firmware updates
assert len(client.async_send_command.call_args_list) == 0
event = Event(
"wake up",
data={"source": "node", "event": "wake up", "nodeId": multisensor_6.node_id},
)
multisensor_6.receive_event(event)
await hass.async_block_till_done()
# Now that the node is up we can check for updates
assert len(client.async_send_command.call_args_list) > 0
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "controller.get_available_firmware_updates"
assert args["nodeId"] == multisensor_6.node_id