2025-05-07 13:32:27 +03:00

3083 lines
92 KiB
Python

"""Websocket API for Z-Wave JS."""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
from contextlib import suppress
import dataclasses
from functools import partial, wraps
from typing import Any, Concatenate, Literal, cast
from aiohttp import web, web_exceptions, web_request
import voluptuous as vol
from zwave_js_server.client import Client
from zwave_js_server.const import (
CommandClass,
ExclusionStrategy,
InclusionState,
InclusionStrategy,
LogLevel,
NodeStatus,
Protocols,
ProvisioningEntryStatus,
QRCodeVersion,
SecurityClass,
ZwaveFeature,
)
from zwave_js_server.exceptions import (
BaseZwaveJSServerError,
FailedCommand,
InvalidNewValue,
NotFoundError,
SetValueFailed,
)
from zwave_js_server.firmware import controller_firmware_update_otw, update_firmware
from zwave_js_server.model.controller import (
ControllerStatistics,
InclusionGrant,
ProvisioningEntry,
QRProvisioningInformation,
)
from zwave_js_server.model.controller.firmware import (
ControllerFirmwareUpdateData,
ControllerFirmwareUpdateProgress,
ControllerFirmwareUpdateResult,
)
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.endpoint import Endpoint
from zwave_js_server.model.log_config import LogConfig
from zwave_js_server.model.log_message import LogMessage
from zwave_js_server.model.node import Node, NodeStatistics
from zwave_js_server.model.node.firmware import (
NodeFirmwareUpdateData,
NodeFirmwareUpdateProgress,
NodeFirmwareUpdateResult,
)
from zwave_js_server.model.utils import (
async_parse_qr_code_string,
async_try_parse_dsk_from_qr_code_string,
)
from zwave_js_server.model.value import ConfigurationValueFormat
from zwave_js_server.util.node import async_set_config_parameter
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.components.websocket_api import (
ERR_INVALID_FORMAT,
ERR_NOT_FOUND,
ERR_NOT_SUPPORTED,
ERR_UNKNOWN_ERROR,
ActiveConnection,
)
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .config_validation import BITMASK_SCHEMA
from .const import (
ATTR_COMMAND_CLASS,
ATTR_ENDPOINT,
ATTR_METHOD_NAME,
ATTR_PARAMETERS,
ATTR_WAIT_FOR_RESULT,
CONF_DATA_COLLECTION_OPTED_IN,
CONF_INSTALLER_MODE,
DATA_CLIENT,
DOMAIN,
EVENT_DEVICE_ADDED_TO_REGISTRY,
USER_AGENT,
)
from .helpers import (
async_enable_statistics,
async_get_node_from_device_id,
async_get_provisioning_entry_from_device_id,
get_device_id,
)
DATA_UNSUBSCRIBE = "unsubs"
# general API constants
ID = "id"
ENTRY_ID = "entry_id"
ERR_NOT_LOADED = "not_loaded"
NODE_ID = "node_id"
DEVICE_ID = "device_id"
COMMAND_CLASS_ID = "command_class_id"
TYPE = "type"
PROPERTY = "property"
PROPERTY_KEY = "property_key"
ENDPOINT = "endpoint"
VALUE = "value"
VALUE_SIZE = "value_size"
VALUE_FORMAT = "value_format"
# constants for log config commands
CONFIG = "config"
LEVEL = "level"
LOG_TO_FILE = "log_to_file"
FILENAME = "filename"
ENABLED = "enabled"
FORCE_CONSOLE = "force_console"
# constants for setting config parameters
VALUE_ID = "value_id"
STATUS = "status"
# constants for data collection
ENABLED = "enabled"
OPTED_IN = "opted_in"
# constants for granting security classes
SECURITY_CLASSES = "securityClasses"
CLIENT_SIDE_AUTH = "clientSideAuth"
# constants for inclusion
INCLUSION_STRATEGY = "inclusion_strategy"
INCLUSION_STRATEGY_NOT_SMART_START: dict[
int,
Literal[
InclusionStrategy.DEFAULT,
InclusionStrategy.SECURITY_S0,
InclusionStrategy.SECURITY_S2,
InclusionStrategy.INSECURE,
],
] = {
InclusionStrategy.DEFAULT.value: InclusionStrategy.DEFAULT,
InclusionStrategy.SECURITY_S0.value: InclusionStrategy.SECURITY_S0,
InclusionStrategy.SECURITY_S2.value: InclusionStrategy.SECURITY_S2,
InclusionStrategy.INSECURE.value: InclusionStrategy.INSECURE,
}
PIN = "pin"
FORCE_SECURITY = "force_security"
PLANNED_PROVISIONING_ENTRY = "planned_provisioning_entry"
QR_PROVISIONING_INFORMATION = "qr_provisioning_information"
QR_CODE_STRING = "qr_code_string"
DSK = "dsk"
VERSION = "version"
GENERIC_DEVICE_CLASS = "genericDeviceClass"
SPECIFIC_DEVICE_CLASS = "specificDeviceClass"
INSTALLER_ICON_TYPE = "installerIconType"
MANUFACTURER_ID = "manufacturerId"
PRODUCT_TYPE = "productType"
PRODUCT_ID = "productId"
APPLICATION_VERSION = "applicationVersion"
MAX_INCLUSION_REQUEST_INTERVAL = "maxInclusionRequestInterval"
UUID = "uuid"
SUPPORTED_PROTOCOLS = "supportedProtocols"
ADDITIONAL_PROPERTIES = "additional_properties"
STATUS = "status"
REQUESTED_SECURITY_CLASSES = "requestedSecurityClasses"
PROTOCOL = "protocol"
DEVICE_NAME = "device_name"
AREA_ID = "area_id"
FEATURE = "feature"
STRATEGY = "strategy"
# https://github.com/zwave-js/node-zwave-js/blob/master/packages/core/src/security/QR.ts#L41
MINIMUM_QR_STRING_LENGTH = 52
HARD_RESET_CONTROLLER_DRIVER_READY_TIMEOUT = 60
# Helper schemas
PLANNED_PROVISIONING_ENTRY_SCHEMA = vol.All(
vol.Schema(
{
vol.Required(DSK): str,
vol.Required(SECURITY_CLASSES): vol.All(
cv.ensure_list,
[vol.Coerce(SecurityClass)],
),
vol.Optional(STATUS, default=ProvisioningEntryStatus.ACTIVE): vol.Coerce(
ProvisioningEntryStatus
),
vol.Optional(REQUESTED_SECURITY_CLASSES): vol.All(
cv.ensure_list, [vol.Coerce(SecurityClass)]
),
},
# Provisioning entries can have extra keys for SmartStart
extra=vol.ALLOW_EXTRA,
),
ProvisioningEntry.from_dict,
)
QR_PROVISIONING_INFORMATION_SCHEMA = vol.All(
vol.Schema(
{
vol.Required(VERSION): vol.Coerce(QRCodeVersion),
vol.Required(SECURITY_CLASSES): vol.All(
cv.ensure_list,
[vol.Coerce(SecurityClass)],
),
vol.Required(DSK): str,
vol.Required(GENERIC_DEVICE_CLASS): int,
vol.Required(SPECIFIC_DEVICE_CLASS): int,
vol.Required(INSTALLER_ICON_TYPE): int,
vol.Required(MANUFACTURER_ID): int,
vol.Required(PRODUCT_TYPE): int,
vol.Required(PRODUCT_ID): int,
vol.Required(APPLICATION_VERSION): str,
vol.Optional(MAX_INCLUSION_REQUEST_INTERVAL): vol.Any(int, None),
vol.Optional(UUID): vol.Any(str, None),
vol.Optional(SUPPORTED_PROTOCOLS): vol.All(
cv.ensure_list,
[vol.Coerce(Protocols)],
),
vol.Optional(STATUS, default=ProvisioningEntryStatus.ACTIVE): vol.Coerce(
ProvisioningEntryStatus
),
vol.Optional(REQUESTED_SECURITY_CLASSES): vol.All(
cv.ensure_list, [vol.Coerce(SecurityClass)]
),
},
extra=vol.ALLOW_EXTRA,
),
QRProvisioningInformation.from_dict,
)
QR_CODE_STRING_SCHEMA = vol.All(str, vol.Length(min=MINIMUM_QR_STRING_LENGTH))
async def _async_get_entry(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry_id: str,
) -> tuple[ConfigEntry, Client, Driver] | tuple[None, None, None]:
"""Get config entry and client from message data."""
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None:
connection.send_error(
msg[ID], ERR_NOT_FOUND, f"Config entry {entry_id} not found"
)
return None, None, None
if entry.state is not ConfigEntryState.LOADED:
connection.send_error(
msg[ID], ERR_NOT_LOADED, f"Config entry {entry_id} not loaded"
)
return None, None, None
client: Client = entry.runtime_data[DATA_CLIENT]
if client.driver is None:
connection.send_error(
msg[ID],
ERR_NOT_LOADED,
f"Config entry {msg[ENTRY_ID]} not loaded, driver not ready",
)
return None, None, None
return entry, client, client.driver
def async_get_entry(
orig_func: Callable[
[HomeAssistant, ActiveConnection, dict[str, Any], ConfigEntry, Client, Driver],
Coroutine[Any, Any, None],
],
) -> Callable[
[HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None]
]:
"""Decorate async function to get entry."""
@wraps(orig_func)
async def async_get_entry_func(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Provide user specific data and store to function."""
entry, client, driver = await _async_get_entry(
hass, connection, msg, msg[ENTRY_ID]
)
if not entry or not client or not driver:
return
await orig_func(hass, connection, msg, entry, client, driver)
return async_get_entry_func
async def _async_get_node(
hass: HomeAssistant, connection: ActiveConnection, msg: dict, device_id: str
) -> Node | None:
"""Get node from message data."""
try:
node = async_get_node_from_device_id(hass, device_id)
except ValueError as err:
error_code = ERR_NOT_FOUND
if "loaded" in err.args[0]:
error_code = ERR_NOT_LOADED
connection.send_error(msg[ID], error_code, err.args[0])
return None
return node
def async_get_node(
orig_func: Callable[
[HomeAssistant, ActiveConnection, dict[str, Any], Node],
Coroutine[Any, Any, None],
],
) -> Callable[
[HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None]
]:
"""Decorate async function to get node."""
@wraps(orig_func)
async def async_get_node_func(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Provide user specific data and store to function."""
node = await _async_get_node(hass, connection, msg, msg[DEVICE_ID])
if not node:
return
await orig_func(hass, connection, msg, node)
return async_get_node_func
def async_handle_failed_command[**_P](
orig_func: Callable[
Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P],
Coroutine[Any, Any, None],
],
) -> Callable[
Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P],
Coroutine[Any, Any, None],
]:
"""Decorate async function to handle FailedCommand and send relevant error."""
@wraps(orig_func)
async def async_handle_failed_command_func(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
*args: _P.args,
**kwargs: _P.kwargs,
) -> None:
"""Handle FailedCommand within function and send relevant error."""
try:
await orig_func(hass, connection, msg, *args, **kwargs)
except FailedCommand as err:
# Unsubscribe to callbacks
if unsubs := msg.get(DATA_UNSUBSCRIBE):
for unsub in unsubs:
unsub()
connection.send_error(msg[ID], err.error_code, err.args[0])
return async_handle_failed_command_func
def node_status(node: Node) -> dict[str, Any]:
"""Get node status."""
return {
"node_id": node.node_id,
"is_routing": node.is_routing,
"status": node.status,
"is_secure": node.is_secure,
"ready": node.ready,
"zwave_plus_version": node.zwave_plus_version,
"highest_security_class": node.highest_security_class,
"is_controller_node": node.is_controller_node,
"has_firmware_update_cc": any(
cc.id == CommandClass.FIRMWARE_UPDATE_MD.value
for cc in node.command_classes
),
}
@callback
def async_register_api(hass: HomeAssistant) -> None:
"""Register all of our api endpoints."""
websocket_api.async_register_command(hass, websocket_network_status)
websocket_api.async_register_command(hass, websocket_subscribe_node_status)
websocket_api.async_register_command(hass, websocket_node_status)
websocket_api.async_register_command(hass, websocket_node_metadata)
websocket_api.async_register_command(hass, websocket_node_alerts)
websocket_api.async_register_command(hass, websocket_add_node)
websocket_api.async_register_command(hass, websocket_cancel_secure_bootstrap_s2)
websocket_api.async_register_command(hass, websocket_subscribe_s2_inclusion)
websocket_api.async_register_command(hass, websocket_grant_security_classes)
websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin)
websocket_api.async_register_command(hass, websocket_subscribe_new_devices)
websocket_api.async_register_command(hass, websocket_provision_smart_start_node)
websocket_api.async_register_command(hass, websocket_unprovision_smart_start_node)
websocket_api.async_register_command(hass, websocket_get_provisioning_entries)
websocket_api.async_register_command(hass, websocket_parse_qr_code_string)
websocket_api.async_register_command(
hass, websocket_try_parse_dsk_from_qr_code_string
)
websocket_api.async_register_command(hass, websocket_lookup_device)
websocket_api.async_register_command(hass, websocket_supports_feature)
websocket_api.async_register_command(hass, websocket_stop_inclusion)
websocket_api.async_register_command(hass, websocket_stop_exclusion)
websocket_api.async_register_command(hass, websocket_remove_node)
websocket_api.async_register_command(hass, websocket_remove_failed_node)
websocket_api.async_register_command(hass, websocket_replace_failed_node)
websocket_api.async_register_command(hass, websocket_begin_rebuilding_routes)
websocket_api.async_register_command(
hass, websocket_subscribe_rebuild_routes_progress
)
websocket_api.async_register_command(hass, websocket_stop_rebuilding_routes)
websocket_api.async_register_command(hass, websocket_refresh_node_info)
websocket_api.async_register_command(hass, websocket_refresh_node_values)
websocket_api.async_register_command(hass, websocket_refresh_node_cc_values)
websocket_api.async_register_command(hass, websocket_rebuild_node_routes)
websocket_api.async_register_command(hass, websocket_set_config_parameter)
websocket_api.async_register_command(hass, websocket_get_config_parameters)
websocket_api.async_register_command(hass, websocket_get_raw_config_parameter)
websocket_api.async_register_command(hass, websocket_set_raw_config_parameter)
websocket_api.async_register_command(hass, websocket_subscribe_log_updates)
websocket_api.async_register_command(hass, websocket_update_log_config)
websocket_api.async_register_command(hass, websocket_get_log_config)
websocket_api.async_register_command(
hass, websocket_update_data_collection_preference
)
websocket_api.async_register_command(hass, websocket_data_collection_status)
websocket_api.async_register_command(hass, websocket_abort_firmware_update)
websocket_api.async_register_command(
hass, websocket_is_node_firmware_update_in_progress
)
websocket_api.async_register_command(
hass, websocket_subscribe_firmware_update_status
)
websocket_api.async_register_command(
hass, websocket_get_node_firmware_update_capabilities
)
websocket_api.async_register_command(
hass, websocket_is_any_ota_firmware_update_in_progress
)
websocket_api.async_register_command(hass, websocket_check_for_config_updates)
websocket_api.async_register_command(hass, websocket_install_config_update)
websocket_api.async_register_command(
hass, websocket_subscribe_controller_statistics
)
websocket_api.async_register_command(hass, websocket_subscribe_node_statistics)
websocket_api.async_register_command(hass, websocket_hard_reset_controller)
websocket_api.async_register_command(hass, websocket_node_capabilities)
websocket_api.async_register_command(hass, websocket_invoke_cc_api)
websocket_api.async_register_command(hass, websocket_get_integration_settings)
websocket_api.async_register_command(hass, websocket_backup_nvm)
websocket_api.async_register_command(hass, websocket_restore_nvm)
hass.http.register_view(FirmwareUploadView(dr.async_get(hass)))
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/network_status",
vol.Exclusive(DEVICE_ID, "id"): str,
vol.Exclusive(ENTRY_ID, "id"): str,
}
)
@websocket_api.async_response
async def websocket_network_status(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Get the status of the Z-Wave JS network."""
if ENTRY_ID in msg:
_, client, driver = await _async_get_entry(hass, connection, msg, msg[ENTRY_ID])
if not client or not driver:
return
elif DEVICE_ID in msg:
node = await _async_get_node(hass, connection, msg, msg[DEVICE_ID])
if not node:
return
client = node.client
assert client.driver
driver = client.driver
else:
connection.send_error(
msg[ID], ERR_INVALID_FORMAT, "Must specify either device_id or entry_id"
)
return
controller = driver.controller
controller.update(await controller.async_get_state())
client_version_info = client.version
assert client_version_info # When client is connected version info is set.
data = {
"client": {
"ws_server_url": client.ws_server_url,
"state": "connected" if client.connected else "disconnected",
"driver_version": client_version_info.driver_version,
"server_version": client_version_info.server_version,
"server_logging_enabled": client.server_logging_enabled,
},
"controller": {
"home_id": controller.home_id,
"sdk_version": controller.sdk_version,
"type": controller.controller_type,
"own_node_id": controller.own_node_id,
"is_primary": controller.is_primary,
"is_using_home_id_from_other_network": (
controller.is_using_home_id_from_other_network
),
"is_sis_present": controller.is_SIS_present,
"was_real_primary": controller.was_real_primary,
"is_suc": controller.is_suc,
"node_type": controller.node_type,
"firmware_version": controller.firmware_version,
"manufacturer_id": controller.manufacturer_id,
"product_id": controller.product_id,
"product_type": controller.product_type,
"supported_function_types": controller.supported_function_types,
"suc_node_id": controller.suc_node_id,
"supports_timers": controller.supports_timers,
"supports_long_range": controller.supports_long_range,
"is_rebuilding_routes": controller.is_rebuilding_routes,
"inclusion_state": controller.inclusion_state,
"rf_region": controller.rf_region,
"status": controller.status,
"nodes": [node_status(node) for node in driver.controller.nodes.values()],
},
}
connection.send_result(
msg[ID],
data,
)
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/subscribe_node_status",
vol.Required(DEVICE_ID): str,
}
)
@websocket_api.async_response
@async_get_node
async def websocket_subscribe_node_status(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Subscribe to node status update events of a Z-Wave JS node."""
@callback
def forward_event(event: dict) -> None:
"""Forward the event."""
connection.send_message(
websocket_api.event_message(
msg[ID],
{"event": event["event"], "status": node.status, "ready": node.ready},
)
)
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [
node.on(evt, forward_event)
for evt in ("alive", "dead", "sleep", "wake up", "ready")
]
connection.send_result(msg[ID])
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/node_status",
vol.Required(DEVICE_ID): str,
}
)
@websocket_api.async_response
@async_get_node
async def websocket_node_status(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Get the status of a Z-Wave JS node."""
connection.send_result(msg[ID], node_status(node))
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/node_metadata",
vol.Required(DEVICE_ID): str,
}
)
@websocket_api.async_response
@async_get_node
async def websocket_node_metadata(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Get the metadata of a Z-Wave JS node."""
data = {
"node_id": node.node_id,
"exclusion": node.device_config.metadata.exclusion,
"inclusion": node.device_config.metadata.inclusion,
"manual": node.device_config.metadata.manual,
"wakeup": node.device_config.metadata.wakeup,
"reset": node.device_config.metadata.reset,
"device_database_url": node.device_database_url,
}
connection.send_result(
msg[ID],
data,
)
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/node_alerts",
vol.Required(DEVICE_ID): str,
}
)
@websocket_api.async_response
async def websocket_node_alerts(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get the alerts for a Z-Wave JS node."""
try:
node = async_get_node_from_device_id(hass, msg[DEVICE_ID])
except ValueError as err:
if "can't be found" in err.args[0]:
provisioning_entry = await async_get_provisioning_entry_from_device_id(
hass, msg[DEVICE_ID]
)
if provisioning_entry:
connection.send_result(
msg[ID],
{
"comments": [
{
"level": "info",
"text": "This device has been provisioned but is not yet included in the "
"network.",
}
],
},
)
else:
connection.send_error(msg[ID], ERR_NOT_FOUND, str(err))
else:
connection.send_error(msg[ID], ERR_NOT_LOADED, str(err))
return
connection.send_result(
msg[ID],
{
"comments": node.device_config.metadata.comments,
"is_embedded": node.device_config.is_embedded,
},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/add_node",
vol.Required(ENTRY_ID): str,
vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.All(
vol.Coerce(int),
vol.In(
[
strategy.value
for strategy in InclusionStrategy
if strategy != InclusionStrategy.SMART_START
]
),
),
vol.Optional(FORCE_SECURITY): bool,
vol.Exclusive(
PLANNED_PROVISIONING_ENTRY, "options"
): PLANNED_PROVISIONING_ENTRY_SCHEMA,
vol.Exclusive(
QR_PROVISIONING_INFORMATION, "options"
): QR_PROVISIONING_INFORMATION_SCHEMA,
vol.Exclusive(QR_CODE_STRING, "options"): QR_CODE_STRING_SCHEMA,
vol.Exclusive(DSK, "options"): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_add_node(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Add a node to the Z-Wave network."""
controller = driver.controller
inclusion_strategy = InclusionStrategy(msg[INCLUSION_STRATEGY])
force_security = msg.get(FORCE_SECURITY)
provisioning = (
msg.get(PLANNED_PROVISIONING_ENTRY)
or msg.get(QR_PROVISIONING_INFORMATION)
or msg.get(QR_CODE_STRING)
)
dsk = msg.get(DSK)
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
@callback
def forward_event(event: dict) -> None:
connection.send_message(
websocket_api.event_message(msg[ID], {"event": event["event"]})
)
@callback
def forward_dsk(event: dict) -> None:
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": event["event"], "dsk": event["dsk"]}
)
)
@callback
def forward_node_added(
node: Node, low_security: bool, low_security_reason: str | None
) -> None:
interview_unsubs = [
node.on("interview started", forward_event),
node.on("interview completed", forward_event),
node.on("interview stage completed", forward_stage),
node.on("interview failed", forward_event),
]
unsubs.extend(interview_unsubs)
node_details = {
"node_id": node.node_id,
"status": node.status,
"ready": node.ready,
"low_security": low_security,
"low_security_reason": low_security_reason,
}
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": "node added", "node": node_details}
)
)
@callback
def forward_requested_grant(event: dict) -> None:
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": event["event"],
"requested_grant": event["requested_grant"].to_dict(),
},
)
)
@callback
def forward_stage(event: dict) -> None:
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": event["event"], "stage": event["stageName"]}
)
)
@callback
def node_found(event: dict) -> None:
node = event["node"]
node_details = {
"node_id": node["nodeId"],
}
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": "node found", "node": node_details}
)
)
@callback
def node_added(event: dict) -> None:
forward_node_added(
event["node"],
event["result"].get("lowSecurity", False),
event["result"].get("lowSecurityReason"),
)
@callback
def device_registered(device: dr.DeviceEntry) -> None:
device_details = {
"name": device.name,
"id": device.id,
"manufacturer": device.manufacturer,
"model": device.model,
}
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": "device registered", "device": device_details}
)
)
connection.subscriptions[msg["id"]] = async_cleanup
unsubs: list[Callable[[], None]] = [
controller.on("inclusion started", forward_event),
controller.on("inclusion failed", forward_event),
controller.on("inclusion stopped", forward_event),
controller.on("validate dsk and enter pin", forward_dsk),
controller.on("grant security classes", forward_requested_grant),
controller.on("node found", node_found),
controller.on("node added", node_added),
async_dispatcher_connect(
hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device_registered
),
]
msg[DATA_UNSUBSCRIBE] = unsubs
if controller.inclusion_state in (InclusionState.INCLUDING, InclusionState.BUSY):
connection.send_result(
msg[ID],
True, # Inclusion is already in progress
)
# Check for nodes that have been added but not fully included
for node in controller.nodes.values():
if node.status != NodeStatus.DEAD and not node.ready:
forward_node_added(
node,
not node.is_secure,
None,
)
else:
try:
result = await controller.async_begin_inclusion(
INCLUSION_STRATEGY_NOT_SMART_START[inclusion_strategy.value],
force_security=force_security,
provisioning=provisioning,
dsk=dsk,
)
except ValueError as err:
connection.send_error(
msg[ID],
ERR_INVALID_FORMAT,
err.args[0],
)
return
connection.send_result(
msg[ID],
result,
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/cancel_secure_bootstrap_s2",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_cancel_secure_bootstrap_s2(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Cancel secure bootstrap S2."""
await driver.controller.async_cancel_secure_bootstrap_s2()
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/subscribe_s2_inclusion",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_subscribe_s2_inclusion(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Subscribe to S2 inclusion initiated by the controller."""
@callback
def async_cleanup() -> None:
for unsub in unsubs:
unsub()
@callback
def forward_dsk(event: dict) -> None:
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": event["event"], "dsk": event["dsk"]}
)
)
@callback
def handle_requested_grant(event: dict) -> None:
"""Accept the requested security classes without user interaction."""
hass.async_create_task(
driver.controller.async_grant_security_classes(event["requested_grant"])
)
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [
driver.controller.on("grant security classes", handle_requested_grant),
driver.controller.on("validate dsk and enter pin", forward_dsk),
]
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/grant_security_classes",
vol.Required(ENTRY_ID): str,
vol.Required(SECURITY_CLASSES): vol.All(
cv.ensure_list,
[vol.Coerce(SecurityClass)],
),
vol.Optional(CLIENT_SIDE_AUTH, default=False): bool,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_grant_security_classes(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Choose SecurityClass grants as part of S2 inclusion process."""
inclusion_grant = InclusionGrant(
[SecurityClass(sec_cls) for sec_cls in msg[SECURITY_CLASSES]],
msg[CLIENT_SIDE_AUTH],
)
await driver.controller.async_grant_security_classes(inclusion_grant)
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/validate_dsk_and_enter_pin",
vol.Required(ENTRY_ID): str,
vol.Required(PIN): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_validate_dsk_and_enter_pin(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Validate DSK and enter PIN as part of S2 inclusion process."""
await driver.controller.async_validate_dsk_and_enter_pin(msg[PIN])
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/subscribe_new_devices",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
async def websocket_subscribe_new_devices(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Subscribe to new devices."""
@callback
def async_cleanup() -> None:
for unsub in unsubs:
unsub()
@callback
def device_registered(device: dr.DeviceEntry) -> None:
device_details = {
"name": device.name,
"id": device.id,
"manufacturer": device.manufacturer,
"model": device.model,
}
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": "device registered", "device": device_details}
)
)
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [
async_dispatcher_connect(
hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device_registered
),
]
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/provision_smart_start_node",
vol.Required(ENTRY_ID): str,
vol.Required(QR_PROVISIONING_INFORMATION): QR_PROVISIONING_INFORMATION_SCHEMA,
vol.Optional(PROTOCOL): vol.Coerce(Protocols),
vol.Optional(DEVICE_NAME): str,
vol.Optional(AREA_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_provision_smart_start_node(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Pre-provision a smart start node."""
qr_info = msg[QR_PROVISIONING_INFORMATION]
if qr_info.version == QRCodeVersion.S2:
connection.send_error(
msg[ID],
ERR_INVALID_FORMAT,
"QR code version S2 is not supported for this command",
)
return
provisioning_info = ProvisioningEntry(
dsk=qr_info.dsk,
security_classes=qr_info.security_classes,
requested_security_classes=qr_info.requested_security_classes,
protocol=msg.get(PROTOCOL),
additional_properties=qr_info.additional_properties,
)
device = None
# Create an empty device if device_name is provided
if device_name := msg.get(DEVICE_NAME):
dev_reg = dr.async_get(hass)
# Create a unique device identifier using the DSK
device_identifier = (DOMAIN, f"provision_{qr_info.dsk}")
manufacturer = None
model = None
device_info = await driver.config_manager.lookup_device(
qr_info.manufacturer_id,
qr_info.product_type,
qr_info.product_id,
)
if device_info:
manufacturer = device_info.manufacturer
model = device_info.label
# Create an empty device
device = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={device_identifier},
name=device_name,
manufacturer=manufacturer,
model=model,
via_device=get_device_id(driver, driver.controller.own_node)
if driver.controller.own_node
else None,
)
dev_reg.async_update_device(
device.id, area_id=msg.get(AREA_ID), name_by_user=device_name
)
if provisioning_info.additional_properties is None:
provisioning_info.additional_properties = {}
provisioning_info.additional_properties["device_id"] = device.id
await driver.controller.async_provision_smart_start_node(provisioning_info)
if device:
connection.send_result(msg[ID], device.id)
else:
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/unprovision_smart_start_node",
vol.Required(ENTRY_ID): str,
vol.Exclusive(DSK, "input"): str,
vol.Exclusive(NODE_ID, "input"): int,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_unprovision_smart_start_node(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Unprovision a smart start node."""
try:
cv.has_at_least_one_key(DSK, NODE_ID)(msg)
except vol.Invalid as err:
connection.send_error(
msg[ID],
ERR_INVALID_FORMAT,
err.args[0],
)
return
dsk_or_node_id = msg.get(DSK) or msg[NODE_ID]
provisioning_entry = await driver.controller.async_get_provisioning_entry(
dsk_or_node_id
)
if (
provisioning_entry
and provisioning_entry.additional_properties
and "device_id" in provisioning_entry.additional_properties
):
device_identifier = (DOMAIN, f"provision_{provisioning_entry.dsk}")
device_id = provisioning_entry.additional_properties["device_id"]
dev_reg = dr.async_get(hass)
device = dev_reg.async_get(device_id)
if device and device.identifiers == {device_identifier}:
# Only remove the device if nothing else has claimed it
dev_reg.async_remove_device(device_id)
await driver.controller.async_unprovision_smart_start_node(dsk_or_node_id)
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/get_provisioning_entries",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_get_provisioning_entries(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Get provisioning entries (entries that have been pre-provisioned)."""
provisioning_entries = await driver.controller.async_get_provisioning_entries()
connection.send_result(msg[ID], [entry.to_dict() for entry in provisioning_entries])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/parse_qr_code_string",
vol.Required(ENTRY_ID): str,
vol.Required(QR_CODE_STRING): QR_CODE_STRING_SCHEMA,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_parse_qr_code_string(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Parse a QR Code String and return QRProvisioningInformation dict."""
qr_provisioning_information = await async_parse_qr_code_string(
client, msg[QR_CODE_STRING]
)
connection.send_result(msg[ID], qr_provisioning_information.to_dict())
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/try_parse_dsk_from_qr_code_string",
vol.Required(ENTRY_ID): str,
vol.Required(QR_CODE_STRING): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_try_parse_dsk_from_qr_code_string(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Try to parse a DSK string from a QR code."""
connection.send_result(
msg[ID],
await async_try_parse_dsk_from_qr_code_string(client, msg[QR_CODE_STRING]),
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/lookup_device",
vol.Required(ENTRY_ID): str,
vol.Required(MANUFACTURER_ID): int,
vol.Required(PRODUCT_TYPE): int,
vol.Required(PRODUCT_ID): int,
vol.Optional(APPLICATION_VERSION): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_lookup_device(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Look up the definition of a given device in the configuration DB."""
device = await driver.config_manager.lookup_device(
msg[MANUFACTURER_ID],
msg[PRODUCT_TYPE],
msg[PRODUCT_ID],
msg.get(APPLICATION_VERSION),
)
if device is None:
connection.send_error(msg[ID], ERR_NOT_FOUND, "Device not found")
else:
connection.send_result(msg[ID], device.to_dict())
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/supports_feature",
vol.Required(ENTRY_ID): str,
vol.Required(FEATURE): vol.Coerce(ZwaveFeature),
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_supports_feature(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Check if controller supports a particular feature."""
supported = await driver.controller.async_supports_feature(msg[FEATURE])
connection.send_result(
msg[ID],
{"supported": supported},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/stop_inclusion",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_stop_inclusion(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Cancel adding a node to the Z-Wave network."""
controller = driver.controller
result = await controller.async_stop_inclusion()
connection.send_result(
msg[ID],
result,
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/stop_exclusion",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_stop_exclusion(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Cancel removing a node from the Z-Wave network."""
controller = driver.controller
result = await controller.async_stop_exclusion()
connection.send_result(
msg[ID],
result,
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/remove_node",
vol.Required(ENTRY_ID): str,
vol.Optional(STRATEGY): vol.Coerce(ExclusionStrategy),
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_remove_node(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Remove a node from the Z-Wave network."""
controller = driver.controller
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
@callback
def forward_event(event: dict) -> None:
connection.send_message(
websocket_api.event_message(msg[ID], {"event": event["event"]})
)
@callback
def node_removed(event: dict) -> None:
node = event["node"]
node_details = {
"node_id": node.node_id,
"reason": event["reason"],
}
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": "node removed", "node": node_details}
)
)
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [
controller.on("exclusion started", forward_event),
controller.on("exclusion failed", forward_event),
controller.on("exclusion stopped", forward_event),
controller.on("node removed", node_removed),
]
result = await controller.async_begin_exclusion(msg.get(STRATEGY))
connection.send_result(
msg[ID],
result,
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/replace_failed_node",
vol.Required(DEVICE_ID): str,
vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.All(
vol.Coerce(int),
vol.In(
[
strategy.value
for strategy in InclusionStrategy
if strategy != InclusionStrategy.SMART_START
]
),
),
vol.Optional(FORCE_SECURITY): bool,
vol.Exclusive(
PLANNED_PROVISIONING_ENTRY, "options"
): PLANNED_PROVISIONING_ENTRY_SCHEMA,
vol.Exclusive(
QR_PROVISIONING_INFORMATION, "options"
): QR_PROVISIONING_INFORMATION_SCHEMA,
vol.Exclusive(QR_CODE_STRING, "options"): QR_CODE_STRING_SCHEMA,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_replace_failed_node(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Replace a failed node with a new node."""
assert node.client.driver
controller = node.client.driver.controller
inclusion_strategy = InclusionStrategy(msg[INCLUSION_STRATEGY])
force_security = msg.get(FORCE_SECURITY)
provisioning = (
msg.get(PLANNED_PROVISIONING_ENTRY)
or msg.get(QR_PROVISIONING_INFORMATION)
or msg.get(QR_CODE_STRING)
)
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
@callback
def forward_event(event: dict) -> None:
connection.send_message(
websocket_api.event_message(msg[ID], {"event": event["event"]})
)
@callback
def forward_dsk(event: dict) -> None:
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": event["event"], "dsk": event["dsk"]}
)
)
@callback
def forward_requested_grant(event: dict) -> None:
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": event["event"],
"requested_grant": event["requested_grant"].to_dict(),
},
)
)
@callback
def forward_stage(event: dict) -> None:
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": event["event"], "stage": event["stageName"]}
)
)
@callback
def node_found(event: dict) -> None:
node = event["node"]
node_details = {
"node_id": node["nodeId"],
}
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": "node found", "node": node_details}
)
)
@callback
def node_added(event: dict) -> None:
node = event["node"]
interview_unsubs = [
node.on("interview started", forward_event),
node.on("interview completed", forward_event),
node.on("interview stage completed", forward_stage),
node.on("interview failed", forward_event),
]
unsubs.extend(interview_unsubs)
node_details = {
"node_id": node.node_id,
"status": node.status,
"ready": node.ready,
}
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": "node added", "node": node_details}
)
)
@callback
def node_removed(event: dict) -> None:
node = event["node"]
node_details = {
"node_id": node.node_id,
}
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": "node removed", "node": node_details}
)
)
@callback
def device_registered(device: dr.DeviceEntry) -> None:
device_details = {
"name": device.name,
"id": device.id,
"manufacturer": device.manufacturer,
"model": device.model,
}
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": "device registered", "device": device_details}
)
)
connection.subscriptions[msg["id"]] = async_cleanup
unsubs: list[Callable[[], None]] = [
controller.on("inclusion started", forward_event),
controller.on("inclusion failed", forward_event),
controller.on("inclusion stopped", forward_event),
controller.on("validate dsk and enter pin", forward_dsk),
controller.on("grant security classes", forward_requested_grant),
controller.on("node removed", node_removed),
controller.on("node found", node_found),
controller.on("node added", node_added),
async_dispatcher_connect(
hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device_registered
),
]
msg[DATA_UNSUBSCRIBE] = unsubs
try:
result = await controller.async_replace_failed_node(
node,
INCLUSION_STRATEGY_NOT_SMART_START[inclusion_strategy.value],
force_security=force_security,
provisioning=provisioning,
)
except ValueError as err:
connection.send_error(
msg[ID],
ERR_INVALID_FORMAT,
err.args[0],
)
return
connection.send_result(
msg[ID],
result,
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/remove_failed_node",
vol.Required(DEVICE_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_remove_failed_node(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Remove a failed node from the Z-Wave network."""
driver = node.client.driver
assert driver is not None # The node comes from the driver instance.
controller = driver.controller
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
@callback
def node_removed(event: dict) -> None:
node_details = {"node_id": event["node"].node_id}
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": "node removed", "node": node_details}
)
)
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [controller.on("node removed", node_removed)]
await controller.async_remove_failed_node(node)
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/begin_rebuilding_routes",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_begin_rebuilding_routes(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Begin rebuilding Z-Wave routes."""
controller = driver.controller
result = await controller.async_begin_rebuilding_routes()
connection.send_result(
msg[ID],
result,
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/subscribe_rebuild_routes_progress",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_get_entry
async def websocket_subscribe_rebuild_routes_progress(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Subscribe to rebuild Z-Wave routes status updates."""
controller = driver.controller
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
@callback
def forward_event(key: str, event: dict) -> None:
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": event["event"], "rebuild_routes_status": event[key]}
)
)
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [
controller.on("rebuild routes progress", partial(forward_event, "progress")),
controller.on("rebuild routes done", partial(forward_event, "result")),
]
if controller.rebuild_routes_progress:
connection.send_result(
msg[ID],
{
node.node_id: status
for node, status in controller.rebuild_routes_progress.items()
},
)
else:
connection.send_result(msg[ID], None)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/stop_rebuilding_routes",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_stop_rebuilding_routes(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Stop rebuilding Z-Wave routes."""
controller = driver.controller
result = await controller.async_stop_rebuilding_routes()
connection.send_result(
msg[ID],
result,
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/rebuild_node_routes",
vol.Required(DEVICE_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_rebuild_node_routes(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Heal a node on the Z-Wave network."""
driver = node.client.driver
assert driver is not None # The node comes from the driver instance.
controller = driver.controller
result = await controller.async_rebuild_node_routes(node)
connection.send_result(
msg[ID],
result,
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/refresh_node_info",
vol.Required(DEVICE_ID): str,
},
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_refresh_node_info(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Re-interview a node."""
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
@callback
def forward_event(event: dict) -> None:
connection.send_message(
websocket_api.event_message(msg[ID], {"event": event["event"]})
)
@callback
def forward_stage(event: dict) -> None:
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": event["event"], "stage": event["stageName"]}
)
)
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [
node.on("interview started", forward_event),
node.on("interview completed", forward_event),
node.on("interview stage completed", forward_stage),
node.on("interview failed", forward_event),
]
await node.async_refresh_info()
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/refresh_node_values",
vol.Required(DEVICE_ID): str,
},
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_refresh_node_values(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Refresh node values."""
await node.async_refresh_values()
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/refresh_node_cc_values",
vol.Required(DEVICE_ID): str,
vol.Required(COMMAND_CLASS_ID): int,
},
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_refresh_node_cc_values(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Refresh node values for a particular CommandClass."""
command_class_id = msg[COMMAND_CLASS_ID]
try:
command_class = CommandClass(command_class_id)
except ValueError:
connection.send_error(
msg[ID], ERR_NOT_FOUND, f"Command class {command_class_id} not found"
)
return
await node.async_refresh_cc_values(command_class)
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/set_config_parameter",
vol.Required(DEVICE_ID): str,
vol.Required(PROPERTY): int,
vol.Optional(ENDPOINT, default=0): int,
vol.Optional(PROPERTY_KEY): int,
vol.Required(VALUE): vol.Any(int, BITMASK_SCHEMA),
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_set_config_parameter(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Set a config parameter value for a Z-Wave node."""
property_ = msg[PROPERTY]
endpoint = msg[ENDPOINT]
property_key = msg.get(PROPERTY_KEY)
value = msg[VALUE]
try:
zwave_value, cmd_status = await async_set_config_parameter(
node, value, property_, property_key=property_key, endpoint=endpoint
)
except (InvalidNewValue, NotFoundError, NotImplementedError, SetValueFailed) as err:
code = ERR_UNKNOWN_ERROR
if isinstance(err, NotFoundError):
code = ERR_NOT_FOUND
elif isinstance(err, (InvalidNewValue, NotImplementedError)):
code = ERR_NOT_SUPPORTED
connection.send_error(
msg[ID],
code,
str(err),
)
return
connection.send_result(
msg[ID],
{
VALUE_ID: zwave_value.value_id,
STATUS: cmd_status.status,
},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/get_config_parameters",
vol.Required(DEVICE_ID): str,
}
)
@websocket_api.async_response
@async_get_node
async def websocket_get_config_parameters(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], node: Node
) -> None:
"""Get a list of configuration parameters for a Z-Wave node."""
values = node.get_configuration_values()
result: dict[str, Any] = {}
for value_id, zwave_value in values.items():
metadata = zwave_value.metadata
result[value_id] = {
"property": zwave_value.property_,
"property_key": zwave_value.property_key,
"endpoint": zwave_value.endpoint,
"configuration_value_type": zwave_value.configuration_value_type.value,
"metadata": {
"description": metadata.description,
"label": metadata.label,
"type": metadata.type,
"min": metadata.min,
"max": metadata.max,
"unit": metadata.unit,
"writeable": metadata.writeable,
"readable": metadata.readable,
"default": metadata.default,
},
"value": zwave_value.value,
}
if zwave_value.metadata.states:
result[value_id]["metadata"]["states"] = zwave_value.metadata.states
connection.send_result(
msg[ID],
result,
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/set_raw_config_parameter",
vol.Required(DEVICE_ID): str,
vol.Required(PROPERTY): int,
vol.Required(VALUE): int,
vol.Required(VALUE_SIZE): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)),
vol.Required(VALUE_FORMAT): vol.Coerce(ConfigurationValueFormat),
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_set_raw_config_parameter(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Set a custom config parameter value for a Z-Wave node."""
result = await node.async_set_raw_config_parameter_value(
msg[VALUE],
msg[PROPERTY],
value_size=msg[VALUE_SIZE],
value_format=msg[VALUE_FORMAT],
)
connection.send_result(
msg[ID],
{
STATUS: result.status,
},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/get_raw_config_parameter",
vol.Required(DEVICE_ID): str,
vol.Required(PROPERTY): int,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_get_raw_config_parameter(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Get a custom config parameter value for a Z-Wave node."""
value = await node.async_get_raw_config_parameter_value(
msg[PROPERTY],
)
connection.send_result(
msg[ID],
{
VALUE: value,
},
)
def filename_is_present_if_logging_to_file(obj: dict) -> dict:
"""Validate that filename is provided if log_to_file is True."""
if obj.get(LOG_TO_FILE, False) and FILENAME not in obj:
raise vol.Invalid("`filename` must be provided if logging to file")
return obj
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/subscribe_log_updates",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_subscribe_log_updates(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Subscribe to log message events from the server."""
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
hass.async_create_task(client.async_stop_listening_logs())
for unsub in unsubs:
unsub()
@callback
def log_messages(event: dict) -> None:
log_msg: LogMessage = event["log_message"]
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"type": "log_message",
"log_message": {
"timestamp": log_msg.timestamp,
"level": log_msg.level,
"primary_tags": log_msg.primary_tags,
"message": log_msg.formatted_message,
},
},
)
)
@callback
def log_config_updates(event: dict) -> None:
log_config: LogConfig = event["log_config"]
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"type": "log_config",
"log_config": dataclasses.asdict(log_config),
},
)
)
msg[DATA_UNSUBSCRIBE] = unsubs = [
driver.on("logging", log_messages),
driver.on("log config updated", log_config_updates),
]
connection.subscriptions[msg["id"]] = async_cleanup
await client.async_start_listening_logs()
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/update_log_config",
vol.Required(ENTRY_ID): str,
vol.Required(CONFIG): vol.All(
vol.Schema(
{
vol.Optional(ENABLED): cv.boolean,
vol.Optional(LEVEL): vol.All(
str,
vol.Lower,
vol.Coerce(LogLevel),
),
vol.Optional(LOG_TO_FILE): cv.boolean,
vol.Optional(FILENAME): str,
vol.Optional(FORCE_CONSOLE): cv.boolean,
}
),
cv.has_at_least_one_key(
ENABLED, FILENAME, FORCE_CONSOLE, LEVEL, LOG_TO_FILE
),
filename_is_present_if_logging_to_file,
),
},
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_update_log_config(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Update the driver log config."""
await driver.async_update_log_config(LogConfig(**msg[CONFIG]))
connection.send_result(
msg[ID],
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/get_log_config",
vol.Required(ENTRY_ID): str,
},
)
@websocket_api.async_response
@async_get_entry
async def websocket_get_log_config(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Get log configuration for the Z-Wave JS driver."""
assert client and client.driver
connection.send_result(
msg[ID],
dataclasses.asdict(driver.log_config),
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/update_data_collection_preference",
vol.Required(ENTRY_ID): str,
vol.Required(OPTED_IN): bool,
},
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_update_data_collection_preference(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Update preference for data collection and enable/disable collection."""
opted_in = msg[OPTED_IN]
if entry.data.get(CONF_DATA_COLLECTION_OPTED_IN) != opted_in:
new_data = entry.data.copy()
new_data[CONF_DATA_COLLECTION_OPTED_IN] = opted_in
hass.config_entries.async_update_entry(entry, data=new_data)
if opted_in:
await async_enable_statistics(driver)
else:
await driver.async_disable_statistics()
connection.send_result(
msg[ID],
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/data_collection_status",
vol.Required(ENTRY_ID): str,
},
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_data_collection_status(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Return data collection preference and status."""
assert client and client.driver
result = {
OPTED_IN: entry.data.get(CONF_DATA_COLLECTION_OPTED_IN),
ENABLED: await driver.async_is_statistics_enabled(),
}
connection.send_result(msg[ID], result)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/abort_firmware_update",
vol.Required(DEVICE_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_abort_firmware_update(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Abort a firmware update."""
await node.async_abort_firmware_update()
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/is_node_firmware_update_in_progress",
vol.Required(DEVICE_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_is_node_firmware_update_in_progress(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Get whether firmware update is in progress for given node."""
connection.send_result(msg[ID], await node.async_is_firmware_update_in_progress())
def _get_node_firmware_update_progress_dict(
progress: NodeFirmwareUpdateProgress,
) -> dict[str, int | float]:
"""Get a dictionary of a node's firmware update progress."""
return {
"current_file": progress.current_file,
"total_files": progress.total_files,
"sent_fragments": progress.sent_fragments,
"total_fragments": progress.total_fragments,
"progress": progress.progress,
}
def _get_controller_firmware_update_progress_dict(
progress: ControllerFirmwareUpdateProgress,
) -> dict[str, int | float]:
"""Get a dictionary of a controller's firmware update progress."""
return {
"current_file": 1,
"total_files": 1,
"sent_fragments": progress.sent_fragments,
"total_fragments": progress.total_fragments,
"progress": progress.progress,
}
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/subscribe_firmware_update_status",
vol.Required(DEVICE_ID): str,
}
)
@websocket_api.async_response
@async_get_node
async def websocket_subscribe_firmware_update_status(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Subscribe to the status of a firmware update."""
assert node.client.driver
controller = node.client.driver.controller
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
@callback
def forward_node_progress(event: dict) -> None:
progress: NodeFirmwareUpdateProgress = event["firmware_update_progress"]
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": event["event"],
**_get_node_firmware_update_progress_dict(progress),
},
)
)
@callback
def forward_node_finished(event: dict) -> None:
finished: NodeFirmwareUpdateResult = event["firmware_update_finished"]
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": event["event"],
"status": finished.status,
"success": finished.success,
"wait_time": finished.wait_time,
"reinterview": finished.reinterview,
},
)
)
@callback
def forward_controller_progress(event: dict) -> None:
progress: ControllerFirmwareUpdateProgress = event["firmware_update_progress"]
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": event["event"],
**_get_controller_firmware_update_progress_dict(progress),
},
)
)
@callback
def forward_controller_finished(event: dict) -> None:
finished: ControllerFirmwareUpdateResult = event["firmware_update_finished"]
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": event["event"],
"status": finished.status,
"success": finished.success,
},
)
)
if controller.own_node == node:
msg[DATA_UNSUBSCRIBE] = unsubs = [
controller.on("firmware update progress", forward_controller_progress),
controller.on("firmware update finished", forward_controller_finished),
]
else:
msg[DATA_UNSUBSCRIBE] = unsubs = [
node.on("firmware update progress", forward_node_progress),
node.on("firmware update finished", forward_node_finished),
]
connection.subscriptions[msg["id"]] = async_cleanup
connection.send_result(msg[ID])
if node.is_controller_node and (
controller_progress := controller.firmware_update_progress
):
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": "firmware update progress",
**_get_controller_firmware_update_progress_dict(
controller_progress
),
},
)
)
elif controller.own_node != node and (
node_progress := node.firmware_update_progress
):
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": "firmware update progress",
**_get_node_firmware_update_progress_dict(node_progress),
},
)
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/get_node_firmware_update_capabilities",
vol.Required(DEVICE_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_get_node_firmware_update_capabilities(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Get a node's firmware update capabilities."""
capabilities = await node.async_get_firmware_update_capabilities()
connection.send_result(msg[ID], capabilities.to_dict())
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/is_any_ota_firmware_update_in_progress",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_is_any_ota_firmware_update_in_progress(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Get whether any firmware updates are in progress."""
connection.send_result(
msg[ID], await driver.controller.async_is_any_ota_firmware_update_in_progress()
)
class FirmwareUploadView(HomeAssistantView):
"""View to upload firmware."""
url = r"/api/zwave_js/firmware/upload/{device_id}"
name = "api:zwave_js:firmware:upload"
def __init__(self, dev_reg: dr.DeviceRegistry) -> None:
"""Initialize view."""
super().__init__()
self._dev_reg = dev_reg
@require_admin
async def post(self, request: web.Request, device_id: str) -> web.Response:
"""Handle upload."""
hass = request.app[KEY_HASS]
try:
node = async_get_node_from_device_id(hass, device_id, self._dev_reg)
except ValueError as err:
if "not loaded" in err.args[0]:
raise web_exceptions.HTTPBadRequest from err
raise web_exceptions.HTTPNotFound from err
# If this was not true, we wouldn't have been able to get the node from the
# device ID above
assert node.client.driver
# Increase max payload
request._client_max_size = 1024 * 1024 * 10 # noqa: SLF001
data = await request.post()
if "file" not in data or not isinstance(data["file"], web_request.FileField):
raise web_exceptions.HTTPBadRequest
uploaded_file: web_request.FileField = data["file"]
try:
if node.client.driver.controller.own_node == node:
await controller_firmware_update_otw(
node.client.ws_server_url,
ControllerFirmwareUpdateData(
uploaded_file.filename,
await hass.async_add_executor_job(uploaded_file.file.read),
),
async_get_clientsession(hass),
additional_user_agent_components=USER_AGENT,
)
else:
firmware_target: int | None = None
if "target" in data:
firmware_target = int(cast(str, data["target"]))
await update_firmware(
node.client.ws_server_url,
node,
[
NodeFirmwareUpdateData(
uploaded_file.filename,
await hass.async_add_executor_job(uploaded_file.file.read),
firmware_target=firmware_target,
)
],
async_get_clientsession(hass),
additional_user_agent_components=USER_AGENT,
)
except BaseZwaveJSServerError as err:
raise web_exceptions.HTTPBadRequest(reason=str(err)) from err
return self.json(None)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/check_for_config_updates",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_check_for_config_updates(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Check for config updates."""
config_update = await driver.async_check_for_config_updates()
connection.send_result(
msg[ID],
{
"update_available": config_update.update_available,
"new_version": config_update.new_version,
},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/install_config_update",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_install_config_update(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Check for config updates."""
success = await driver.async_install_config_update()
connection.send_result(msg[ID], success)
def _get_controller_statistics_dict(
statistics: ControllerStatistics,
) -> dict[str, int]:
"""Get dictionary of controller statistics."""
return {
"messages_tx": statistics.messages_tx,
"messages_rx": statistics.messages_rx,
"messages_dropped_tx": statistics.messages_dropped_tx,
"messages_dropped_rx": statistics.messages_dropped_rx,
"nak": statistics.nak,
"can": statistics.can,
"timeout_ack": statistics.timeout_ack,
"timout_response": statistics.timeout_response,
"timeout_callback": statistics.timeout_callback,
}
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/subscribe_controller_statistics",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_get_entry
async def websocket_subscribe_controller_statistics(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Subscribe to the statistics updates for a controller."""
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
@callback
def forward_stats(event: dict) -> None:
statistics: ControllerStatistics = event["statistics_updated"]
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": event["event"],
"source": "controller",
**_get_controller_statistics_dict(statistics),
},
)
)
controller = driver.controller
msg[DATA_UNSUBSCRIBE] = unsubs = [
controller.on("statistics updated", forward_stats)
]
connection.subscriptions[msg["id"]] = async_cleanup
connection.send_result(msg[ID])
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": "statistics updated",
"source": "controller",
**_get_controller_statistics_dict(controller.statistics),
},
)
)
def _get_node_statistics_dict(
hass: HomeAssistant, statistics: NodeStatistics
) -> dict[str, Any]:
"""Get dictionary of node statistics."""
dev_reg = dr.async_get(hass)
def _convert_node_to_device_id(node: Node) -> str:
"""Convert a node to a device id."""
driver = node.client.driver
assert driver
device = dev_reg.async_get_device(identifiers={get_device_id(driver, node)})
assert device
return device.id
data: dict = {
"commands_tx": statistics.commands_tx,
"commands_rx": statistics.commands_rx,
"commands_dropped_tx": statistics.commands_dropped_tx,
"commands_dropped_rx": statistics.commands_dropped_rx,
"timeout_response": statistics.timeout_response,
"rtt": statistics.rtt,
"rssi": statistics.rssi,
"lwr": statistics.lwr.as_dict() if statistics.lwr else None,
"nlwr": statistics.nlwr.as_dict() if statistics.nlwr else None,
}
for key in ("lwr", "nlwr"):
if not data[key]:
continue
for key_2 in ("repeaters", "route_failed_between"):
if not data[key][key_2]:
continue
data[key][key_2] = [
_convert_node_to_device_id(node) for node in data[key][key_2]
]
return data
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/subscribe_node_statistics",
vol.Required(DEVICE_ID): str,
}
)
@websocket_api.async_response
@async_get_node
async def websocket_subscribe_node_statistics(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Subscribe to the statistics updates for a node."""
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
@callback
def forward_stats(event: dict) -> None:
statistics: NodeStatistics = event["statistics_updated"]
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": event["event"],
"source": "node",
"node_id": node.node_id,
**_get_node_statistics_dict(hass, statistics),
},
)
)
msg[DATA_UNSUBSCRIBE] = unsubs = [node.on("statistics updated", forward_stats)]
connection.subscriptions[msg["id"]] = async_cleanup
connection.send_result(msg[ID])
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": "statistics updated",
"source": "node",
"nodeId": node.node_id,
**_get_node_statistics_dict(hass, node.statistics),
},
)
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/hard_reset_controller",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_hard_reset_controller(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Hard reset controller."""
unsubs: list[Callable[[], None]]
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
unsubs.clear()
@callback
def _handle_device_added(device: dr.DeviceEntry) -> None:
"""Handle device is added."""
if entry.entry_id in device.config_entries:
connection.send_result(msg[ID], device.id)
async_cleanup()
@callback
def set_driver_ready(event: dict) -> None:
"Set the driver ready event."
wait_driver_ready.set()
wait_driver_ready = asyncio.Event()
msg[DATA_UNSUBSCRIBE] = unsubs = [
async_dispatcher_connect(
hass, EVENT_DEVICE_ADDED_TO_REGISTRY, _handle_device_added
),
driver.once("driver ready", set_driver_ready),
]
await driver.async_hard_reset()
with suppress(TimeoutError):
async with asyncio.timeout(HARD_RESET_CONTROLLER_DRIVER_READY_TIMEOUT):
await wait_driver_ready.wait()
await hass.config_entries.async_reload(entry.entry_id)
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/node_capabilities",
vol.Required(DEVICE_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_node_capabilities(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Get node endpoints with their support command classes."""
# consumers expect snake_case at the moment
# remove that addition when consumers are updated
connection.send_result(
msg[ID],
{
idx: [
command_class.to_dict() | {"is_secure": command_class.is_secure}
for command_class in endpoint.command_classes
]
for idx, endpoint in node.endpoints.items()
},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/invoke_cc_api",
vol.Required(DEVICE_ID): str,
vol.Required(ATTR_COMMAND_CLASS): vol.All(
vol.Coerce(int), vol.Coerce(CommandClass)
),
vol.Optional(ATTR_ENDPOINT): vol.Coerce(int),
vol.Required(ATTR_METHOD_NAME): cv.string,
vol.Required(ATTR_PARAMETERS): list,
vol.Optional(ATTR_WAIT_FOR_RESULT): cv.boolean,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_node
async def websocket_invoke_cc_api(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Call invokeCCAPI on the node or provided endpoint."""
command_class: CommandClass = msg[ATTR_COMMAND_CLASS]
method_name: str = msg[ATTR_METHOD_NAME]
parameters: list[Any] = msg[ATTR_PARAMETERS]
node_or_endpoint: Node | Endpoint = node
if (endpoint := msg.get(ATTR_ENDPOINT)) is not None:
node_or_endpoint = node.endpoints[endpoint]
try:
result = await node_or_endpoint.async_invoke_cc_api(
command_class,
method_name,
*parameters,
wait_for_result=msg.get(ATTR_WAIT_FOR_RESULT, False),
)
except BaseZwaveJSServerError as err:
connection.send_error(msg[ID], err.__class__.__name__, str(err))
else:
connection.send_result(
msg[ID],
result,
)
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/get_integration_settings",
}
)
def websocket_get_integration_settings(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get Z-Wave JS integration wide configuration."""
connection.send_result(
msg[ID],
{
# list explicitly to avoid leaking other keys and to set default
CONF_INSTALLER_MODE: hass.data[DOMAIN].get(CONF_INSTALLER_MODE, False),
},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/backup_nvm",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_backup_nvm(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Backup NVM data."""
controller = driver.controller
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
@callback
def forward_progress(event: dict) -> None:
"""Forward progress events to websocket."""
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": event["event"],
"bytesRead": event["bytesRead"],
"total": event["total"],
},
)
)
# Set up subscription for progress events
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [
controller.on("nvm backup progress", forward_progress),
]
result = await controller.async_backup_nvm_raw_base64()
# Send the finished event with the backup data
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": "finished",
"data": result,
},
)
)
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/restore_nvm",
vol.Required(ENTRY_ID): str,
vol.Required("data"): str,
}
)
@websocket_api.async_response
@async_handle_failed_command
@async_get_entry
async def websocket_restore_nvm(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
entry: ConfigEntry,
client: Client,
driver: Driver,
) -> None:
"""Restore NVM data."""
controller = driver.controller
@callback
def async_cleanup() -> None:
"""Remove signal listeners."""
for unsub in unsubs:
unsub()
@callback
def forward_progress(event: dict) -> None:
"""Forward progress events to websocket."""
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": event["event"],
"bytesRead": event.get("bytesRead"),
"bytesWritten": event.get("bytesWritten"),
"total": event["total"],
},
)
)
# Set up subscription for progress events
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [
controller.on("nvm convert progress", forward_progress),
controller.on("nvm restore progress", forward_progress),
]
await controller.async_restore_nvm_base64(msg["data"])
connection.send_message(
websocket_api.event_message(
msg[ID],
{
"event": "finished",
},
)
)
connection.send_result(msg[ID])