Add zwave mqtt (#34987)
This commit is contained in:
parent
984a2769db
commit
aeb891649e
26 changed files with 1578 additions and 3 deletions
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
328
homeassistant/components/zwave_mqtt/__init__.py
Normal file
328
homeassistant/components/zwave_mqtt/__init__.py
Normal 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,
|
||||
},
|
||||
)
|
24
homeassistant/components/zwave_mqtt/config_flow.py
Normal file
24
homeassistant/components/zwave_mqtt/config_flow.py
Normal 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")
|
43
homeassistant/components/zwave_mqtt/const.py
Normal file
43
homeassistant/components/zwave_mqtt/const.py
Normal 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"
|
83
homeassistant/components/zwave_mqtt/discovery.py
Normal file
83
homeassistant/components/zwave_mqtt/discovery.py
Normal 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
|
289
homeassistant/components/zwave_mqtt/entity.py
Normal file
289
homeassistant/components/zwave_mqtt/entity.py
Normal 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}"
|
17
homeassistant/components/zwave_mqtt/manifest.json
Normal file
17
homeassistant/components/zwave_mqtt/manifest.json
Normal 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"
|
||||
]
|
||||
}
|
53
homeassistant/components/zwave_mqtt/services.py
Normal file
53
homeassistant/components/zwave_mqtt/services.py
Normal 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()
|
14
homeassistant/components/zwave_mqtt/services.yaml
Normal file
14
homeassistant/components/zwave_mqtt/services.yaml
Normal 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.
|
14
homeassistant/components/zwave_mqtt/strings.json
Normal file
14
homeassistant/components/zwave_mqtt/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
41
homeassistant/components/zwave_mqtt/switch.py
Normal file
41
homeassistant/components/zwave_mqtt/switch.py
Normal 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)
|
14
homeassistant/components/zwave_mqtt/translations/en.json
Normal file
14
homeassistant/components/zwave_mqtt/translations/en.json
Normal 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"
|
||||
}
|
|
@ -146,5 +146,6 @@ FLOWS = [
|
|||
"wwlln",
|
||||
"xiaomi_miio",
|
||||
"zha",
|
||||
"zwave"
|
||||
"zwave",
|
||||
"zwave_mqtt"
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
1
tests/components/zwave_mqtt/__init__.py
Normal file
1
tests/components/zwave_mqtt/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the Z-Wave MQTT integration."""
|
57
tests/components/zwave_mqtt/common.py
Normal file
57
tests/components/zwave_mqtt/common.py
Normal 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)
|
40
tests/components/zwave_mqtt/conftest.py
Normal file
40
tests/components/zwave_mqtt/conftest.py
Normal 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
|
53
tests/components/zwave_mqtt/test_config_flow.py
Normal file
53
tests/components/zwave_mqtt/test_config_flow.py
Normal 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"
|
62
tests/components/zwave_mqtt/test_init.py
Normal file
62
tests/components/zwave_mqtt/test_init.py
Normal 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"
|
88
tests/components/zwave_mqtt/test_scenes.py
Normal file
88
tests/components/zwave_mqtt/test_scenes.py
Normal 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"
|
41
tests/components/zwave_mqtt/test_switch.py
Normal file
41
tests/components/zwave_mqtt/test_switch.py
Normal 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}
|
276
tests/fixtures/zwave_mqtt/generic_network_dump.csv
vendored
Normal file
276
tests/fixtures/zwave_mqtt/generic_network_dump.csv
vendored
Normal file
File diff suppressed because one or more lines are too long
25
tests/fixtures/zwave_mqtt/switch.json
vendored
Normal file
25
tests/fixtures/zwave_mqtt/switch.json
vendored
Normal 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
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue