Compare commits

...

3 Commits

Author SHA1 Message Date
Ville Skyttä 1141ab2887 Add Tasmota firmware update availability support 2026-05-30 00:57:19 +03:00
Franck Nijhof d7c13fee27 Fix Tado config flow crash on device activation polling (#172486) 2026-05-28 22:06:24 +02:00
Ronald van der Meer a0a44f7a25 Refactor Duco tests to use shared fixtures (#172351) 2026-05-28 22:04:25 +02:00
13 changed files with 394 additions and 127 deletions
+6 -4
View File
@@ -40,6 +40,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
login_task: asyncio.Task | None = None
refresh_token: str | None = None
tado: Tado | None = None
tado_device_url: str = ""
user_code: str = ""
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
@@ -69,8 +71,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Error while initiating Tado")
return self.async_abort(reason="cannot_connect")
assert self.tado is not None
tado_device_url = self.tado.device_verification_url()
user_code = URL(tado_device_url).query["user_code"]
self.tado_device_url = self.tado.device_verification_url()
self.user_code = URL(self.tado_device_url).query["user_code"]
async def _wait_for_login() -> None:
"""Wait for the user to login."""
@@ -119,8 +121,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user",
progress_action="wait_for_device",
description_placeholders={
"url": tado_device_url,
"code": user_code,
"url": self.tado_device_url,
"code": self.user_code,
},
progress_task=self.login_task,
)
@@ -19,6 +19,7 @@ PLATFORMS = [
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
TASMOTA_EVENT = "tasmota_event"
@@ -0,0 +1,47 @@
"""Data update coordinators for Tasmota."""
from datetime import timedelta
import logging
from aiogithubapi import (
GitHubAPI,
GitHubConnectionException,
GitHubException,
GitHubRatelimitException,
GitHubReleaseModel,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
class TasmotaLatestReleaseUpdateCoordinator(DataUpdateCoordinator[GitHubReleaseModel]):
"""Data update coordinator for Tasmota latest release info."""
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize the coordinator."""
self.client = GitHubAPI(session=async_get_clientsession(hass))
super().__init__(
hass,
logger=logging.getLogger(__name__),
config_entry=config_entry,
name="Tasmota latest release",
update_interval=timedelta(days=1),
)
async def _async_update_data(self) -> GitHubReleaseModel:
"""Get new data."""
try:
response = await self.client.repos.releases.latest("arendst/Tasmota")
if response.data is None:
raise UpdateFailed("No data received")
except (GitHubConnectionException, GitHubRatelimitException) as ex:
# Expected/transient, just wrap as failure
raise UpdateFailed(ex) from ex
except GitHubException as ex:
self.logger.exception("Unexpected GitHub exception")
raise UpdateFailed(ex) from ex
else:
return response.data
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["hatasmota"],
"mqtt": ["tasmota/discovery/#"],
"requirements": ["HATasmota==0.10.1"]
"requirements": ["HATasmota==0.10.1", "aiogithubapi==26.0.0"]
}
@@ -0,0 +1,80 @@
"""Update entity for Tasmota."""
import re
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TasmotaLatestReleaseUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tasmota update entities."""
coordinator = TasmotaLatestReleaseUpdateCoordinator(hass, config_entry)
await coordinator.async_config_entry_first_refresh()
device_registry = dr.async_get(hass)
devices = device_registry.devices.get_devices_for_config_entry_id(
config_entry.entry_id
)
async_add_entities(TasmotaUpdateEntity(coordinator, device) for device in devices)
class TasmotaUpdateEntity(UpdateEntity):
"""Representation of a Tasmota update entity."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_name = "Firmware"
_attr_title = "Tasmota firmware"
_attr_supported_features = UpdateEntityFeature.RELEASE_NOTES
def __init__(
self,
coordinator: TasmotaLatestReleaseUpdateCoordinator,
device_entry: DeviceEntry,
) -> None:
"""Initialize the Tasmota update entity."""
self.coordinator = coordinator
self.device_entry = device_entry
self._attr_unique_id = f"{device_entry.id}_update"
@property
def installed_version(self) -> str | None:
"""Return the installed version."""
return self.device_entry.sw_version # type:ignore[union-attr]
@property
def latest_version(self) -> str:
"""Return the latest version."""
return self.coordinator.data.tag_name.removeprefix("v")
@property
def release_url(self) -> str:
"""Return the release URL."""
return self.coordinator.data.html_url
@property
def release_summary(self) -> str:
"""Return the release summary."""
return self.coordinator.data.name
def release_notes(self) -> str | None:
"""Return the release notes."""
if not self.coordinator.data.body:
return None
# Remove the picture tag, it uses relative URLs that won't work in the UI
return re.sub(
r"^<picture>.*?</picture>", "", self.coordinator.data.body, flags=re.DOTALL
)
+1
View File
@@ -273,6 +273,7 @@ aioftp==0.21.3
aioghost==0.4.16
# homeassistant.components.github
# homeassistant.components.tasmota
aiogithubapi==26.0.0
# homeassistant.components.guardian
+32
View File
@@ -1 +1,33 @@
"""Tests for the Duco integration."""
from collections.abc import Sequence
from unittest.mock import patch
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> MockConfigEntry:
"""Set up the full Duco integration for testing."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return config_entry
async def setup_platform_integration(
hass: HomeAssistant,
config_entry: MockConfigEntry,
platforms: Sequence[Platform],
) -> MockConfigEntry:
"""Set up selected Duco platforms for testing."""
config_entry.add_to_hass(hass)
with patch("homeassistant.components.duco.PLATFORMS", list(platforms)):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return config_entry
+4 -110
View File
@@ -23,6 +23,8 @@ from homeassistant.components.duco.const import DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry, load_json_array_fixture
TEST_HOST = "192.168.1.100"
@@ -159,112 +161,7 @@ def mock_lan_info() -> LanInfo:
@pytest.fixture
def mock_nodes() -> list[Node]:
"""Return a list of nodes covering all supported types."""
return [
Node(
node_id=1,
general=NodeGeneralInfo(
node_type="BOX",
sub_type=1,
network_type="VIRT",
parent=0,
asso=0,
name="Living",
identify=0,
),
ventilation=NodeVentilationInfo(
state="AUTO",
time_state_remain=0,
time_state_end=0,
mode="AUTO",
flow_lvl_tgt=0,
),
sensor=NodeSensorInfo(
co2=None,
iaq_co2=None,
rh=None,
iaq_rh=None,
temp=27.9,
),
),
Node(
node_id=2,
general=NodeGeneralInfo(
node_type="UCCO2",
sub_type=0,
network_type="RF",
parent=1,
asso=1,
name="Office CO2",
identify=0,
),
ventilation=NodeVentilationInfo(
state="AUTO",
time_state_remain=0,
time_state_end=0,
mode="-",
flow_lvl_tgt=None,
),
sensor=NodeSensorInfo(
co2=405,
iaq_co2=80,
rh=None,
iaq_rh=None,
temp=19.8,
),
),
Node(
node_id=113,
general=NodeGeneralInfo(
node_type="BSRH",
sub_type=0,
network_type="RF",
parent=1,
asso=1,
name="Bathroom RH",
identify=0,
),
ventilation=NodeVentilationInfo(
state="AUTO",
time_state_remain=0,
time_state_end=0,
mode="-",
flow_lvl_tgt=None,
),
sensor=NodeSensorInfo(
co2=None,
iaq_co2=None,
rh=42.0,
iaq_rh=85,
temp=27.9,
),
),
Node(
node_id=50,
general=NodeGeneralInfo(
node_type="UCRH",
sub_type=0,
network_type="RF",
parent=1,
asso=1,
name="Kitchen RH",
identify=0,
),
ventilation=NodeVentilationInfo(
state="AUTO",
time_state_remain=0,
time_state_end=0,
mode="-",
flow_lvl_tgt=None,
),
sensor=NodeSensorInfo(
co2=None,
iaq_co2=None,
rh=61.0,
iaq_rh=90,
temp=22.5,
),
),
]
return load_nodes_fixture("nodes.json")
@pytest.fixture
@@ -327,7 +224,4 @@ async def init_integration(
mock_duco_client: AsyncMock,
) -> MockConfigEntry:
"""Set up the Duco integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
return await setup_integration(hass, mock_config_entry)
+106
View File
@@ -0,0 +1,106 @@
[
{
"node_id": 1,
"general": {
"node_type": "BOX",
"sub_type": 1,
"network_type": "VIRT",
"parent": 0,
"asso": 0,
"name": "Living",
"identify": 0
},
"ventilation": {
"state": "AUTO",
"time_state_remain": 0,
"time_state_end": 0,
"mode": "AUTO",
"flow_lvl_tgt": 0
},
"sensor": {
"co2": null,
"iaq_co2": null,
"rh": null,
"iaq_rh": null,
"temp": 27.9
}
},
{
"node_id": 2,
"general": {
"node_type": "UCCO2",
"sub_type": 0,
"network_type": "RF",
"parent": 1,
"asso": 1,
"name": "Office CO2",
"identify": 0
},
"ventilation": {
"state": "AUTO",
"time_state_remain": 0,
"time_state_end": 0,
"mode": "-",
"flow_lvl_tgt": null
},
"sensor": {
"co2": 405,
"iaq_co2": 80,
"rh": null,
"iaq_rh": null,
"temp": 19.8
}
},
{
"node_id": 113,
"general": {
"node_type": "BSRH",
"sub_type": 0,
"network_type": "RF",
"parent": 1,
"asso": 1,
"name": "Bathroom RH",
"identify": 0
},
"ventilation": {
"state": "AUTO",
"time_state_remain": 0,
"time_state_end": 0,
"mode": "-",
"flow_lvl_tgt": null
},
"sensor": {
"co2": null,
"iaq_co2": null,
"rh": 42.0,
"iaq_rh": 85,
"temp": 27.9
}
},
{
"node_id": 50,
"general": {
"node_type": "UCRH",
"sub_type": 0,
"network_type": "RF",
"parent": 1,
"asso": 1,
"name": "Kitchen RH",
"identify": 0
},
"ventilation": {
"state": "AUTO",
"time_state_remain": 0,
"time_state_end": 0,
"mode": "-",
"flow_lvl_tgt": null
},
"sensor": {
"co2": null,
"iaq_co2": null,
"rh": 61.0,
"iaq_rh": 90,
"temp": 22.5
}
}
]
+4 -6
View File
@@ -1,7 +1,7 @@
"""Tests for the Duco fan platform."""
import logging
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock
from duco_connectivity import DucoConnectionError, DucoError, DucoRateLimitError
from freezegun.api import FrozenDateTimeFactory
@@ -21,6 +21,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_platform_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
_FAN_ENTITY = "fan.living"
@@ -33,11 +35,7 @@ async def init_integration(
mock_duco_client: AsyncMock,
) -> MockConfigEntry:
"""Set up only the fan platform for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.duco.PLATFORMS", [Platform.FAN]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
return await setup_platform_integration(hass, mock_config_entry, [Platform.FAN])
@pytest.mark.usefixtures("init_integration")
+4 -6
View File
@@ -1,7 +1,7 @@
"""Tests for the Duco sensor platform."""
import logging
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock
from duco_connectivity import (
DucoConnectionError,
@@ -22,6 +22,8 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import setup_platform_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -34,11 +36,7 @@ async def init_integration(
) -> MockConfigEntry:
"""Set up only the sensor platform for testing."""
mock_duco_client.async_get_nodes.return_value = mock_sensor_nodes
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.duco.PLATFORMS", [Platform.SENSOR]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
return await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR])
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
+37
View File
@@ -231,6 +231,43 @@ async def test_options_flow(
assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT}
async def test_show_progress_polling(
hass: HomeAssistant,
mock_tado_api: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test progress step re-entry while login task is still running."""
event = threading.Event()
def mock_tado_api_device_activation() -> None:
event.wait(timeout=5)
mock_tado_api.device_activation = mock_tado_api_device_activation
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "user"
assert result["description_placeholders"]["url"] is not None
assert result["description_placeholders"]["code"] == "TEST"
# Poll again while task is still running — this re-enters async_step_user
# with self.tado already set
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["description_placeholders"]["url"] is not None
assert result["description_placeholders"]["code"] == "TEST"
# Now complete the login
event.set()
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_homekit(hass: HomeAssistant, mock_tado_api: MagicMock) -> None:
"""Test that we abort from homekit if tado is already setup."""
+71
View File
@@ -0,0 +1,71 @@
"""Tests for the Tasmota update platform."""
import copy
import json
from aiogithubapi import GitHubReleaseModel
import pytest
from homeassistant.components.tasmota.const import DEFAULT_PREFIX
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .test_common import DEFAULT_CONFIG
from tests.common import async_fire_mqtt_message
from tests.typing import MqttMockHAClient
@pytest.mark.parametrize(
("candidate_version", "update_available"),
[
("0.0.0", False),
(".".join(str(int(x) + 1) for x in DEFAULT_CONFIG["sw"].split(".")), True),
],
)
async def test_update_state(
hass: HomeAssistant,
mqtt_mock: MqttMockHAClient,
device_registry: dr.DeviceRegistry,
setup_tasmota,
candidate_version: str,
update_available: bool,
) -> None:
"""Test setting up a device."""
config = copy.deepcopy(DEFAULT_CONFIG)
mac = config["mac"]
async_fire_mqtt_message(
hass,
f"{DEFAULT_PREFIX}/{mac}/config",
json.dumps(config),
)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
)
async def mock_latest(owner_repo):
return GitHubReleaseModel(
{
"tag_name": f"v{candidate_version}",
"name": f"Tasmota v{candidate_version} Foo",
"html_url": f"https://github.com/arendst/Tasmota/releases/tag/v{candidate_version}",
"body": """\
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./tools/logo/TASMOTA_FullLogo_Vector_White.svg">
<img alt="Logo" src="./tools/logo/TASMOTA_FullLogo_Vector.svg" align="right" height="76">
</picture>
# RELEASE NOTES
... """,
}
)
# TODO mock the coordinator
coordinator = hass.data["tasmota"]["coordinator"]
coordinator.client.repos.releases.latest = mock_latest
# TODO update_available test, device_entry.sw_version has the current version