mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Improve ESPHome update platform error reporting (#96455)
This commit is contained in:
parent
127fbded18
commit
ffe81a9716
@ -14,6 +14,7 @@ from homeassistant.components.update import (
|
|||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
@ -27,6 +28,9 @@ from .entry_data import RuntimeEntryData
|
|||||||
KEY_UPDATE_LOCK = "esphome_update_lock"
|
KEY_UPDATE_LOCK = "esphome_update_lock"
|
||||||
|
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
@ -109,14 +113,10 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
|
|||||||
During deep sleep the ESP will not be connectable (by design)
|
During deep sleep the ESP will not be connectable (by design)
|
||||||
and thus, even when unavailable, we'll show it as available.
|
and thus, even when unavailable, we'll show it as available.
|
||||||
"""
|
"""
|
||||||
return (
|
return super().available and (
|
||||||
super().available
|
self._entry_data.available
|
||||||
and (
|
or self._entry_data.expected_disconnect
|
||||||
self._entry_data.available
|
or self._device_info.has_deep_sleep
|
||||||
or self._entry_data.expected_disconnect
|
|
||||||
or self._device_info.has_deep_sleep
|
|
||||||
)
|
|
||||||
and self._device_info.name in self.coordinator.data
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -137,33 +137,26 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
|
|||||||
"""URL to the full release notes of the latest version available."""
|
"""URL to the full release notes of the latest version available."""
|
||||||
return "https://esphome.io/changelog/"
|
return "https://esphome.io/changelog/"
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_static_info_updated(self, _: list[EntityInfo]) -> None:
|
||||||
|
"""Handle static info update."""
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Handle entity added to Home Assistant."""
|
"""Handle entity added to Home Assistant."""
|
||||||
await super().async_added_to_hass()
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
@callback
|
|
||||||
def _static_info_updated(infos: list[EntityInfo]) -> None:
|
|
||||||
"""Handle static info update."""
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
self.hass,
|
self.hass,
|
||||||
self._entry_data.signal_static_info_updated,
|
self._entry_data.signal_static_info_updated,
|
||||||
_static_info_updated,
|
self._async_static_info_updated,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@callback
|
|
||||||
def _on_device_update() -> None:
|
|
||||||
"""Handle update of device state, like availability."""
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
self.hass,
|
self.hass,
|
||||||
self._entry_data.signal_device_updated,
|
self._entry_data.signal_device_updated,
|
||||||
_on_device_update,
|
self.async_write_ha_state,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -172,16 +165,20 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Install an update."""
|
"""Install an update."""
|
||||||
async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()):
|
async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()):
|
||||||
device = self.coordinator.data.get(self._device_info.name)
|
coordinator = self.coordinator
|
||||||
|
api = coordinator.api
|
||||||
|
device = coordinator.data.get(self._device_info.name)
|
||||||
assert device is not None
|
assert device is not None
|
||||||
if not await self.coordinator.api.compile(device["configuration"]):
|
try:
|
||||||
logging.getLogger(__name__).error(
|
if not await api.compile(device["configuration"]):
|
||||||
"Error compiling %s. Try again in ESPHome dashboard for error",
|
raise HomeAssistantError(
|
||||||
device["configuration"],
|
f"Error compiling {device['configuration']}; "
|
||||||
)
|
"Try again in ESPHome dashboard for more information."
|
||||||
if not await self.coordinator.api.upload(device["configuration"], "OTA"):
|
)
|
||||||
logging.getLogger(__name__).error(
|
if not await api.upload(device["configuration"], "OTA"):
|
||||||
"Error OTA updating %s. Try again in ESPHome dashboard for error",
|
raise HomeAssistantError(
|
||||||
device["configuration"],
|
f"Error updating {device['configuration']} via OTA; "
|
||||||
)
|
"Try again in ESPHome dashboard for more information."
|
||||||
await self.coordinator.async_request_refresh()
|
)
|
||||||
|
finally:
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
@ -15,6 +15,7 @@ from aioesphomeapi import (
|
|||||||
ReconnectLogic,
|
ReconnectLogic,
|
||||||
UserService,
|
UserService,
|
||||||
)
|
)
|
||||||
|
import async_timeout
|
||||||
import pytest
|
import pytest
|
||||||
from zeroconf import Zeroconf
|
from zeroconf import Zeroconf
|
||||||
|
|
||||||
@ -53,6 +54,11 @@ async def load_homeassistant(hass) -> None:
|
|||||||
assert await async_setup_component(hass, "homeassistant", {})
|
assert await async_setup_component(hass, "homeassistant", {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_tts(mock_tts_cache_dir):
|
||||||
|
"""Auto mock the tts cache."""
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_config_entry(hass) -> MockConfigEntry:
|
def mock_config_entry(hass) -> MockConfigEntry:
|
||||||
"""Return the default mocked config entry."""
|
"""Return the default mocked config entry."""
|
||||||
@ -248,10 +254,10 @@ async def _mock_generic_device_entry(
|
|||||||
"homeassistant.components.esphome.manager.ReconnectLogic", MockReconnectLogic
|
"homeassistant.components.esphome.manager.ReconnectLogic", MockReconnectLogic
|
||||||
):
|
):
|
||||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||||
await try_connect_done.wait()
|
async with async_timeout.timeout(2):
|
||||||
|
await try_connect_done.wait()
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
return mock_device
|
return mock_device
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,18 +1,35 @@
|
|||||||
"""Test ESPHome update entities."""
|
"""Test ESPHome update entities."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
import dataclasses
|
import dataclasses
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from aioesphomeapi import (
|
||||||
|
APIClient,
|
||||||
|
EntityInfo,
|
||||||
|
EntityState,
|
||||||
|
UserService,
|
||||||
|
)
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.esphome.dashboard import async_get_dashboard
|
from homeassistant.components.esphome.dashboard import (
|
||||||
|
async_get_dashboard,
|
||||||
|
)
|
||||||
from homeassistant.components.update import UpdateEntityFeature
|
from homeassistant.components.update import UpdateEntityFeature
|
||||||
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE
|
from homeassistant.const import (
|
||||||
|
STATE_OFF,
|
||||||
|
STATE_ON,
|
||||||
|
STATE_UNAVAILABLE,
|
||||||
|
STATE_UNKNOWN,
|
||||||
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
|
|
||||||
|
from .conftest import MockESPHomeDevice
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
|
@pytest.fixture
|
||||||
def stub_reconnect():
|
def stub_reconnect():
|
||||||
"""Stub reconnect."""
|
"""Stub reconnect."""
|
||||||
with patch("homeassistant.components.esphome.manager.ReconnectLogic.start"):
|
with patch("homeassistant.components.esphome.manager.ReconnectLogic.start"):
|
||||||
@ -30,7 +47,7 @@ def stub_reconnect():
|
|||||||
"configuration": "test.yaml",
|
"configuration": "test.yaml",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"on",
|
STATE_ON,
|
||||||
{
|
{
|
||||||
"latest_version": "2023.2.0-dev",
|
"latest_version": "2023.2.0-dev",
|
||||||
"installed_version": "1.0.0",
|
"installed_version": "1.0.0",
|
||||||
@ -44,7 +61,7 @@ def stub_reconnect():
|
|||||||
"current_version": "1.0.0",
|
"current_version": "1.0.0",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"off",
|
STATE_OFF,
|
||||||
{
|
{
|
||||||
"latest_version": "1.0.0",
|
"latest_version": "1.0.0",
|
||||||
"installed_version": "1.0.0",
|
"installed_version": "1.0.0",
|
||||||
@ -53,13 +70,14 @@ def stub_reconnect():
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
[],
|
[],
|
||||||
"unavailable",
|
STATE_UNKNOWN, # dashboard is available but device is unknown
|
||||||
{"supported_features": 0},
|
{"supported_features": 0},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_update_entity(
|
async def test_update_entity(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
stub_reconnect,
|
||||||
mock_config_entry,
|
mock_config_entry,
|
||||||
mock_device_info,
|
mock_device_info,
|
||||||
mock_dashboard,
|
mock_dashboard,
|
||||||
@ -88,6 +106,48 @@ async def test_update_entity(
|
|||||||
if expected_state != "on":
|
if expected_state != "on":
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Compile failed, don't try to upload
|
||||||
|
with patch(
|
||||||
|
"esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=False
|
||||||
|
) as mock_compile, patch(
|
||||||
|
"esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=True
|
||||||
|
) as mock_upload, pytest.raises(
|
||||||
|
HomeAssistantError, match="compiling"
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
"update",
|
||||||
|
"install",
|
||||||
|
{"entity_id": "update.none_firmware"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mock_compile.mock_calls) == 1
|
||||||
|
assert mock_compile.mock_calls[0][1][0] == "test.yaml"
|
||||||
|
|
||||||
|
assert len(mock_upload.mock_calls) == 0
|
||||||
|
|
||||||
|
# Compile success, upload fails
|
||||||
|
with patch(
|
||||||
|
"esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=True
|
||||||
|
) as mock_compile, patch(
|
||||||
|
"esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=False
|
||||||
|
) as mock_upload, pytest.raises(
|
||||||
|
HomeAssistantError, match="OTA"
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
"update",
|
||||||
|
"install",
|
||||||
|
{"entity_id": "update.none_firmware"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mock_compile.mock_calls) == 1
|
||||||
|
assert mock_compile.mock_calls[0][1][0] == "test.yaml"
|
||||||
|
|
||||||
|
assert len(mock_upload.mock_calls) == 1
|
||||||
|
assert mock_upload.mock_calls[0][1][0] == "test.yaml"
|
||||||
|
|
||||||
|
# Everything works
|
||||||
with patch(
|
with patch(
|
||||||
"esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=True
|
"esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=True
|
||||||
) as mock_compile, patch(
|
) as mock_compile, patch(
|
||||||
@ -109,6 +169,7 @@ async def test_update_entity(
|
|||||||
|
|
||||||
async def test_update_static_info(
|
async def test_update_static_info(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
stub_reconnect,
|
||||||
mock_config_entry,
|
mock_config_entry,
|
||||||
mock_device_info,
|
mock_device_info,
|
||||||
mock_dashboard,
|
mock_dashboard,
|
||||||
@ -155,6 +216,7 @@ async def test_update_static_info(
|
|||||||
)
|
)
|
||||||
async def test_update_device_state_for_availability(
|
async def test_update_device_state_for_availability(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
stub_reconnect,
|
||||||
expected_disconnect_state: tuple[bool, str],
|
expected_disconnect_state: tuple[bool, str],
|
||||||
mock_config_entry,
|
mock_config_entry,
|
||||||
mock_device_info,
|
mock_device_info,
|
||||||
@ -210,7 +272,11 @@ async def test_update_device_state_for_availability(
|
|||||||
|
|
||||||
|
|
||||||
async def test_update_entity_dashboard_not_available_startup(
|
async def test_update_entity_dashboard_not_available_startup(
|
||||||
hass: HomeAssistant, mock_config_entry, mock_device_info, mock_dashboard
|
hass: HomeAssistant,
|
||||||
|
stub_reconnect,
|
||||||
|
mock_config_entry,
|
||||||
|
mock_device_info,
|
||||||
|
mock_dashboard,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test ESPHome update entity when dashboard is not available at startup."""
|
"""Test ESPHome update entity when dashboard is not available at startup."""
|
||||||
with patch(
|
with patch(
|
||||||
@ -225,6 +291,7 @@ async def test_update_entity_dashboard_not_available_startup(
|
|||||||
mock_config_entry, "update"
|
mock_config_entry, "update"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# We have a dashboard but it is not available
|
||||||
state = hass.states.get("update.none_firmware")
|
state = hass.states.get("update.none_firmware")
|
||||||
assert state is None
|
assert state is None
|
||||||
|
|
||||||
@ -239,7 +306,7 @@ async def test_update_entity_dashboard_not_available_startup(
|
|||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
state = hass.states.get("update.none_firmware")
|
state = hass.states.get("update.none_firmware")
|
||||||
assert state.state == "on"
|
assert state.state == STATE_ON
|
||||||
expected_attributes = {
|
expected_attributes = {
|
||||||
"latest_version": "2023.2.0-dev",
|
"latest_version": "2023.2.0-dev",
|
||||||
"installed_version": "1.0.0",
|
"installed_version": "1.0.0",
|
||||||
@ -247,3 +314,69 @@ async def test_update_entity_dashboard_not_available_startup(
|
|||||||
}
|
}
|
||||||
for key, expected_value in expected_attributes.items():
|
for key, expected_value in expected_attributes.items():
|
||||||
assert state.attributes.get(key) == expected_value
|
assert state.attributes.get(key) == expected_value
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_entity_dashboard_discovered_after_startup_but_update_failed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_client: APIClient,
|
||||||
|
mock_esphome_device: Callable[
|
||||||
|
[APIClient, list[EntityInfo], list[UserService], list[EntityState]],
|
||||||
|
Awaitable[MockESPHomeDevice],
|
||||||
|
],
|
||||||
|
mock_dashboard,
|
||||||
|
) -> None:
|
||||||
|
"""Test ESPHome update entity when dashboard is discovered after startup and the first update fails."""
|
||||||
|
with patch(
|
||||||
|
"esphome_dashboard_api.ESPHomeDashboardAPI.get_devices",
|
||||||
|
side_effect=asyncio.TimeoutError,
|
||||||
|
):
|
||||||
|
await async_get_dashboard(hass).async_refresh()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
mock_device = await mock_esphome_device(
|
||||||
|
mock_client=mock_client,
|
||||||
|
entity_info=[],
|
||||||
|
user_service=[],
|
||||||
|
states=[],
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
state = hass.states.get("update.test_firmware")
|
||||||
|
assert state is None
|
||||||
|
|
||||||
|
await mock_device.mock_disconnect(False)
|
||||||
|
|
||||||
|
mock_dashboard["configured"] = [
|
||||||
|
{
|
||||||
|
"name": "test",
|
||||||
|
"current_version": "2023.2.0-dev",
|
||||||
|
"configuration": "test.yaml",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
# Device goes unavailable, and dashboard becomes available
|
||||||
|
await async_get_dashboard(hass).async_refresh()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("update.test_firmware")
|
||||||
|
assert state is None
|
||||||
|
|
||||||
|
# Finally both are available
|
||||||
|
await mock_device.mock_connect()
|
||||||
|
await async_get_dashboard(hass).async_refresh()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
state = hass.states.get("update.test_firmware")
|
||||||
|
assert state is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_entity_not_present_without_dashboard(
|
||||||
|
hass: HomeAssistant, stub_reconnect, mock_config_entry, mock_device_info
|
||||||
|
) -> None:
|
||||||
|
"""Test ESPHome update entity does not get created if there is no dashboard."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.esphome.update.DomainData.get_entry_data",
|
||||||
|
return_value=Mock(available=True, device_info=mock_device_info),
|
||||||
|
):
|
||||||
|
assert await hass.config_entries.async_forward_entry_setup(
|
||||||
|
mock_config_entry, "update"
|
||||||
|
)
|
||||||
|
|
||||||
|
state = hass.states.get("update.none_firmware")
|
||||||
|
assert state is None
|
||||||
|
Loading…
x
Reference in New Issue
Block a user