Install and start Mosquitto MQTT broker add on from MQTT config flow (#124106)

* Opt in to install Mosquitto broker add-on in MQTT config flow

* rephrase

* Tests with supervisor and running add-on

* Complete tests for success flows

* Also set up entry in success flow

* Use realistic names for addon and broker

* Finetuning and fail test cases

* Spelling

* Improve translation strings

* Update addon docstr

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Raise AddonError if add-on does not start

* Only show the option to use the add-on

* Simplify flow, rework and cleanup

* Revert unrelated cleanup, process suggestion

* Move ADDON_SLUG const to addon module

* Move fixture to component level

* Move back supervisor fixture

* Move addon_setup_time_fixture and superfixe to config flow model tests

* Refactor hassio fixture

* Rename helpers as they are no fixtures, remove fixture from their names

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Jan Bouwhuis 2024-08-22 09:07:45 +02:00 committed by GitHub
parent 9b9e98a26e
commit 61114d8328
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 876 additions and 18 deletions

View file

@ -0,0 +1,22 @@
"""Provide MQTT add-on management.
Currently only supports the official mosquitto add-on.
"""
from __future__ import annotations
from homeassistant.components.hassio import AddonManager
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.singleton import singleton
from .const import DOMAIN, LOGGER
ADDON_SLUG = "core_mosquitto"
DATA_ADDON_MANAGER = f"{DOMAIN}_addon_manager"
@singleton(DATA_ADDON_MANAGER)
@callback
def get_addon_manager(hass: HomeAssistant) -> AddonManager:
"""Get the add-on manager."""
return AddonManager(hass, LOGGER, "Mosquitto Mqtt Broker", ADDON_SLUG)

View file

@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from collections import OrderedDict from collections import OrderedDict
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
import logging
import queue import queue
from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError
from types import MappingProxyType from types import MappingProxyType
@ -14,7 +16,12 @@ from cryptography.x509 import load_pem_x509_certificate
import voluptuous as vol import voluptuous as vol
from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.file_upload import process_uploaded_file
from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.hassio import HassioServiceInfo, is_hassio
from homeassistant.components.hassio.addon_manager import (
AddonError,
AddonManager,
AddonState,
)
from homeassistant.config_entries import ( from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
@ -32,6 +39,7 @@ from homeassistant.const import (
CONF_USERNAME, CONF_USERNAME,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import json_dumps from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.selector import ( from homeassistant.helpers.selector import (
@ -51,6 +59,7 @@ from homeassistant.helpers.selector import (
) )
from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
from .addon import get_addon_manager
from .client import MqttClientSetup from .client import MqttClientSetup
from .const import ( from .const import (
ATTR_PAYLOAD, ATTR_PAYLOAD,
@ -91,6 +100,11 @@ from .util import (
valid_publish_topic, valid_publish_topic,
) )
_LOGGER = logging.getLogger(__name__)
ADDON_SETUP_TIMEOUT = 5
ADDON_SETUP_TIMEOUT_ROUNDS = 5
MQTT_TIMEOUT = 5 MQTT_TIMEOUT = 5
ADVANCED_OPTIONS = "advanced_options" ADVANCED_OPTIONS = "advanced_options"
@ -197,6 +211,12 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
entry: ConfigEntry | None entry: ConfigEntry | None
_hassio_discovery: dict[str, Any] | None = None _hassio_discovery: dict[str, Any] | None = None
_addon_manager: AddonManager
def __init__(self) -> None:
"""Set up flow instance."""
self.install_task: asyncio.Task | None = None
self.start_task: asyncio.Task | None = None
@staticmethod @staticmethod
@callback @callback
@ -206,6 +226,118 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return MQTTOptionsFlowHandler(config_entry) return MQTTOptionsFlowHandler(config_entry)
async def _async_install_addon(self) -> None:
"""Install the Mosquitto Mqtt broker add-on."""
addon_manager: AddonManager = get_addon_manager(self.hass)
await addon_manager.async_schedule_install_addon()
async def async_step_install_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Add-on installation failed."""
return self.async_abort(
reason="addon_install_failed",
description_placeholders={"addon": self._addon_manager.addon_name},
)
async def async_step_install_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Mosquitto Broker add-on."""
if self.install_task is None:
self.install_task = self.hass.async_create_task(self._async_install_addon())
if not self.install_task.done():
return self.async_show_progress(
step_id="install_addon",
progress_action="install_addon",
progress_task=self.install_task,
)
try:
await self.install_task
except AddonError as err:
_LOGGER.error(err)
return self.async_show_progress_done(next_step_id="install_failed")
finally:
self.install_task = None
return self.async_show_progress_done(next_step_id="start_addon")
async def async_step_start_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Add-on start failed."""
return self.async_abort(
reason="addon_start_failed",
description_placeholders={"addon": self._addon_manager.addon_name},
)
async def async_step_start_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Start Mosquitto Broker add-on."""
if not self.start_task:
self.start_task = self.hass.async_create_task(self._async_start_addon())
if not self.start_task.done():
return self.async_show_progress(
step_id="start_addon",
progress_action="start_addon",
progress_task=self.start_task,
)
try:
await self.start_task
except AddonError as err:
_LOGGER.error(err)
return self.async_show_progress_done(next_step_id="start_failed")
finally:
self.start_task = None
return self.async_show_progress_done(next_step_id="setup_entry_from_discovery")
async def _async_get_config_and_try(self) -> dict[str, Any] | None:
"""Get the MQTT add-on discovery info and try the connection."""
if self._hassio_discovery is not None:
return self._hassio_discovery
addon_manager: AddonManager = get_addon_manager(self.hass)
try:
addon_discovery_config = (
await addon_manager.async_get_addon_discovery_info()
)
config: dict[str, Any] = {
CONF_BROKER: addon_discovery_config[CONF_HOST],
CONF_PORT: addon_discovery_config[CONF_PORT],
CONF_USERNAME: addon_discovery_config.get(CONF_USERNAME),
CONF_PASSWORD: addon_discovery_config.get(CONF_PASSWORD),
CONF_DISCOVERY: DEFAULT_DISCOVERY,
}
except AddonError:
# We do not have discovery information yet
return None
if await self.hass.async_add_executor_job(
try_connection,
config,
):
self._hassio_discovery = config
return config
return None
async def _async_start_addon(self) -> None:
"""Start the Mosquitto Broker add-on."""
addon_manager: AddonManager = get_addon_manager(self.hass)
await addon_manager.async_schedule_start_addon()
# Sleep some seconds to let the add-on start properly before connecting.
for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS):
await asyncio.sleep(ADDON_SETUP_TIMEOUT)
# Finish setup using discovery info to test the connection
if await self._async_get_config_and_try():
break
else:
raise AddonError(
f"Failed to correctly start {addon_manager.addon_name} add-on"
)
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -213,8 +345,57 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
if self._async_current_entries(): if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed") return self.async_abort(reason="single_instance_allowed")
if is_hassio(self.hass):
# Offer to set up broker add-on if supervisor is available
self._addon_manager = get_addon_manager(self.hass)
return self.async_show_menu(
step_id="user",
menu_options=["addon", "broker"],
description_placeholders={"addon": self._addon_manager.addon_name},
)
# Start up a flow for manual setup
return await self.async_step_broker() return await self.async_step_broker()
async def async_step_setup_entry_from_discovery(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Set up mqtt entry from discovery info."""
if (config := await self._async_get_config_and_try()) is not None:
return self.async_create_entry(
title=self._addon_manager.addon_name,
data=config,
)
raise AbortFlow(
"addon_connection_failed",
description_placeholders={"addon": self._addon_manager.addon_name},
)
async def async_step_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install and start MQTT broker add-on."""
addon_manager = self._addon_manager
try:
addon_info = await addon_manager.async_get_addon_info()
except AddonError as err:
raise AbortFlow(
"addon_info_failed",
description_placeholders={"addon": self._addon_manager.addon_name},
) from err
if addon_info.state == AddonState.RUNNING:
# Finish setup using discovery info
return await self.async_step_setup_entry_from_discovery()
if addon_info.state == AddonState.NOT_RUNNING:
return await self.async_step_start_addon()
# Install the add-on and start it
return await self.async_step_install_addon()
async def async_step_reauth( async def async_step_reauth(
self, entry_data: Mapping[str, Any] self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -293,7 +474,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_hassio( async def async_step_hassio(
self, discovery_info: HassioServiceInfo self, discovery_info: HassioServiceInfo
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Receive a Hass.io discovery.""" """Receive a Hass.io discovery or process setup after addon install."""
await self._async_handle_discovery_without_unique_id() await self._async_handle_discovery_without_unique_id()
self._hassio_discovery = discovery_info.config self._hassio_discovery = discovery_info.config

View file

@ -1,5 +1,7 @@
"""Constants used by multiple MQTT modules.""" """Constants used by multiple MQTT modules."""
import logging
import jinja2 import jinja2
from homeassistant.const import CONF_PAYLOAD, Platform from homeassistant.const import CONF_PAYLOAD, Platform
@ -148,6 +150,7 @@ DEFAULT_WILL = {
} }
DOMAIN = "mqtt" DOMAIN = "mqtt"
LOGGER = logging.getLogger(__package__)
MQTT_CONNECTION_STATE = "mqtt_connection_state" MQTT_CONNECTION_STATE = "mqtt_connection_state"

View file

@ -23,6 +23,13 @@
}, },
"config": { "config": {
"step": { "step": {
"user": {
"description": "Please choose how you want to connect to the MQTT broker:",
"menu_options": {
"addon": "Use the official {addon} add-on.",
"broker": "Manually enter the MQTT broker connection details"
}
},
"broker": { "broker": {
"description": "Please enter the connection information of your MQTT broker.", "description": "Please enter the connection information of your MQTT broker.",
"data": { "data": {
@ -63,6 +70,12 @@
"ws_path": "The WebSocket path to be used for the connection to your MQTT broker." "ws_path": "The WebSocket path to be used for the connection to your MQTT broker."
} }
}, },
"install_addon": {
"title": "Installing add-on"
},
"start_addon": {
"title": "Starting add-on"
},
"hassio_confirm": { "hassio_confirm": {
"title": "MQTT Broker via Home Assistant add-on", "title": "MQTT Broker via Home Assistant add-on",
"description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the add-on {addon}?", "description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the add-on {addon}?",
@ -87,6 +100,10 @@
} }
}, },
"abort": { "abort": {
"addon_info_failed": "Failed get info for the {addon} add-on.",
"addon_install_failed": "Failed to install the {addon} add-on.",
"addon_start_failed": "Failed to start the {addon} add-on.",
"addon_connection_failed": "Failed to connect to the {addon} add-on. Check the add-on status and try again later.",
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"

View file

@ -6,7 +6,7 @@ from collections.abc import Callable, Generator
from importlib.util import find_spec from importlib.util import find_spec
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@ -180,3 +180,92 @@ def mock_legacy_device_tracker_setup() -> Callable[[HomeAssistant, MockScanner],
from .device_tracker.common import mock_legacy_device_tracker_setup from .device_tracker.common import mock_legacy_device_tracker_setup
return mock_legacy_device_tracker_setup return mock_legacy_device_tracker_setup
@pytest.fixture(name="discovery_info")
def discovery_info_fixture() -> Any:
"""Return the discovery info from the supervisor."""
# pylint: disable-next=import-outside-toplevel
from .hassio.common import mock_discovery_info
return mock_discovery_info()
@pytest.fixture(name="get_addon_discovery_info")
def get_addon_discovery_info_fixture(discovery_info: Any) -> Generator[AsyncMock]:
"""Mock get add-on discovery info."""
# pylint: disable-next=import-outside-toplevel
from .hassio.common import mock_get_addon_discovery_info
yield from mock_get_addon_discovery_info(discovery_info)
@pytest.fixture(name="addon_store_info")
def addon_store_info_fixture() -> Generator[AsyncMock]:
"""Mock Supervisor add-on store info."""
# pylint: disable-next=import-outside-toplevel
from .hassio.common import mock_addon_store_info
yield from mock_addon_store_info()
@pytest.fixture(name="addon_info")
def addon_info_fixture() -> Generator[AsyncMock]:
"""Mock Supervisor add-on info."""
# pylint: disable-next=import-outside-toplevel
from .hassio.common import mock_addon_info
yield from mock_addon_info()
@pytest.fixture(name="addon_not_installed")
def addon_not_installed_fixture(
addon_store_info: AsyncMock, addon_info: AsyncMock
) -> AsyncMock:
"""Mock add-on not installed."""
# pylint: disable-next=import-outside-toplevel
from .hassio.common import mock_addon_not_installed
return mock_addon_not_installed(addon_store_info, addon_info)
@pytest.fixture(name="addon_installed")
def addon_installed_fixture(
addon_store_info: AsyncMock, addon_info: AsyncMock
) -> AsyncMock:
"""Mock add-on already installed but not running."""
# pylint: disable-next=import-outside-toplevel
from .hassio.common import mock_addon_installed
return mock_addon_installed(addon_store_info, addon_info)
@pytest.fixture(name="addon_running")
def addon_running_fixture(
addon_store_info: AsyncMock, addon_info: AsyncMock
) -> AsyncMock:
"""Mock add-on already running."""
# pylint: disable-next=import-outside-toplevel
from .hassio.common import mock_addon_running
return mock_addon_running(addon_store_info, addon_info)
@pytest.fixture(name="install_addon")
def install_addon_fixture(
addon_store_info: AsyncMock, addon_info: AsyncMock
) -> Generator[AsyncMock]:
"""Mock install add-on."""
# pylint: disable-next=import-outside-toplevel
from .hassio.common import mock_install_addon
yield from mock_install_addon(addon_store_info, addon_info)
@pytest.fixture(name="start_addon")
def start_addon_fixture() -> Generator[AsyncMock]:
"""Mock start add-on."""
# pylint: disable-next=import-outside-toplevel
from .hassio.common import mock_start_addon
yield from mock_start_addon()

View file

@ -0,0 +1,125 @@
"""Provide common test tools for hassio."""
from __future__ import annotations
from collections.abc import Generator
from typing import Any
from unittest.mock import DEFAULT, AsyncMock, patch
from homeassistant.core import HomeAssistant
def mock_discovery_info() -> Any:
"""Return the discovery info from the supervisor."""
return DEFAULT
def mock_get_addon_discovery_info(discovery_info: Any) -> Generator[AsyncMock]:
"""Mock get add-on discovery info."""
with patch(
"homeassistant.components.hassio.addon_manager.async_get_addon_discovery_info",
return_value=discovery_info,
) as get_addon_discovery_info:
yield get_addon_discovery_info
def mock_addon_store_info() -> Generator[AsyncMock]:
"""Mock Supervisor add-on store info."""
with patch(
"homeassistant.components.hassio.addon_manager.async_get_addon_store_info"
) as addon_store_info:
addon_store_info.return_value = {
"available": False,
"installed": None,
"state": None,
"version": "1.0.0",
}
yield addon_store_info
def mock_addon_info() -> Generator[AsyncMock]:
"""Mock Supervisor add-on info."""
with patch(
"homeassistant.components.hassio.addon_manager.async_get_addon_info",
) as addon_info:
addon_info.return_value = {
"available": False,
"hostname": None,
"options": {},
"state": None,
"update_available": False,
"version": None,
}
yield addon_info
def mock_addon_not_installed(
addon_store_info: AsyncMock, addon_info: AsyncMock
) -> AsyncMock:
"""Mock add-on not installed."""
addon_store_info.return_value["available"] = True
return addon_info
def mock_addon_installed(
addon_store_info: AsyncMock, addon_info: AsyncMock
) -> AsyncMock:
"""Mock add-on already installed but not running."""
addon_store_info.return_value = {
"available": True,
"installed": "1.0.0",
"state": "stopped",
"version": "1.0.0",
}
addon_info.return_value["available"] = True
addon_info.return_value["hostname"] = "core-matter-server"
addon_info.return_value["state"] = "stopped"
addon_info.return_value["version"] = "1.0.0"
return addon_info
def mock_addon_running(addon_store_info: AsyncMock, addon_info: AsyncMock) -> AsyncMock:
"""Mock add-on already running."""
addon_store_info.return_value = {
"available": True,
"installed": "1.0.0",
"state": "started",
"version": "1.0.0",
}
addon_info.return_value["available"] = True
addon_info.return_value["hostname"] = "core-mosquitto"
addon_info.return_value["state"] = "started"
addon_info.return_value["version"] = "1.0.0"
return addon_info
def mock_install_addon(
addon_store_info: AsyncMock, addon_info: AsyncMock
) -> Generator[AsyncMock]:
"""Mock install add-on."""
async def install_addon_side_effect(hass: HomeAssistant, slug: str) -> None:
"""Mock install add-on."""
addon_store_info.return_value = {
"available": True,
"installed": "1.0.0",
"state": "stopped",
"version": "1.0.0",
}
addon_info.return_value["available"] = True
addon_info.return_value["state"] = "stopped"
addon_info.return_value["version"] = "1.0.0"
with patch(
"homeassistant.components.hassio.addon_manager.async_install_addon"
) as install_addon:
install_addon.side_effect = install_addon_side_effect
yield install_addon
def mock_start_addon() -> Generator[AsyncMock]:
"""Mock start add-on."""
with patch(
"homeassistant.components.hassio.addon_manager.async_start_addon"
) as start_addon:
yield start_addon

View file

@ -14,6 +14,8 @@ import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import mqtt from homeassistant.components import mqtt
from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.hassio import HassioServiceInfo
from homeassistant.components.hassio.addon_manager import AddonError
from homeassistant.components.hassio.handler import HassioAPIError
from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED
from homeassistant.const import ( from homeassistant.const import (
CONF_CLIENT_ID, CONF_CLIENT_ID,
@ -28,6 +30,15 @@ from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient
ADD_ON_DISCOVERY_INFO = {
"addon": "Mosquitto Mqtt Broker",
"host": "core-mosquitto",
"port": 1883,
"username": "mock-user",
"password": "mock-pass",
"protocol": "3.1.1",
"ssl": False,
}
MOCK_CLIENT_CERT = b"## mock client certificate file ##" MOCK_CLIENT_CERT = b"## mock client certificate file ##"
MOCK_CLIENT_KEY = b"## mock key file ##" MOCK_CLIENT_KEY = b"## mock key file ##"
@ -186,6 +197,29 @@ def mock_process_uploaded_file(
yield mock_upload yield mock_upload
@pytest.fixture(name="supervisor")
def supervisor_fixture() -> Generator[MagicMock]:
"""Mock Supervisor."""
with patch(
"homeassistant.components.mqtt.config_flow.is_hassio", return_value=True
) as is_hassio:
yield is_hassio
@pytest.fixture(name="addon_setup_time", autouse=True)
def addon_setup_time_fixture() -> Generator[int]:
"""Mock add-on setup sleep time."""
with patch(
"homeassistant.components.mqtt.config_flow.ADDON_SETUP_TIMEOUT", new=0
) as addon_setup_time:
yield addon_setup_time
@pytest.fixture(autouse=True)
def mock_get_addon_discovery_info(get_addon_discovery_info: AsyncMock) -> None:
"""Mock get add-on discovery info."""
@pytest.mark.usefixtures("mqtt_client_mock") @pytest.mark.usefixtures("mqtt_client_mock")
async def test_user_connection_works( async def test_user_connection_works(
hass: HomeAssistant, hass: HomeAssistant,
@ -216,6 +250,47 @@ async def test_user_connection_works(
assert len(mock_finish_setup.mock_calls) == 1 assert len(mock_finish_setup.mock_calls) == 1
@pytest.mark.usefixtures("mqtt_client_mock", "supervisor")
async def test_user_connection_works_with_supervisor(
hass: HomeAssistant,
mock_try_connection: MagicMock,
mock_finish_setup: MagicMock,
) -> None:
"""Test we can finish a config flow with a supervised install."""
mock_try_connection.return_value = True
result = await hass.config_entries.flow.async_init(
"mqtt", context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["menu_options"] == ["addon", "broker"]
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "broker"},
)
# Assert a manual setup flow
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"broker": "127.0.0.1"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].data == {
"broker": "127.0.0.1",
"port": 1883,
"discovery": True,
}
# Check we tried the connection
assert len(mock_try_connection.mock_calls) == 1
# Check config entry got setup
assert len(mock_finish_setup.mock_calls) == 1
await hass.async_block_till_done(wait_background_tasks=True)
@pytest.mark.usefixtures("mqtt_client_mock") @pytest.mark.usefixtures("mqtt_client_mock")
async def test_user_v5_connection_works( async def test_user_v5_connection_works(
hass: HomeAssistant, hass: HomeAssistant,
@ -382,16 +457,8 @@ async def test_hassio_confirm(
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
"mqtt", "mqtt",
data=HassioServiceInfo( data=HassioServiceInfo(
config={ config=ADD_ON_DISCOVERY_INFO.copy(),
"addon": "Mock Addon", name="Mosquitto Mqtt Broker",
"host": "mock-broker",
"port": 1883,
"username": "mock-user",
"password": "mock-pass",
"protocol": "3.1.1", # Set by the addon's discovery, ignored by HA
"ssl": False, # Set by the addon's discovery, ignored by HA
},
name="Mock Addon",
slug="mosquitto", slug="mosquitto",
uuid="1234", uuid="1234",
), ),
@ -399,7 +466,7 @@ async def test_hassio_confirm(
) )
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "hassio_confirm" assert result["step_id"] == "hassio_confirm"
assert result["description_placeholders"] == {"addon": "Mock Addon"} assert result["description_placeholders"] == {"addon": "Mosquitto Mqtt Broker"}
mock_try_connection_success.reset_mock() mock_try_connection_success.reset_mock()
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
@ -408,7 +475,7 @@ async def test_hassio_confirm(
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].data == { assert result["result"].data == {
"broker": "mock-broker", "broker": "core-mosquitto",
"port": 1883, "port": 1883,
"username": "mock-user", "username": "mock-user",
"password": "mock-pass", "password": "mock-pass",
@ -426,14 +493,12 @@ async def test_hassio_cannot_connect(
mock_finish_setup: MagicMock, mock_finish_setup: MagicMock,
) -> None: ) -> None:
"""Test a config flow is aborted when a connection was not successful.""" """Test a config flow is aborted when a connection was not successful."""
mock_try_connection.return_value = True
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
"mqtt", "mqtt",
data=HassioServiceInfo( data=HassioServiceInfo(
config={ config={
"addon": "Mock Addon", "addon": "Mock Addon",
"host": "mock-broker", "host": "core-mosquitto",
"port": 1883, "port": 1883,
"username": "mock-user", "username": "mock-user",
"password": "mock-pass", "password": "mock-pass",
@ -463,6 +528,362 @@ async def test_hassio_cannot_connect(
assert len(mock_finish_setup.mock_calls) == 0 assert len(mock_finish_setup.mock_calls) == 0
@pytest.mark.usefixtures(
"mqtt_client_mock", "supervisor", "addon_info", "addon_running"
)
@pytest.mark.parametrize("discovery_info", [{"config": ADD_ON_DISCOVERY_INFO.copy()}])
async def test_addon_flow_with_supervisor_addon_running(
hass: HomeAssistant,
mock_try_connection_success: MagicMock,
mock_finish_setup: MagicMock,
) -> None:
"""Test we perform an auto config flow with a supervised install.
Case: The Mosquitto add-on is already installed, and running.
"""
# show menu
result = await hass.config_entries.flow.async_init(
"mqtt", context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["menu_options"] == ["addon", "broker"]
assert result["step_id"] == "user"
# select install via add-on
mock_try_connection_success.reset_mock()
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "addon"},
)
await hass.async_block_till_done(wait_background_tasks=True)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].data == {
"broker": "core-mosquitto",
"port": 1883,
"username": "mock-user",
"password": "mock-pass",
"discovery": True,
}
# Check we tried the connection
assert len(mock_try_connection_success.mock_calls)
# Check config entry got setup
assert len(mock_finish_setup.mock_calls) == 1
@pytest.mark.usefixtures(
"mqtt_client_mock", "supervisor", "addon_info", "addon_installed", "start_addon"
)
@pytest.mark.parametrize("discovery_info", [{"config": ADD_ON_DISCOVERY_INFO.copy()}])
async def test_addon_flow_with_supervisor_addon_installed(
hass: HomeAssistant,
mock_try_connection_success: MagicMock,
mock_finish_setup: MagicMock,
) -> None:
"""Test we perform an auto config flow with a supervised install.
Case: The Mosquitto add-on is installed, but not running.
"""
# show menu
result = await hass.config_entries.flow.async_init(
"mqtt", context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["menu_options"] == ["addon", "broker"]
assert result["step_id"] == "user"
# select install via add-on
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "addon"},
)
# add-on installed but not started, so we wait for start-up
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "start_addon"
assert result["step_id"] == "start_addon"
await hass.async_block_till_done()
await hass.async_block_till_done(wait_background_tasks=True)
mock_try_connection_success.reset_mock()
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "start_addon"},
)
# add-on is running, so entry can be installed
await hass.async_block_till_done(wait_background_tasks=True)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].data == {
"broker": "core-mosquitto",
"port": 1883,
"username": "mock-user",
"password": "mock-pass",
"discovery": True,
}
# Check we tried the connection
assert len(mock_try_connection_success.mock_calls)
# Check config entry got setup
assert len(mock_finish_setup.mock_calls) == 1
@pytest.mark.usefixtures(
"mqtt_client_mock", "supervisor", "addon_info", "addon_running"
)
@pytest.mark.parametrize("discovery_info", [{"config": ADD_ON_DISCOVERY_INFO.copy()}])
async def test_addon_flow_with_supervisor_addon_running_connection_fails(
hass: HomeAssistant,
mock_try_connection: MagicMock,
) -> None:
"""Test we perform an auto config flow with a supervised install.
Case: The Mosquitto add-on is already installed, and running.
"""
# show menu
result = await hass.config_entries.flow.async_init(
"mqtt", context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["menu_options"] == ["addon", "broker"]
assert result["step_id"] == "user"
# select install via add-on but the connection fails and the flow will be aborted.
mock_try_connection.return_value = False
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "addon"},
)
await hass.async_block_till_done(wait_background_tasks=True)
assert result["type"] is FlowResultType.ABORT
@pytest.mark.usefixtures(
"mqtt_client_mock",
"supervisor",
"addon_info",
"addon_installed",
)
async def test_addon_not_running_api_error(
hass: HomeAssistant,
start_addon: AsyncMock,
) -> None:
"""Test we perform an auto config flow with a supervised install.
Case: The Mosquitto add-on start fails on a API error.
"""
start_addon.side_effect = HassioAPIError()
result = await hass.config_entries.flow.async_init(
"mqtt", context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["menu_options"] == ["addon", "broker"]
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "addon"},
)
# add-on not installed, so we wait for install
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "start_addon"
assert result["step_id"] == "start_addon"
await hass.async_block_till_done()
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "install_addon"},
)
# add-on start-up failed
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "addon_start_failed"
@pytest.mark.usefixtures(
"mqtt_client_mock",
"supervisor",
"start_addon",
"addon_installed",
)
async def test_addon_discovery_info_error(
hass: HomeAssistant,
addon_info: AsyncMock,
get_addon_discovery_info: AsyncMock,
) -> None:
"""Test we perform an auto config flow with a supervised install.
Case: The Mosquitto add-on start on a discovery error.
"""
get_addon_discovery_info.side_effect = AddonError
result = await hass.config_entries.flow.async_init(
"mqtt", context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["menu_options"] == ["addon", "broker"]
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "addon"},
)
# Addon will retry
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "start_addon"
assert result["step_id"] == "start_addon"
await hass.async_block_till_done()
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "start_addon"},
)
# add-on start-up failed
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "addon_start_failed"
@pytest.mark.usefixtures(
"mqtt_client_mock",
"supervisor",
"start_addon",
"addon_installed",
)
async def test_addon_info_error(
hass: HomeAssistant,
addon_info: AsyncMock,
) -> None:
"""Test we perform an auto config flow with a supervised install.
Case: The Mosquitto add-on info could not be retrieved.
"""
addon_info.side_effect = AddonError()
result = await hass.config_entries.flow.async_init(
"mqtt", context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["menu_options"] == ["addon", "broker"]
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "addon"},
)
# add-on info failed
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "addon_info_failed"
@pytest.mark.usefixtures(
"mqtt_client_mock",
"supervisor",
"addon_info",
"addon_not_installed",
"install_addon",
"start_addon",
)
@pytest.mark.parametrize("discovery_info", [{"config": ADD_ON_DISCOVERY_INFO.copy()}])
async def test_addon_flow_with_supervisor_addon_not_installed(
hass: HomeAssistant,
mock_try_connection_success: MagicMock,
mock_finish_setup: MagicMock,
) -> None:
"""Test we perform an auto config flow with a supervised install.
Case: The Mosquitto add-on is not yet installed nor running.
"""
result = await hass.config_entries.flow.async_init(
"mqtt", context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["menu_options"] == ["addon", "broker"]
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "addon"},
)
# add-on not installed, so we wait for install
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "install_addon"
assert result["step_id"] == "install_addon"
await hass.async_block_till_done()
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "install_addon"},
)
# add-on installed but not started, so we wait for start-up
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "start_addon"
assert result["step_id"] == "start_addon"
await hass.async_block_till_done()
await hass.async_block_till_done(wait_background_tasks=True)
mock_try_connection_success.reset_mock()
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "start_addon"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].data == {
"broker": "core-mosquitto",
"port": 1883,
"username": "mock-user",
"password": "mock-pass",
"discovery": True,
}
# Check we tried the connection
assert len(mock_try_connection_success.mock_calls)
# Check config entry got setup
assert len(mock_finish_setup.mock_calls) == 1
@pytest.mark.usefixtures(
"mqtt_client_mock",
"supervisor",
"addon_info",
"addon_not_installed",
"start_addon",
)
async def test_addon_not_installed_failures(
hass: HomeAssistant,
install_addon: AsyncMock,
) -> None:
"""Test we perform an auto config flow with a supervised install.
Case: The Mosquitto add-on install fails.
"""
install_addon.side_effect = HassioAPIError()
result = await hass.config_entries.flow.async_init(
"mqtt", context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["menu_options"] == ["addon", "broker"]
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "addon"},
)
# add-on not installed, so we wait for install
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "install_addon"
assert result["step_id"] == "install_addon"
await hass.async_block_till_done()
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "install_addon"},
)
# add-on install failed
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "addon_install_failed"
async def test_option_flow( async def test_option_flow(
hass: HomeAssistant, hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_mock_entry: MqttMockHAClientGenerator,