Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions homeassistant/components/esphome/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,21 +61,25 @@ async def async_setup(self) -> None:
if not (data := self._data) or not (info := data.get("info")):
return
if is_hassio(self._hass):
from homeassistant.components.hassio import get_addons_info # noqa: PLC0415

# This may raise HassioNotReadyError if Supervisor was unreachable
# during setup of the Supervisor integration. That will fail setup
# of this integration. However there is no better option at this time
# since we need to know if the addon is installed from Supervisor to
# correctly setup this integration and we can't raise ConfigEntryNotReady
# to trigger a retry from async_setup.
addons = get_addons_info(self._hass)
if info["addon_slug"] not in addons:
# The addon is not installed anymore, but it make come back
# so we don't want to remove the dashboard, but for now
# we don't want to use it.
_LOGGER.debug("Addon %s is no longer installed", info["addon_slug"])
return
from homeassistant.components.hassio import ( # noqa: PLC0415
HassioNotReadyError,
get_addons_info,
)

try:
addons = get_addons_info(self._hass)
except HassioNotReadyError:
# Supervisor was unreachable during its own setup, so we cannot
# tell if the addon is installed. Restore the dashboard anyway,
# a stale one only fails to refresh.
_LOGGER.debug("Supervisor is not ready, skipping addon check")
else:
if info["addon_slug"] not in addons:
# The addon is not installed anymore, but it make come back
# so we don't want to remove the dashboard, but for now
# we don't want to use it.
_LOGGER.debug("Addon %s is no longer installed", info["addon_slug"])
return

