Add ozw add-on discovery and mqtt client (#43838)

This commit is contained in:
Martin Hjelmare 2020-12-02 20:03:29 +01:00 committed by GitHub
parent 8efa9c5097
commit 9043b7b214
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 353 additions and 30 deletions

View File

@ -2,6 +2,7 @@
from datetime import timedelta
import logging
import os
from typing import Optional
import voluptuous as vol
@ -23,6 +24,7 @@ from homeassistant.util.dt import utcnow
from .addon_panel import async_setup_addon_panel
from .auth import async_setup_auth_view
from .const import ATTR_DISCOVERY
from .discovery import async_setup_discovery_view
from .handler import HassIO, HassioAPIError, api_data
from .http import HassIOView
@ -200,6 +202,17 @@ async def async_set_addon_options(
return await hassio.send_command(command, payload=options)
@bind_hass
async def async_get_addon_discovery_info(
hass: HomeAssistantType, slug: str
) -> Optional[dict]:
"""Return discovery data for an add-on."""
hassio = hass.data[DOMAIN]
data = await hassio.retrieve_discovery_messages()
discovered_addons = data[ATTR_DISCOVERY]
return next((addon for addon in discovered_addons if addon["addon"] == slug), None)
@callback
@bind_hass
def get_info(hass):

View File

@ -17,22 +17,25 @@ from openzwavemqtt.const import (
)
from openzwavemqtt.models.node import OZWNode
from openzwavemqtt.models.value import OZWValue
from openzwavemqtt.util.mqtt_client import MQTTClient
import voluptuous as vol
from homeassistant.components import mqtt
from homeassistant.components.hassio.handler import HassioAPIError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
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 (
CONF_INTEGRATION_CREATED_ADDON,
CONF_USE_ADDON,
DATA_UNSUBSCRIBE,
DOMAIN,
MANAGER,
OPTIONS,
PLATFORMS,
TOPIC_OPENZWAVE,
)
@ -50,13 +53,11 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
DATA_DEVICES = "zwave-mqtt-devices"
DATA_STOP_MQTT_CLIENT = "ozw_stop_mqtt_client"
async def async_setup(hass: HomeAssistant, config: dict):
"""Initialize basic config of ozw component."""
if "mqtt" not in hass.config.components:
_LOGGER.error("MQTT integration is not set up")
return False
hass.data[DOMAIN] = {}
return True
@ -69,16 +70,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
data_nodes = {}
data_values = {}
removed_nodes = []
manager_options = {"topic_prefix": f"{TOPIC_OPENZWAVE}/"}
@callback
def send_message(topic, payload):
mqtt.async_publish(hass, topic, json.dumps(payload))
if entry.unique_id is None:
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
options = OZWOptions(send_message=send_message, topic_prefix=f"{TOPIC_OPENZWAVE}/")
if entry.data.get(CONF_USE_ADDON):
# Do not use MQTT integration. Use own MQTT client.
# Retrieve discovery info from the OpenZWave add-on.
discovery_info = await hass.components.hassio.async_get_addon_discovery_info(
"core_zwave"
)
if not discovery_info:
_LOGGER.error("Failed to get add-on discovery info")
raise ConfigEntryNotReady
discovery_info_config = discovery_info["config"]
host = discovery_info_config["host"]
port = discovery_info_config["port"]
username = discovery_info_config["username"]
password = discovery_info_config["password"]
mqtt_client = MQTTClient(host, port, username=username, password=password)
manager_options["send_message"] = mqtt_client.send_message
else:
if "mqtt" not in hass.config.components:
_LOGGER.error("MQTT integration is not set up")
return False
@callback
def send_message(topic, payload):
mqtt.async_publish(hass, topic, json.dumps(payload))
manager_options["send_message"] = send_message
options = OZWOptions(**manager_options)
manager = OZWManager(options)
hass.data[DOMAIN][MANAGER] = manager
hass.data[DOMAIN][OPTIONS] = options
@callback
def async_node_added(node):
@ -234,11 +265,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
for component in PLATFORMS
]
)
ozw_data[DATA_UNSUBSCRIBE].append(
await mqtt.async_subscribe(
hass, f"{TOPIC_OPENZWAVE}/#", async_receive_message
if entry.data.get(CONF_USE_ADDON):
mqtt_client_task = asyncio.create_task(mqtt_client.start_client(manager))
async def async_stop_mqtt_client(event=None):
"""Stop the mqtt client.
Do not unsubscribe the manager topic.
"""
mqtt_client_task.cancel()
try:
await mqtt_client_task
except asyncio.CancelledError:
pass
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt_client)
ozw_data[DATA_STOP_MQTT_CLIENT] = async_stop_mqtt_client
else:
ozw_data[DATA_UNSUBSCRIBE].append(
await mqtt.async_subscribe(
hass, f"{manager.options.topic_prefix}#", async_receive_message
)
)
)
hass.async_create_task(start_platforms())
@ -262,6 +311,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
# unsubscribe all listeners
for unsubscribe_listener in hass.data[DOMAIN][entry.entry_id][DATA_UNSUBSCRIBE]:
unsubscribe_listener()
if entry.data.get(CONF_USE_ADDON):
async_stop_mqtt_client = hass.data[DOMAIN][entry.entry_id][
DATA_STOP_MQTT_CLIENT
]
await async_stop_mqtt_client()
hass.data[DOMAIN].pop(entry.entry_id)
return True

