Add zwave mqtt (#34987)

This commit is contained in:
Martin Hjelmare 2020-05-03 02:54:16 +02:00 committed by GitHub
parent 984a2769db
commit aeb891649e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1578 additions and 3 deletions

View File

@ -878,6 +878,10 @@ omit =
homeassistant/components/zoneminder/*
homeassistant/components/supla/*
homeassistant/components/zwave/util.py
homeassistant/components/zwave_mqtt/__init__.py
homeassistant/components/zwave_mqtt/discovery.py
homeassistant/components/zwave_mqtt/entity.py
homeassistant/components/zwave_mqtt/services.py
[report]
# Regexes for lines to exclude from consideration

View File

@ -18,9 +18,9 @@ repos:
- id: codespell
args:
- --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing
- --skip="./.*,*.json"
- --skip="./.*,*.csv,*.json"
- --quiet-level=2
exclude_types: [json]
exclude_types: [csv, json]
- repo: https://gitlab.com/pycqa/flake8
rev: 3.7.9
hooks:

View File

@ -463,6 +463,7 @@ homeassistant/components/zha/* @dmulcahey @adminiuga
homeassistant/components/zone/* @home-assistant/core
homeassistant/components/zoneminder/* @rohankapoorcom
homeassistant/components/zwave/* @home-assistant/z-wave
homeassistant/components/zwave_mqtt/* @cgarwood @marcelveldt @MartinHjelmare
# Individual files
homeassistant/components/demo/weather @fabaff

View File

@ -0,0 +1,328 @@
"""The zwave_mqtt integration."""
import asyncio
import json
import logging
from openzwavemqtt import OZWManager, OZWOptions
from openzwavemqtt.const import (
EVENT_INSTANCE_EVENT,
EVENT_NODE_ADDED,
EVENT_NODE_CHANGED,
EVENT_NODE_REMOVED,
EVENT_VALUE_ADDED,
EVENT_VALUE_CHANGED,
EVENT_VALUE_REMOVED,
CommandClass,
ValueType,
)
from openzwavemqtt.models.node import OZWNode
from openzwavemqtt.models.value import OZWValue
import voluptuous as vol
from homeassistant.components import mqtt
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg
from homeassistant.helpers.dispatcher import async_dispatcher_send
from . import const
from .const import DATA_UNSUBSCRIBE, DOMAIN, PLATFORMS, TOPIC_OPENZWAVE
from .discovery import DISCOVERY_SCHEMAS, check_node_schema, check_value_schema
from .entity import (
ZWaveDeviceEntityValues,
create_device_id,
create_device_name,
create_value_id,
)
from .services import ZWaveServices
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
DATA_DEVICES = "zwave-mqtt-devices"
async def async_setup(hass: HomeAssistant, config: dict):
"""Initialize basic config of zwave_mqtt component."""
if "mqtt" not in hass.config.components:
_LOGGER.error("MQTT integration is not set up")
return False
hass.data[DOMAIN] = {}
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up zwave_mqtt from a config entry."""
zwave_mqtt_data = hass.data[DOMAIN][entry.entry_id] = {}
zwave_mqtt_data[DATA_UNSUBSCRIBE] = []
data_nodes = {}
data_values = {}
removed_nodes = []
@callback
def send_message(topic, payload):
mqtt.async_publish(hass, topic, json.dumps(payload))
options = OZWOptions(send_message=send_message, topic_prefix=f"{TOPIC_OPENZWAVE}/")
manager = OZWManager(options)
@callback
def async_node_added(node):
# Caution: This is also called on (re)start.
_LOGGER.debug("[NODE ADDED] node_id: %s", node.id)
data_nodes[node.id] = node
if node.id not in data_values:
data_values[node.id] = []
@callback
def async_node_changed(node):
_LOGGER.debug("[NODE CHANGED] node_id: %s", node.id)
data_nodes[node.id] = node
# notify devices about the node change
if node.id not in removed_nodes:
hass.async_create_task(async_handle_node_update(hass, node))
@callback
def async_node_removed(node):
_LOGGER.debug("[NODE REMOVED] node_id: %s", node.id)
data_nodes.pop(node.id)
# node added/removed events also happen on (re)starts of hass/mqtt/ozw
# cleanup device/entity registry if we know this node is permanently deleted
# entities itself are removed by the values logic
if node.id in removed_nodes:
hass.async_create_task(async_handle_remove_node(hass, node))
removed_nodes.remove(node.id)
@callback
def async_instance_event(message):
event = message["event"]
event_data = message["data"]
_LOGGER.debug("[INSTANCE EVENT]: %s - data: %s", event, event_data)
# The actual removal action of a Z-Wave node is reported as instance event
# Only when this event is detected we cleanup the device and entities from hass
if event == "removenode" and "Node" in event_data:
removed_nodes.append(event_data["Node"])
@callback
def async_value_added(value):
node = value.node
node_id = value.node.node_id
# Filter out CommandClasses we're definitely not interested in.
if value.command_class in [
CommandClass.CONFIGURATION,
CommandClass.VERSION,
CommandClass.MANUFACTURER_SPECIFIC,
]:
return
_LOGGER.debug(
"[VALUE ADDED] node_id: %s - label: %s - value: %s - value_id: %s - CC: %s",
value.node.id,
value.label,
value.value,
value.value_id_key,
value.command_class,
)
node_data_values = data_values[node_id]
# Check if this value should be tracked by an existing entity
value_unique_id = create_value_id(value)
for values in node_data_values:
values.async_check_value(value)
if values.values_id == value_unique_id:
return # this value already has an entity
# Run discovery on it and see if any entities need created
for schema in DISCOVERY_SCHEMAS:
if not check_node_schema(node, schema):
continue
if not check_value_schema(
value, schema[const.DISC_VALUES][const.DISC_PRIMARY]
):
continue
values = ZWaveDeviceEntityValues(hass, options, schema, value)
values.async_setup()
# We create a new list and update the reference here so that
# the list can be safely iterated over in the main thread
data_values[node_id] = node_data_values + [values]
@callback
def async_value_changed(value):
# if an entity belonging to this value needs updating,
# it's handled within the entity logic
_LOGGER.debug(
"[VALUE CHANGED] node_id: %s - label: %s - value: %s - value_id: %s - CC: %s",
value.node.id,
value.label,
value.value,
value.value_id_key,
value.command_class,
)
# Handle a scene activation message
if value.command_class in [
CommandClass.SCENE_ACTIVATION,
CommandClass.CENTRAL_SCENE,
]:
async_handle_scene_activated(hass, value)
return
@callback
def async_value_removed(value):
_LOGGER.debug(
"[VALUE REMOVED] node_id: %s - label: %s - value: %s - value_id: %s - CC: %s",
value.node.id,
value.label,
value.value,
value.value_id_key,
value.command_class,
)
# signal all entities using this value for removal
value_unique_id = create_value_id(value)
async_dispatcher_send(hass, const.SIGNAL_DELETE_ENTITY, value_unique_id)
# remove value from our local list
node_data_values = data_values[value.node.id]
node_data_values[:] = [
item for item in node_data_values if item.values_id != value_unique_id
]
# Listen to events for node and value changes
options.listen(EVENT_NODE_ADDED, async_node_added)
options.listen(EVENT_NODE_CHANGED, async_node_changed)
options.listen(EVENT_NODE_REMOVED, async_node_removed)
options.listen(EVENT_VALUE_ADDED, async_value_added)
options.listen(EVENT_VALUE_CHANGED, async_value_changed)
options.listen(EVENT_VALUE_REMOVED, async_value_removed)
options.listen(EVENT_INSTANCE_EVENT, async_instance_event)
# Register Services
services = ZWaveServices(hass, manager)
services.async_register()
@callback
def async_receive_message(msg):
manager.receive_message(msg.topic, msg.payload)
async def start_platforms():
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_setup(entry, component)
for component in PLATFORMS
]
)
zwave_mqtt_data[DATA_UNSUBSCRIBE].append(
await mqtt.async_subscribe(
hass, f"{TOPIC_OPENZWAVE}/#", async_receive_message
)
)
hass.async_create_task(start_platforms())
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
# cleanup platforms
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if not unload_ok:
return False
# unsubscribe all listeners
for unsubscribe_listener in hass.data[DOMAIN][entry.entry_id][DATA_UNSUBSCRIBE]:
unsubscribe_listener()
hass.data[DOMAIN].pop(entry.entry_id)
return True
async def async_handle_remove_node(hass: HomeAssistant, node: OZWNode):
"""Handle the removal of a Z-Wave node, removing all traces in device/entity registry."""
dev_registry = await get_dev_reg(hass)
# grab device in device registry attached to this node
dev_id = create_device_id(node)
device = dev_registry.async_get_device({(DOMAIN, dev_id)}, set())
if not device:
return
devices_to_remove = [device.id]
# also grab slave devices (node instances)
for item in dev_registry.devices.values():
if item.via_device_id == device.id:
devices_to_remove.append(item.id)
# remove all devices in registry related to this node
# note: removal of entity registry is handled by core
for dev_id in devices_to_remove:
dev_registry.async_remove_device(dev_id)
async def async_handle_node_update(hass: HomeAssistant, node: OZWNode):
"""
Handle a node updated event from OZW.
Meaning some of the basic info like name/model is updated.
We want these changes to be pushed to the device registry.
"""
dev_registry = await get_dev_reg(hass)
# grab device in device registry attached to this node
dev_id = create_device_id(node)
device = dev_registry.async_get_device({(DOMAIN, dev_id)}, set())
if not device:
return
# update device in device registry with (updated) info
for item in dev_registry.devices.values():
if item.id != device.id and item.via_device_id != device.id:
continue
dev_name = create_device_name(node)
dev_registry.async_update_device(
item.id,
manufacturer=node.node_manufacturer_name,
model=node.node_product_name,
name=dev_name,
)
@callback
def async_handle_scene_activated(hass: HomeAssistant, scene_value: OZWValue):
"""Handle a (central) scene activation message."""
node_id = scene_value.node.id
scene_id = scene_value.index
scene_label = scene_value.label
if scene_value.command_class == CommandClass.SCENE_ACTIVATION:
# legacy/network scene
scene_value_id = scene_value.value
scene_value_label = scene_value.label
else:
# central scene command
if scene_value.type != ValueType.LIST:
return
scene_value_label = scene_value.value["Selected"]
scene_value_id = scene_value.value["Selected_id"]
_LOGGER.debug(
"[SCENE_ACTIVATED] node_id: %s - scene_id: %s - scene_value_id: %s",
node_id,
scene_id,
scene_value_id,
)
# Simply forward it to the hass event bus
hass.bus.async_fire(
const.EVENT_SCENE_ACTIVATED,
{
const.ATTR_NODE_ID: node_id,
const.ATTR_SCENE_ID: scene_id,
const.ATTR_SCENE_LABEL: scene_label,
const.ATTR_SCENE_VALUE_ID: scene_value_id,
const.ATTR_SCENE_VALUE_LABEL: scene_value_label,
},
)

View File

@ -0,0 +1,24 @@
"""Config flow for zwave_mqtt integration."""
from homeassistant import config_entries
from .const import DOMAIN # pylint:disable=unused-import
TITLE = "Z-Wave MQTT"
class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for zwave_mqtt."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
if self._async_current_entries():
return self.async_abort(reason="one_instance_allowed")
if "mqtt" not in self.hass.config.components:
return self.async_abort(reason="mqtt_required")
if user_input is not None:
return self.async_create_entry(title=TITLE, data={})
return self.async_show_form(step_id="user")

View File

@ -0,0 +1,43 @@
"""Constants for the zwave_mqtt integration."""
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
DOMAIN = "zwave_mqtt"
DATA_UNSUBSCRIBE = "unsubscribe"
PLATFORMS = [SWITCH_DOMAIN]
# MQTT Topics
TOPIC_OPENZWAVE = "OpenZWave"
# Common Attributes
ATTR_INSTANCE_ID = "instance_id"
ATTR_SECURE = "secure"
ATTR_NODE_ID = "node_id"
ATTR_SCENE_ID = "scene_id"
ATTR_SCENE_LABEL = "scene_label"
ATTR_SCENE_VALUE_ID = "scene_value_id"
ATTR_SCENE_VALUE_LABEL = "scene_value_label"
# Service specific
SERVICE_ADD_NODE = "add_node"
SERVICE_REMOVE_NODE = "remove_node"
# Home Assistant Events
EVENT_SCENE_ACTIVATED = f"{DOMAIN}.scene_activated"
# Signals
SIGNAL_DELETE_ENTITY = f"{DOMAIN}_delete_entity"
# Discovery Information
DISC_COMMAND_CLASS = "command_class"
DISC_COMPONENT = "component"
DISC_GENERIC_DEVICE_CLASS = "generic_device_class"
DISC_GENRE = "genre"
DISC_INDEX = "index"
DISC_INSTANCE = "instance"
DISC_NODE_ID = "node_id"
DISC_OPTIONAL = "optional"
DISC_PRIMARY = "primary"
DISC_SCHEMAS = "schemas"
DISC_SPECIFIC_DEVICE_CLASS = "specific_device_class"
DISC_TYPE = "type"
DISC_VALUES = "values"

View File

@ -0,0 +1,83 @@
"""Map Z-Wave nodes and values to Home Assistant entities."""
import openzwavemqtt.const as const_ozw
from openzwavemqtt.const import CommandClass, ValueGenre, ValueType
from . import const
DISCOVERY_SCHEMAS = (
{ # Switch platform
const.DISC_COMPONENT: "switch",
const.DISC_GENERIC_DEVICE_CLASS: (
const_ozw.GENERIC_TYPE_METER,
const_ozw.GENERIC_TYPE_SENSOR_ALARM,
const_ozw.GENERIC_TYPE_SENSOR_BINARY,
const_ozw.GENERIC_TYPE_SWITCH_BINARY,
const_ozw.GENERIC_TYPE_ENTRY_CONTROL,
const_ozw.GENERIC_TYPE_SENSOR_MULTILEVEL,
const_ozw.GENERIC_TYPE_SWITCH_MULTILEVEL,
const_ozw.GENERIC_TYPE_GENERIC_CONTROLLER,
const_ozw.GENERIC_TYPE_SWITCH_REMOTE,
const_ozw.GENERIC_TYPE_REPEATER_SLAVE,
const_ozw.GENERIC_TYPE_THERMOSTAT,
const_ozw.GENERIC_TYPE_WALL_CONTROLLER,
),
const.DISC_VALUES: {
const.DISC_PRIMARY: {
const.DISC_COMMAND_CLASS: (CommandClass.SWITCH_BINARY,),
const.DISC_TYPE: ValueType.BOOL,
const.DISC_GENRE: ValueGenre.USER,
}
},
},
)
def check_node_schema(node, schema):
"""Check if node matches the passed node schema."""
if const.DISC_NODE_ID in schema and node.node_id not in schema[const.DISC_NODE_ID]:
return False
if const.DISC_GENERIC_DEVICE_CLASS in schema and not eq_or_in(
node.node_generic, schema[const.DISC_GENERIC_DEVICE_CLASS]
):
return False
if const.DISC_SPECIFIC_DEVICE_CLASS in schema and not eq_or_in(
node.node_specific, schema[const.DISC_SPECIFIC_DEVICE_CLASS]
):
return False
return True
def check_value_schema(value, schema):
"""Check if the value matches the passed value schema."""
if (
const.DISC_COMMAND_CLASS in schema
and value.parent.command_class_id not in schema[const.DISC_COMMAND_CLASS]
):
return False
if const.DISC_TYPE in schema and not eq_or_in(value.type, schema[const.DISC_TYPE]):
return False
if const.DISC_GENRE in schema and not eq_or_in(
value.genre, schema[const.DISC_GENRE]
):
return False
if const.DISC_INDEX in schema and not eq_or_in(
value.index, schema[const.DISC_INDEX]
):
return False
if const.DISC_INSTANCE in schema and not eq_or_in(
value.instance, schema[const.DISC_INSTANCE]
):
return False
if const.DISC_SCHEMAS in schema:
found = False
for schema_item in schema[const.DISC_SCHEMAS]:
found = found or check_value_schema(value, schema_item)
if not found:
return False
return True
def eq_or_in(val, options):
"""Return True if options contains value or if value is equal to options."""
return val in options if isinstance(options, tuple) else val == options

View File

@ -0,0 +1,289 @@
"""Generic Z-Wave Entity Classes."""
import copy
import logging
from openzwavemqtt.const import (
EVENT_INSTANCE_STATUS_CHANGED,
EVENT_VALUE_CHANGED,
OZW_READY_STATES,
)
from openzwavemqtt.models.node import OZWNode
from openzwavemqtt.models.value import OZWValue
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from . import const
from .const import DOMAIN, PLATFORMS
from .discovery import check_node_schema, check_value_schema
_LOGGER = logging.getLogger(__name__)
class ZWaveDeviceEntityValues:
"""Manages entity access to the underlying Z-Wave value objects."""
def __init__(self, hass, options, schema, primary_value):
"""Initialize the values object with the passed entity schema."""
self._hass = hass
self._entity_created = False
self._schema = copy.deepcopy(schema)
self._values = {}
self.options = options
# Go through values listed in the discovery schema, initialize them,
# and add a check to the schema to make sure the Instance matches.
for name, disc_settings in self._schema[const.DISC_VALUES].items():
self._values[name] = None
disc_settings[const.DISC_INSTANCE] = [primary_value.instance]
self._values[const.DISC_PRIMARY] = primary_value
self._node = primary_value.node
self._schema[const.DISC_NODE_ID] = [self._node.node_id]
def async_setup(self):
"""Set up values instance."""
# Check values that have already been discovered for node
# and see if they match the schema and need added to the entity.
for value in self._node.values():
self.async_check_value(value)
# Check if all the _required_ values in the schema are present and
# create the entity.
self._async_check_entity_ready()
def __getattr__(self, name):
"""Get the specified value for this entity."""
return self._values.get(name, None)
def __iter__(self):
"""Allow iteration over all values."""
return iter(self._values.values())
def __contains__(self, name):
"""Check if the specified name/key exists in the values."""
return name in self._values
@callback
def async_check_value(self, value):
"""Check if the new value matches a missing value for this entity.
If a match is found, it is added to the values mapping.
"""
# Make sure the node matches the schema for this entity.
if not check_node_schema(value.node, self._schema):
return
# Go through the possible values for this entity defined by the schema.
for name in self._values:
# Skip if it's already been added.
if self._values[name] is not None:
continue
# Skip if the value doesn't match the schema.
if not check_value_schema(value, self._schema[const.DISC_VALUES][name]):
continue
# Add value to mapping.
self._values[name] = value
# If the entity has already been created, notify it of the new value.
if self._entity_created:
async_dispatcher_send(
self._hass, f"{DOMAIN}_{self.values_id}_value_added"
)
# Check if entity has all required values and create the entity if needed.
self._async_check_entity_ready()
@callback
def _async_check_entity_ready(self):
"""Check if all required values are discovered and create entity."""
# Abort if the entity has already been created
if self._entity_created:
return
# Go through values defined in the schema and abort if a required value is missing.
for name, disc_settings in self._schema[const.DISC_VALUES].items():
if self._values[name] is None and not disc_settings.get(
const.DISC_OPTIONAL
):
return
# We have all the required values, so create the entity.
component = self._schema[const.DISC_COMPONENT]
_LOGGER.debug(
"Adding Node_id=%s Generic_command_class=%s, "
"Specific_command_class=%s, "
"Command_class=%s, Index=%s, Value type=%s, "
"Genre=%s as %s",
self._node.node_id,
self._node.node_generic,
self._node.node_specific,
self.primary.command_class,
self.primary.index,
self.primary.type,
self.primary.genre,
component,
)
self._entity_created = True
if component in PLATFORMS:
async_dispatcher_send(self._hass, f"{DOMAIN}_new_{component}", self)
@property
def values_id(self):
"""Identification for this values collection."""
return create_value_id(self.primary)
class ZWaveDeviceEntity(Entity):
"""Generic Entity Class for a Z-Wave Device."""
def __init__(self, values):
"""Initialize a generic Z-Wave device entity."""
self.values = values
self.options = values.options
@callback
def on_value_update(self):
"""Call when a value is added/updated in the entity EntityValues Collection.
To be overridden by platforms needing this event.
"""
async def async_added_to_hass(self):
"""Call when entity is added."""
# add dispatcher and OZW listeners callbacks,
self.options.listen(EVENT_VALUE_CHANGED, self._value_changed)
self.options.listen(EVENT_INSTANCE_STATUS_CHANGED, self._instance_updated)
# add to on_remove so they will be cleaned up on entity removal
self.async_on_remove(
async_dispatcher_connect(
self.hass, const.SIGNAL_DELETE_ENTITY, self._delete_callback
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self.values.values_id}_value_added",
self._value_added,
)
)
@property
def device_info(self):
"""Return device information for the device registry."""
node = self.values.primary.node
node_instance = self.values.primary.instance
dev_id = create_device_id(node, self.values.primary.instance)
device_info = {
"identifiers": {(DOMAIN, dev_id)},
"name": create_device_name(node),
"manufacturer": node.node_manufacturer_name,
"model": node.node_product_name,
}
# device with multiple instances is split up into virtual devices for each instance
if node_instance > 1:
parent_dev_id = create_device_id(node)
device_info["name"] += f" - Instance {node_instance}"
device_info["via_device"] = (DOMAIN, parent_dev_id)
return device_info
@property
def device_state_attributes(self):
"""Return the device specific state attributes."""
return {const.ATTR_NODE_ID: self.values.primary.node.node_id}
@property
def name(self):
"""Return the name of the entity."""
node = self.values.primary.node
return f"{create_device_name(node)}: {self.values.primary.label}"
@property
def unique_id(self):
"""Return the unique_id of the entity."""
return self.values.values_id
@property
def available(self) -> bool:
"""Return entity availability."""
# Use OZW Daemon status for availability.
instance_status = self.values.primary.ozw_instance.get_status()
return instance_status and instance_status.status in (
state.value for state in OZW_READY_STATES
)
@callback
def _value_changed(self, value):
"""Call when a value from ZWaveDeviceEntityValues is changed.
Should not be overridden by subclasses.
"""
if value.value_id_key in (v.value_id_key for v in self.values if v):
self.on_value_update()
self.async_write_ha_state()
@callback
def _value_added(self):
"""Call when a value from ZWaveDeviceEntityValues is added.
Should not be overridden by subclasses.
"""
self.on_value_update()
@callback
def _instance_updated(self, new_status):
"""Call when the instance status changes.
Should not be overridden by subclasses.
"""
self.on_value_update()
self.async_write_ha_state()
async def _delete_callback(self, values_id):
"""Remove this entity."""
if not self.values:
return # race condition: delete already requested
if values_id == self.values.values_id:
await self.async_remove()
async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed from hass."""
# cleanup OZW listeners
self.options.listeners[EVENT_VALUE_CHANGED].remove(self._value_changed)
self.options.listeners[EVENT_INSTANCE_STATUS_CHANGED].remove(
self._instance_updated
)
def create_device_name(node: OZWNode):
"""Generate sensible (short) default device name from a OZWNode."""
if node.meta_data["Name"]:
dev_name = node.meta_data["Name"]
elif node.node_product_name:
dev_name = node.node_product_name
elif node.node_device_type_string:
dev_name = node.node_device_type_string
else:
dev_name = node.specific_string
return dev_name
def create_device_id(node: OZWNode, node_instance: int = 1):
"""Generate unique device_id from a OZWNode."""
ozw_instance = node.parent.id
dev_id = f"{ozw_instance}.{node.node_id}.{node_instance}"
return dev_id
def create_value_id(value: OZWValue):
"""Generate unique value_id from an OZWValue."""
# [OZW_INSTANCE_ID]-[NODE_ID]-[VALUE_ID_KEY]
return f"{value.node.parent.id}-{value.node.id}-{value.value_id_key}"

View File

@ -0,0 +1,17 @@
{
"domain": "zwave_mqtt",
"name": "Z-Wave over MQTT",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zwave_mqtt",
"requirements": [
"python-openzwave-mqtt==1.0.1"
],
"after_dependencies": [
"mqtt"
],
"codeowners": [
"@cgarwood",
"@marcelveldt",
"@MartinHjelmare"
]
}

View File

@ -0,0 +1,53 @@
"""Methods and classes related to executing Z-Wave commands and publishing these to hass."""
import voluptuous as vol
from homeassistant.core import callback
from . import const
class ZWaveServices:
"""Class that holds our services ( Zwave Commands) that should be published to hass."""
def __init__(self, hass, manager):
"""Initialize with both hass and ozwmanager objects."""
self._hass = hass
self._manager = manager
@callback
def async_register(self):
"""Register all our services."""
self._hass.services.async_register(
const.DOMAIN,
const.SERVICE_ADD_NODE,
self.async_add_node,
schema=vol.Schema(
{
vol.Optional(const.ATTR_INSTANCE_ID, default=1): vol.Coerce(int),
vol.Optional(const.ATTR_SECURE, default=False): vol.Coerce(bool),
}
),
)
self._hass.services.async_register(
const.DOMAIN,
const.SERVICE_REMOVE_NODE,
self.async_remove_node,
schema=vol.Schema(
{vol.Optional(const.ATTR_INSTANCE_ID, default=1): vol.Coerce(int)}
),
)
@callback
def async_add_node(self, service):
"""Enter inclusion mode on the controller."""
instance_id = service.data[const.ATTR_INSTANCE_ID]
secure = service.data[const.ATTR_SECURE]
instance = self._manager.get_instance(instance_id)
instance.add_node(secure)
@callback
def async_remove_node(self, service):
"""Enter exclusion mode on the controller."""
instance_id = service.data[const.ATTR_INSTANCE_ID]
instance = self._manager.get_instance(instance_id)
instance.remove_node()

View File

@ -0,0 +1,14 @@
# Describes the format for available Z-Wave services
add_node:
description: Add a new node to the Z-Wave network.
fields:
secure:
description: Add the new node with secure communications. Secure network key must be set, this process will fallback to add_node (unsecure) for unsupported devices. Note that unsecure devices can't directly talk to secure devices.
instance_id:
description: (Optional) The OZW Instance/Controller to use, defaults to 1.
remove_node:
description: Remove a node from the Z-Wave network. Will set the controller into exclusion mode.
fields:
instance_id:
description: (Optional) The OZW Instance/Controller to use, defaults to 1.

View File

@ -0,0 +1,14 @@
{
"title": "Z-Wave over MQTT",
"config": {
"step": {
"user": {
"title": "Confirm set up"
}
},
"abort": {
"one_instance_allowed": "The integration only supports one Z-Wave instance",
"mqtt_required": "The MQTT integration is not set up"
}
}
}

View File

@ -0,0 +1,41 @@
"""Representation of Z-Wave switches."""
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import DATA_UNSUBSCRIBE, DOMAIN
from .entity import ZWaveDeviceEntity
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Z-Wave switch from config entry."""
@callback
def async_add_switch(value):
"""Add Z-Wave Switch."""
switch = ZWaveSwitch(value)
async_add_entities([switch])
hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append(
async_dispatcher_connect(
hass, f"{DOMAIN}_new_{SWITCH_DOMAIN}", async_add_switch
)
)
class ZWaveSwitch(ZWaveDeviceEntity, SwitchEntity):
"""Representation of a Z-Wave switch."""
@property
def is_on(self):
"""Return a boolean for the state of the switch."""
return bool(self.values.primary.value)
async def async_turn_on(self, **kwargs):
"""Turn the switch on."""
self.values.primary.send_value(True)
async def async_turn_off(self, **kwargs):
"""Turn the switch off."""
self.values.primary.send_value(False)

View File

@ -0,0 +1,14 @@
{
"config": {
"abort": {
"mqtt_required": "The MQTT integration is not set up",
"one_instance_allowed": "The integration only supports one Z-Wave instance"
},
"step": {
"user": {
"title": "Confirm set up"
}
}
},
"title": "Z-Wave over MQTT"
}

View File

@ -146,5 +146,6 @@ FLOWS = [
"wwlln",
"xiaomi_miio",
"zha",
"zwave"
"zwave",
"zwave_mqtt"
]

View File

@ -1679,6 +1679,9 @@ python-nest==4.1.0
# homeassistant.components.nmap_tracker
python-nmap==0.6.1
# homeassistant.components.zwave_mqtt
python-openzwave-mqtt==1.0.1
# homeassistant.components.qbittorrent
python-qbittorrent==0.4.1

View File

@ -661,6 +661,9 @@ python-miio==0.5.0.1
# homeassistant.components.nest
python-nest==4.1.0
# homeassistant.components.zwave_mqtt
python-openzwave-mqtt==1.0.1
# homeassistant.components.synology_dsm
python-synology==0.8.0

View File

@ -0,0 +1 @@
"""Tests for the Z-Wave MQTT integration."""

View File

@ -0,0 +1,57 @@
"""Helpers for tests."""
import json
from homeassistant import config_entries
from homeassistant.components.zwave_mqtt.const import DOMAIN
from tests.async_mock import Mock, patch
from tests.common import MockConfigEntry
async def setup_zwave(hass, entry=None, fixture=None):
"""Set up Z-Wave and load a dump."""
hass.config.components.add("mqtt")
if entry is None:
entry = MockConfigEntry(
domain=DOMAIN,
title="Z-Wave",
connection_class=config_entries.CONN_CLASS_LOCAL_PUSH,
)
entry.add_to_hass(hass)
with patch("homeassistant.components.mqtt.async_subscribe") as mock_subscribe:
mock_subscribe.return_value = Mock()
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert "zwave_mqtt" in hass.config.components
assert len(mock_subscribe.mock_calls) == 1
receive_message = mock_subscribe.mock_calls[0][1][2]
if fixture is not None:
for line in fixture.split("\n"):
topic, payload = line.strip().split(",", 1)
receive_message(Mock(topic=topic, payload=payload))
await hass.async_block_till_done()
return receive_message
class MQTTMessage:
"""Represent a mock MQTT message."""
def __init__(self, topic, payload):
"""Set up message."""
self.topic = topic
self.payload = payload
def decode(self):
"""Decode message payload from a string to a json dict."""
self.payload = json.loads(self.payload)
def encode(self):
"""Encode message payload into a string."""
self.payload = json.dumps(self.payload)

View File

@ -0,0 +1,40 @@
"""Helpers for tests."""
import json
import pytest
from .common import MQTTMessage
from tests.async_mock import patch
from tests.common import load_fixture
@pytest.fixture(name="generic_data", scope="session")
def generic_data_fixture():
"""Load generic MQTT data and return it."""
return load_fixture(f"zwave_mqtt/generic_network_dump.csv")
@pytest.fixture(name="sent_messages")
def sent_messages_fixture():
"""Fixture to capture sent messages."""
sent_messages = []
with patch(
"homeassistant.components.mqtt.async_publish",
side_effect=lambda hass, topic, payload: sent_messages.append(
{"topic": topic, "payload": json.loads(payload)}
),
):
yield sent_messages
@pytest.fixture(name="switch_msg")
async def switch_msg_fixture(hass):
"""Return a mock MQTT msg with a switch actuator message."""
switch_json = json.loads(
await hass.async_add_executor_job(load_fixture, "zwave_mqtt/switch.json")
)
message = MQTTMessage(topic=switch_json["topic"], payload=switch_json["payload"])
message.encode()
return message

View File

@ -0,0 +1,53 @@
"""Test the Z-Wave over MQTT config flow."""
from homeassistant import config_entries, setup
from homeassistant.components.zwave_mqtt.config_flow import TITLE
from homeassistant.components.zwave_mqtt.const import DOMAIN
from tests.async_mock import patch
from tests.common import MockConfigEntry
async def test_user_create_entry(hass):
"""Test the user step creates an entry."""
hass.config.components.add("mqtt")
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] is None
with patch(
"homeassistant.components.zwave_mqtt.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.zwave_mqtt.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result2["type"] == "create_entry"
assert result2["title"] == TITLE
assert result2["data"] == {}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_mqtt_not_setup(hass):
"""Test that mqtt is required."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "abort"
assert result["reason"] == "mqtt_required"
async def test_one_instance_allowed(hass):
"""Test that only one instance is allowed."""
entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "abort"
assert result["reason"] == "one_instance_allowed"

View File

@ -0,0 +1,62 @@
"""Test integration initialization."""
from homeassistant import config_entries
from homeassistant.components.zwave_mqtt import DOMAIN, PLATFORMS, const
from .common import setup_zwave
from tests.common import MockConfigEntry
async def test_init_entry(hass, generic_data):
"""Test setting up config entry."""
await setup_zwave(hass, fixture=generic_data)
# Verify integration + platform loaded.
assert "zwave_mqtt" in hass.config.components
for platform in PLATFORMS:
assert platform in hass.config.components, platform
assert f"{platform}.{DOMAIN}" in hass.config.components, f"{platform}.{DOMAIN}"
# Verify services registered
assert hass.services.has_service(DOMAIN, const.SERVICE_ADD_NODE)
assert hass.services.has_service(DOMAIN, const.SERVICE_REMOVE_NODE)
async def test_unload_entry(hass, generic_data, switch_msg, caplog):
"""Test unload the config entry."""
entry = MockConfigEntry(
domain=DOMAIN,
title="Z-Wave",
connection_class=config_entries.CONN_CLASS_LOCAL_PUSH,
)
entry.add_to_hass(hass)
assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED
receive_message = await setup_zwave(hass, entry=entry, fixture=generic_data)
assert entry.state == config_entries.ENTRY_STATE_LOADED
assert len(hass.states.async_entity_ids("switch")) == 1
await hass.config_entries.async_unload(entry.entry_id)
assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED
assert len(hass.states.async_entity_ids("switch")) == 0
# Send a message for a switch from the broker to check that
# all entity topic subscribers are unsubscribed.
receive_message(switch_msg)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids("switch")) == 0
# Load the integration again and check that there are no errors when
# adding the entities.
# This asserts that we have unsubscribed the entity addition signals
# when unloading the integration previously.
await setup_zwave(hass, entry=entry, fixture=generic_data)
await hass.async_block_till_done()
assert entry.state == config_entries.ENTRY_STATE_LOADED
assert len(hass.states.async_entity_ids("switch")) == 1
for record in caplog.records:
assert record.levelname != "ERROR"

View File

@ -0,0 +1,88 @@
"""Test Z-Wave (central) Scenes."""
from .common import MQTTMessage, setup_zwave
from tests.common import async_capture_events
async def test_scenes(hass, generic_data, sent_messages):
"""Test setting up config entry."""
receive_message = await setup_zwave(hass, fixture=generic_data)
events = async_capture_events(hass, "zwave_mqtt.scene_activated")
# Publish fake scene event on mqtt
message = MQTTMessage(
topic="OpenZWave/1/node/39/instance/1/commandclass/43/value/562950622511127/",
payload={
"Label": "Scene",
"Value": 16,
"Units": "",
"Min": -2147483648,
"Max": 2147483647,
"Type": "Int",
"Instance": 1,
"CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION",
"Index": 0,
"Node": 7,
"Genre": "User",
"Help": "",
"ValueIDKey": 122339347,
"ReadOnly": False,
"WriteOnly": False,
"ValueSet": False,
"ValuePolled": False,
"ChangeVerified": False,
"Event": "valueChanged",
"TimeStamp": 1579630367,
},
)
message.encode()
receive_message(message)
# wait for the event
await hass.async_block_till_done()
assert len(events) == 1
assert events[0].data["scene_value_id"] == 16
# Publish fake central scene event on mqtt
message = MQTTMessage(
topic="OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/",
payload={
"Label": "Scene 1",
"Value": {
"List": [
{"Value": 0, "Label": "Inactive"},
{"Value": 1, "Label": "Pressed 1 Time"},
{"Value": 2, "Label": "Key Released"},
{"Value": 3, "Label": "Key Held down"},
],
"Selected": "Pressed 1 Time",
"Selected_id": 1,
},
"Units": "",
"Min": 0,
"Max": 0,
"Type": "List",
"Instance": 1,
"CommandClass": "COMMAND_CLASS_CENTRAL_SCENE",
"Index": 1,
"Node": 61,
"Genre": "User",
"Help": "",
"ValueIDKey": 281476005806100,
"ReadOnly": False,
"WriteOnly": False,
"ValueSet": False,
"ValuePolled": False,
"ChangeVerified": False,
"Event": "valueChanged",
"TimeStamp": 1579640710,
},
)
message.encode()
receive_message(message)
# wait for the event
await hass.async_block_till_done()
assert len(events) == 2
assert events[1].data["scene_id"] == 1
assert events[1].data["scene_label"] == "Scene 1"
assert events[1].data["scene_value_label"] == "Pressed 1 Time"

View File

@ -0,0 +1,41 @@
"""Test Z-Wave Switches."""
from .common import setup_zwave
async def test_switch(hass, generic_data, sent_messages, switch_msg):
"""Test setting up config entry."""
receive_message = await setup_zwave(hass, fixture=generic_data)
# Test loaded
state = hass.states.get("switch.smart_plug_switch")
assert state is not None
assert state.state == "off"
# Test turning on
await hass.services.async_call(
"switch", "turn_on", {"entity_id": "switch.smart_plug_switch"}, blocking=True
)
assert len(sent_messages) == 1
msg = sent_messages[0]
assert msg["topic"] == "OpenZWave/1/command/setvalue/"
assert msg["payload"] == {"Value": True, "ValueIDKey": 541671440}
# Feedback on state
switch_msg.decode()
switch_msg.payload["Value"] = True
switch_msg.encode()
receive_message(switch_msg)
await hass.async_block_till_done()
state = hass.states.get("switch.smart_plug_switch")
assert state is not None
assert state.state == "on"
# Test turning off
await hass.services.async_call(
"switch", "turn_off", {"entity_id": "switch.smart_plug_switch"}, blocking=True
)
assert len(sent_messages) == 2
msg = sent_messages[1]
assert msg["topic"] == "OpenZWave/1/command/setvalue/"
assert msg["payload"] == {"Value": False, "ValueIDKey": 541671440}

File diff suppressed because one or more lines are too long

25
tests/fixtures/zwave_mqtt/switch.json vendored Normal file
View File

@ -0,0 +1,25 @@
{
"topic": "OpenZWave/1/node/32/instance/1/commandclass/37/value/541671440/",
"payload": {
"Label": "Switch",
"Value": false,
"Units": "",
"Min": 0,
"Max": 0,
"Type": "Bool",
"Instance": 1,
"CommandClass": "COMMAND_CLASS_SWITCH_BINARY",
"Index": 0,
"Node": 32,
"Genre": "User",
"Help": "Turn On/Off Device",
"ValueIDKey": 541671440,
"ReadOnly": false,
"WriteOnly": false,
"ValueSet": false,
"ValuePolled": false,
"ChangeVerified": false,
"Event": "valueAdded",
"TimeStamp": 1579566891
}
}