await self.async_set_dashboard_info(
info["addon_slug"], info["host"], info["port"]
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/repairs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@
from . import issue_handler, websocket_api
from .const import DOMAIN, FlowType
from .issue_handler import ConfirmRepairFlow, RepairsFlowManager
from .models import RepairsFlow, RepairsFlowResult
from .models import RepairsFlow, RepairsFlowContext, RepairsFlowResult

__all__ = [
"DOMAIN",
"ConfirmRepairFlow",
"FlowType",
"RepairsFlow",
"RepairsFlowContext",
"RepairsFlowManager",
"RepairsFlowResult",
"repairs_flow_manager",
Expand Down
37 changes: 27 additions & 10 deletions homeassistant/components/repairs/issue_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms

from .const import DOMAIN
from .models import RepairsFlow, RepairsFlowResult, RepairsProtocol
from .models import RepairsFlow, RepairsFlowContext, RepairsFlowResult, RepairsProtocol


class ConfirmRepairFlow(RepairsFlow):
Expand Down Expand Up @@ -43,21 +43,41 @@ async def async_step_confirm(


class RepairsFlowManager(
data_entry_flow.FlowManager[data_entry_flow.FlowContext, RepairsFlowResult, str]
data_entry_flow.FlowManager[RepairsFlowContext, RepairsFlowResult, str]
):
"""Manage repairs flows."""

@override
async def async_init(
self,
handler: str,
*,
context: RepairsFlowContext | None = None,
data: dict[str, Any] | None = None,
) -> RepairsFlowResult:
"""Override to ensure appropriate context is set in the flow result."""
_context: RepairsFlowContext = context or {}
if "issue_id" not in _context and data is not None and "issue_id" in data:
# fallback for custom integrations
_context |= {"issue_id": data["issue_id"]}
if "issue_id" in _context:
# interim compatibility fallback for custom integrations that may expect
# "issue_id" in user_input of async_step_init
data = {**(data or {}), "issue_id": _context["issue_id"]}
return await super().async_init(handler, context=_context, data=data)

@override
async def async_create_flow(
self,
handler_key: str,
*,
context: data_entry_flow.FlowContext | None = None,
context: RepairsFlowContext | None = None,
data: dict[str, Any] | None = None,
) -> RepairsFlow:
"""Create a flow. platform is a repairs module."""
assert data and "issue_id" in data
issue_id = data["issue_id"]
if context is None or "issue_id" not in context:
raise KeyError("issue_id was not set in context")
issue_id = context["issue_id"]

issue_registry = ir.async_get(self.hass)
issue = issue_registry.async_get_issue(handler_key, issue_id)
Expand All @@ -74,16 +94,13 @@ async def async_create_flow(
else:
flow = await platform.async_create_fix_flow(self.hass, issue_id, issue.data)

flow.issue_id = issue_id
flow.data = issue.data
return flow

@override
async def async_finish_flow(
self,
flow: data_entry_flow.FlowHandler[
data_entry_flow.FlowContext, RepairsFlowResult, str
],
flow: data_entry_flow.FlowHandler[RepairsFlowContext, RepairsFlowResult, str],
result: RepairsFlowResult,
) -> RepairsFlowResult:
"""Complete a fix flow.
Expand All @@ -92,7 +109,7 @@ async def async_finish_flow(
FlowResultType.CREATE_ENTRY.
"""
if result.get("type") is not data_entry_flow.FlowResultType.ABORT:
ir.async_delete_issue(self.hass, flow.handler, flow.init_data["issue_id"])
ir.async_delete_issue(self.hass, flow.handler, flow.context["issue_id"])
return result


Expand Down
40 changes: 36 additions & 4 deletions homeassistant/components/repairs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,54 @@
from .const import FlowType


class RepairsFlowContext(data_entry_flow.FlowContext, total=False):
"""Typed flow context for repairs flow."""

issue_id: str


class RepairsFlowResult(
data_entry_flow.FlowResult[data_entry_flow.FlowContext, str], total=False
data_entry_flow.FlowResult[
RepairsFlowContext,
str,
],
total=False,
):
"""Typed result dict for repair flow."""
"""Typed result dict for repairs flow."""

next_flow: tuple[FlowType, str]
result: ConfigEntry | None


class RepairsFlow(
data_entry_flow.FlowHandler[data_entry_flow.FlowContext, RepairsFlowResult, str]
data_entry_flow.FlowHandler[
RepairsFlowContext,
RepairsFlowResult,
str,
]
):
"""Handle a flow for fixing an issue."""

issue_id: str
data: dict[str, str | int | float | None] | None
_issue_id: str

@property
def issue_id(self) -> str:
"""Return the flow's issue_id."""
if "issue_id" in self.context:
return self.context["issue_id"]
# Avoid breaking changes in legacy custom integrations that may access
# this property prior to the flow manager applying the context in async_create_flow.
return self._issue_id

@issue_id.setter
def issue_id(self, issue_id: str) -> None:
"""Allow legacy implementations to set issue_id.

Setter is retained to avoid breaking changes in custom integrations that may set issue_id in a RepairFlow
prior to the flow manager applying the context.
"""
self._issue_id = issue_id

@override
@callback
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/repairs/websocket_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response
try:
result = await self._flow_mgr.async_init(
data["handler"],
data={"issue_id": data["issue_id"]},
context={"issue_id": data["issue_id"]},
)
except data_entry_flow.UnknownFlow as ex:
return self.json_message(
Expand Down
30 changes: 30 additions & 0 deletions tests/components/esphome/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest

from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, dashboard
from homeassistant.components.hassio import HassioNotReadyError
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
Expand Down Expand Up @@ -110,6 +111,35 @@ async def test_restore_dashboard_storage_skipped_if_addon_uninstalled(
assert not mock_dashboard_api.called


@pytest.mark.usefixtures("hassio_stubs")
async def test_restore_dashboard_storage_if_supervisor_not_ready(
hass: HomeAssistant,
hass_storage: dict[str, Any],
) -> None:
"""Restore the dashboard, without failing setup, if Supervisor is not ready."""
hass_storage[dashboard.STORAGE_KEY] = {
"version": dashboard.STORAGE_VERSION,
"minor_version": dashboard.STORAGE_VERSION,
"key": dashboard.STORAGE_KEY,
"data": {"info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052}},
}
with (
patch(
"homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI"
) as mock_dashboard_api,
patch(
"homeassistant.components.esphome.dashboard.is_hassio", return_value=True
),
patch(
"homeassistant.components.hassio.get_addons_info",
side_effect=HassioNotReadyError,
),
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert mock_dashboard_api.mock_calls[0][1][0] == "http://new-host:6052"


async def test_setup_dashboard_fails(
hass: HomeAssistant,
hass_storage: dict[str, Any],
Expand Down
97 changes: 97 additions & 0 deletions tests/components/repairs/test_issue_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Tests for repairs issue_handler.py."""

import pytest

from homeassistant.components.repairs import (
DOMAIN,
RepairsFlow,
RepairsFlowResult,
repairs_flow_manager,
)
from homeassistant.core import HomeAssistant
import homeassistant.helpers.issue_registry as ir

from tests.common import AsyncMock, Mock, async_setup_component, mock_platform


@pytest.fixture(autouse=True)
async def mock_repairs_integration(hass: HomeAssistant) -> None:
"""Mock a repairs integration."""
hass.config.components.add("fake_integration")

async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
return MockFixFlowContext()

mock_platform(
hass,
"fake_integration.repairs",
Mock(async_create_fix_flow=AsyncMock(wraps=async_create_fix_flow)),
)


class MockFixFlowContext(RepairsFlow):
"""Mock for context tests."""

def __init__(self) -> None:
"""Initialize a MockFlowFixContext."""
# Test issue_id setter
self.issue_id = "fake_issue"
assert self.issue_id == "fake_issue"

async def async_step_init(self, user_input: dict | None) -> RepairsFlowResult:
"""Initial step of a repairs flow."""
assert user_input and user_input["issue_id"] == self.issue_id
return self.async_show_form()


@pytest.mark.parametrize(
("ignore_translations_for_mock_domains"),
[
["fake_integration"],
],
)
async def test_flow_fix_via_data(hass: HomeAssistant) -> None:
"""Test that a repairs flow's issue_id can be set via data."""

assert await async_setup_component(hass, DOMAIN, {})

ir.async_create_issue(
hass,
issue_id="context_issue",
domain="fake_integration",
is_fixable=True,
severity="error",
translation_key="fake_key",
)

assert (repairs := repairs_flow_manager(hass))

result = await repairs.async_init(
"fake_integration", data={"issue_id": "context_issue"}
)
assert result["type"] == "form"
result = repairs.async_get(result["flow_id"])
assert result["context"] == {"issue_id": "context_issue"}


@pytest.mark.parametrize(
("ignore_translations_for_mock_domains"),
[
["fake_integration"],
],
)
async def test_flow_fix_missing_context(hass: HomeAssistant) -> None:
"""Test that KeyError is thrown when context and data is missing."""

assert await async_setup_component(hass, DOMAIN, {})

assert (repairs := repairs_flow_manager(hass))

with pytest.raises(KeyError) as exc:
await repairs.async_init("fake_integration")

assert "issue_id was not set in context" in str(exc.value)
Loading
Loading