View File

@ -7,7 +7,7 @@ from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from .const import CONF_INTEGRATION_CREATED_ADDON
from .const import CONF_INTEGRATION_CREATED_ADDON, CONF_USE_ADDON
from .const import DOMAIN # pylint:disable=unused-import
_LOGGER = logging.getLogger(__name__)
@ -16,7 +16,6 @@ CONF_ADDON_DEVICE = "device"
CONF_ADDON_NETWORK_KEY = "network_key"
CONF_NETWORK_KEY = "network_key"
CONF_USB_PATH = "usb_path"
CONF_USE_ADDON = "use_addon"
TITLE = "OpenZWave"
ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=False): bool})
@ -43,17 +42,36 @@ class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
# Currently all flow results need the MQTT integration.
# This will change when we have the direct MQTT client connection.
# When that is implemented, move this check to _async_use_mqtt_integration.
if "mqtt" not in self.hass.config.components:
return self.async_abort(reason="mqtt_required")
# Set a unique_id to make sure discovery flow is aborted on progress.
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
if not self.hass.components.hassio.is_hassio():
return self._async_use_mqtt_integration()
return await self.async_step_on_supervisor()
async def async_step_hassio(self, discovery_info):
"""Receive configuration from add-on discovery info.
This flow is triggered by the OpenZWave add-on.
"""
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
addon_config = await self._async_get_addon_config()
self.usb_path = addon_config[CONF_ADDON_DEVICE]
self.network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, "")
return await self.async_step_hassio_confirm()
async def async_step_hassio_confirm(self, user_input=None):
"""Confirm the add-on discovery."""
if user_input is not None:
self.use_addon = True
return self._async_create_entry_from_vars()
return self.async_show_form(step_id="hassio_confirm")
def _async_create_entry_from_vars(self):
"""Return a config entry for the flow."""
return self.async_create_entry(
@ -73,6 +91,8 @@ class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
This is the entry point for the logic that is needed
when this integration will depend on the MQTT integration.
"""
if "mqtt" not in self.hass.config.components:
return self.async_abort(reason="mqtt_required")
return self._async_create_entry_from_vars()
async def async_step_on_supervisor(self, user_input=None):

View File

@ -12,6 +12,7 @@ DOMAIN = "ozw"
DATA_UNSUBSCRIBE = "unsubscribe"
CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon"
CONF_USE_ADDON = "use_addon"
PLATFORMS = [
BINARY_SENSOR_DOMAIN,
@ -24,7 +25,6 @@ PLATFORMS = [
SWITCH_DOMAIN,
]
MANAGER = "manager"
OPTIONS = "options"
# MQTT Topics
TOPIC_OPENZWAVE = "OpenZWave"

View File

@ -4,7 +4,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ozw",
"requirements": [
"python-openzwave-mqtt==1.3.2"
"python-openzwave-mqtt[mqtt-client]==1.4.0"
],
"after_dependencies": [
"mqtt"
@ -14,4 +14,4 @@
"@marcelveldt",
"@MartinHjelmare"
]
}
}

View File

@ -4,17 +4,25 @@
"on_supervisor": {
"title": "Select connection method",
"description": "Do you want to use the OpenZWave Supervisor add-on?",
"data": {"use_addon": "Use the OpenZWave Supervisor add-on"}
"data": { "use_addon": "Use the OpenZWave Supervisor add-on" }
},
"install_addon": {
"title": "The OpenZWave add-on installation has started"
},
"start_addon": {
"title": "Enter the OpenZWave add-on configuration",
"data": {"usb_path": "[%key:common::config_flow::data::usb_path%]", "network_key": "Network Key"}
"data": {
"usb_path": "[%key:common::config_flow::data::usb_path%]",
"network_key": "Network Key"
}
},
"hassio_confirm": {
"title": "Set up OpenZWave integration with the OpenZWave add-on"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"addon_info_failed": "Failed to get OpenZWave add-on info.",
"addon_install_failed": "Failed to install the OpenZWave add-on.",
"addon_set_config_failed": "Failed to set OpenZWave configuration.",

View File

@ -4,6 +4,8 @@
"addon_info_failed": "Failed to get OpenZWave add-on info.",
"addon_install_failed": "Failed to install the OpenZWave add-on.",
"addon_set_config_failed": "Failed to set OpenZWave configuration.",
"already_configured": "Device is already configured",
"already_in_progress": "Configuration flow is already in progress",
"mqtt_required": "The MQTT integration is not set up",
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
@ -14,6 +16,9 @@
"install_addon": "Please wait while the OpenZWave add-on installation finishes. This can take several minutes."
},
"step": {
"hassio_confirm": {
"title": "Set up OpenZWave integration with the OpenZWave add-on"
},
"install_addon": {
"title": "The OpenZWave add-on installation has started"
},

View File

@ -21,7 +21,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from .const import ATTR_CONFIG_PARAMETER, ATTR_CONFIG_VALUE, DOMAIN, MANAGER, OPTIONS
from .const import ATTR_CONFIG_PARAMETER, ATTR_CONFIG_VALUE, DOMAIN, MANAGER
from .lock import ATTR_USERCODE
TYPE = "type"
@ -461,7 +461,7 @@ def websocket_refresh_node_info(hass, connection, msg):
"""Tell OpenZWave to re-interview a node."""
manager = hass.data[DOMAIN][MANAGER]
options = hass.data[DOMAIN][OPTIONS]
options = manager.options
@callback
def forward_node(node):

View File

@ -1794,7 +1794,7 @@ python-nest==4.1.0
python-nmap==0.6.1
# homeassistant.components.ozw
python-openzwave-mqtt==1.3.2
python-openzwave-mqtt[mqtt-client]==1.4.0
# homeassistant.components.qbittorrent
python-qbittorrent==0.4.1

View File

@ -884,7 +884,7 @@ python-miio==0.5.4
python-nest==4.1.0
# homeassistant.components.ozw
python-openzwave-mqtt==1.3.2
python-openzwave-mqtt[mqtt-client]==1.4.0
# homeassistant.components.songpal
python-songpal==0.12

View File

@ -253,3 +253,12 @@ def mock_uninstall_addon():
"homeassistant.components.hassio.async_uninstall_addon"
) as uninstall_addon:
yield uninstall_addon
@pytest.fixture(name="get_addon_discovery_info")
def mock_get_addon_discovery_info():
"""Mock get add-on discovery info."""
with patch(
"homeassistant.components.hassio.async_get_addon_discovery_info"
) as get_addon_discovery_info:
yield get_addon_discovery_info

View File

@ -9,6 +9,14 @@ from homeassistant.components.ozw.const import DOMAIN
from tests.async_mock import patch
from tests.common import MockConfigEntry
ADDON_DISCOVERY_INFO = {
"addon": "OpenZWave",
"host": "host1",
"port": 1234,
"username": "name1",
"password": "pass1",
}
@pytest.fixture(name="supervisor")
def mock_supervisor_fixture():
@ -44,7 +52,7 @@ def mock_addon_installed(addon_info):
def mock_addon_options(addon_info):
"""Mock add-on options."""
addon_info.return_value["options"] = {}
return addon_info
return addon_info.return_value["options"]
@pytest.fixture(name="set_addon_options")
@ -361,3 +369,122 @@ async def test_install_addon_failure(hass, supervisor, addon_installed, install_
assert result["type"] == "abort"
assert result["reason"] == "addon_install_failed"
async def test_supervisor_discovery(hass, supervisor, addon_running, addon_options):
"""Test flow started from Supervisor discovery."""
await setup.async_setup_component(hass, "persistent_notification", {})
addon_options["device"] = "/test"
addon_options["network_key"] = "abc123"
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_HASSIO},
data=ADDON_DISCOVERY_INFO,
)
with patch(
"homeassistant.components.ozw.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.ozw.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
assert result["type"] == "create_entry"
assert result["title"] == TITLE
assert result["data"] == {
"usb_path": "/test",
"network_key": "abc123",
"use_addon": True,
"integration_created_addon": False,
}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_clean_discovery_on_user_create(
hass, supervisor, addon_running, addon_options
):
"""Test discovery flow is cleaned up when a user flow is finished."""
await setup.async_setup_component(hass, "persistent_notification", {})
addon_options["device"] = "/test"
addon_options["network_key"] = "abc123"
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_HASSIO},
data=ADDON_DISCOVERY_INFO,
)
assert result["type"] == "form"
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.ozw.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.ozw.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"use_addon": False}
)
await hass.async_block_till_done()
assert len(hass.config_entries.flow.async_progress()) == 0
assert result["type"] == "create_entry"
assert result["title"] == TITLE
assert result["data"] == {
"usb_path": None,
"network_key": None,
"use_addon": False,
"integration_created_addon": False,
}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_abort_discovery_with_user_flow(
hass, supervisor, addon_running, addon_options
):
"""Test discovery flow is aborted if a user flow is in progress."""
await setup.async_setup_component(hass, "persistent_notification", {})
await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_HASSIO},
data=ADDON_DISCOVERY_INFO,
)
assert result["type"] == "abort"
assert result["reason"] == "already_in_progress"
assert len(hass.config_entries.flow.async_progress()) == 1
async def test_abort_discovery_with_existing_entry(
hass, supervisor, addon_running, addon_options
):
"""Test discovery flow is aborted if an entry already exists."""
await setup.async_setup_component(hass, "persistent_notification", {})
entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=DOMAIN)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_HASSIO},
data=ADDON_DISCOVERY_INFO,
)
assert result["type"] == "abort"
assert result["reason"] == "already_configured"

View File

@ -5,6 +5,7 @@ from homeassistant.components.ozw import DOMAIN, PLATFORMS, const
from .common import setup_ozw
from tests.async_mock import patch
from tests.common import MockConfigEntry
@ -23,6 +24,18 @@ async def test_init_entry(hass, generic_data):
assert hass.services.has_service(DOMAIN, const.SERVICE_REMOVE_NODE)
async def test_setup_entry_without_mqtt(hass):
"""Test setting up config entry without mqtt integration setup."""
entry = MockConfigEntry(
domain=DOMAIN,
title="OpenZWave",
connection_class=config_entries.CONN_CLASS_LOCAL_PUSH,
)
entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(entry.entry_id)
async def test_unload_entry(hass, generic_data, switch_msg, caplog):
"""Test unload the config entry."""
entry = MockConfigEntry(
@ -128,3 +141,75 @@ async def test_remove_entry(hass, stop_addon, uninstall_addon, caplog):
assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
assert "Failed to uninstall the OpenZWave add-on" in caplog.text
async def test_setup_entry_with_addon(hass, get_addon_discovery_info):
"""Test set up entry using OpenZWave add-on."""
entry = MockConfigEntry(
domain=DOMAIN,
title="OpenZWave",
connection_class=config_entries.CONN_CLASS_LOCAL_PUSH,
data={"use_addon": True},
)
entry.add_to_hass(hass)
with patch("homeassistant.components.ozw.MQTTClient", autospec=True) as mock_client:
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert mock_client.return_value.start_client.call_count == 1
# Verify integration + platform loaded.
assert "ozw" 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_setup_entry_without_addon_info(hass, get_addon_discovery_info):
"""Test set up entry using OpenZWave add-on but missing discovery info."""
entry = MockConfigEntry(
domain=DOMAIN,
title="OpenZWave",
connection_class=config_entries.CONN_CLASS_LOCAL_PUSH,
data={"use_addon": True},
)
entry.add_to_hass(hass)
get_addon_discovery_info.return_value = None
with patch("homeassistant.components.ozw.MQTTClient", autospec=True) as mock_client:
assert not await hass.config_entries.async_setup(entry.entry_id)
assert mock_client.return_value.start_client.call_count == 0
assert entry.state == config_entries.ENTRY_STATE_SETUP_RETRY
async def test_unload_entry_with_addon(
hass, get_addon_discovery_info, generic_data, switch_msg, caplog
):
"""Test unload the config entry using the OpenZWave add-on."""
entry = MockConfigEntry(
domain=DOMAIN,
title="OpenZWave",
connection_class=config_entries.CONN_CLASS_LOCAL_PUSH,
data={"use_addon": True},
)
entry.add_to_hass(hass)
assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED
with patch("homeassistant.components.ozw.MQTTClient", autospec=True) as mock_client:
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert mock_client.return_value.start_client.call_count == 1
assert entry.state == config_entries.ENTRY_STATE_LOADED
await hass.config_entries.async_unload(entry.entry_id)
assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED