Compare commits

..

7 Commits

Author SHA1 Message Date
J. Nick Koston 1aa6e1d32c Bump pyroute2 to 0.9.6 2026-05-28 22:12:52 -05:00
Franck Nijhof c587e101af Reduce Wyoming satellite disconnect log to debug level (#172499) 2026-05-28 19:18:14 -05:00
Franck Nijhof 6eeeac46f3 Convert Roomba hw_version to string for device registry (#172497) 2026-05-28 23:13:08 +02:00
Franck Nijhof 86542b8ad0 Increase ConfigEntryNotReady retry backoff cap from 80s to 10 minutes (#172487) 2026-05-28 22:41:54 +02:00
Franck Nijhof 7e07e7062c Add prog operating mode to Overkiz Atlantic heater HVAC mapping (#172491) 2026-05-28 22:21:53 +02: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
16 changed files with 247 additions and 178 deletions
@@ -57,6 +57,7 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = {
OverkizCommandParam.STANDBY: HVACMode.OFF, # main command
OverkizCommandParam.AUTO: HVACMode.AUTO,
OverkizCommandParam.EXTERNAL: HVACMode.AUTO,
OverkizCommandParam.PROG: HVACMode.AUTO,
OverkizCommandParam.INTERNAL: HVACMode.AUTO, # main command
}
+5 -1
View File
@@ -29,7 +29,11 @@ class IRobotEntity(Entity):
model=self.vacuum_state.get("sku"),
name=str(self.vacuum_state.get("name")),
sw_version=self.vacuum_state.get("softwareVer"),
hw_version=self.vacuum_state.get("hardwareRev"),
hw_version=(
str(hw_rev)
if (hw_rev := self.vacuum_state.get("hardwareRev")) is not None
else None
),
)
if mac_address := self.vacuum_state.get("hwPartsRev", {}).get(
+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,
)
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/thread",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["python-otbr-api==2.10.0", "pyroute2==0.7.5"],
"requirements": ["python-otbr-api==2.10.0", "pyroute2==0.9.6"],
"single_config_entry": true,
"zeroconf": ["_meshcop._udp.local."]
}
@@ -468,7 +468,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
async def on_restart(self) -> None:
"""Block until pipeline loop will be restarted."""
_LOGGER.warning(
_LOGGER.debug(
"Satellite has been disconnected. Reconnecting in %s second(s)",
_RECONNECT_SECONDS,
)
+4 -15
View File
@@ -138,6 +138,8 @@ SAVE_DELAY = 1
DISCOVERY_COOLDOWN = 1
SETUP_RETRY_MAX_WAIT = 600 # 10 minutes
ISSUE_UNIQUE_ID_COLLISION = "config_entry_unique_id_collision"
UNIQUE_ID_COLLISION_TITLE_LIMIT = 5
@@ -824,7 +826,7 @@ class ConfigEntry[_DataT = Any]:
auth_message,
)
logger.debug("Full exception", exc_info=True)
self.async_start_reauth_if_available(hass)
self.async_start_reauth(hass)
except ConfigEntryNotReady as exc:
message = str(exc)
error_reason_translation_key = exc.translation_key
@@ -836,7 +838,7 @@ class ConfigEntry[_DataT = Any]:
error_reason_translation_key,
error_reason_translation_placeholders,
)
wait_time = 2 ** min(self._tries, 4) * 5 + (
wait_time = min(2**self._tries * 5, SETUP_RETRY_MAX_WAIT) + (
randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000
)
self._tries += 1
@@ -1290,19 +1292,6 @@ class ConfigEntry[_DataT = Any]:
eager_start=True,
)
@callback
def async_start_reauth_if_available(
self,
hass: HomeAssistant,
context: ConfigFlowContext | None = None,
data: dict[str, Any] | None = None,
) -> None:
"""Start a reauth flow only if the integration implements one."""
handler = HANDLERS.get(self.domain)
if handler is None or not hasattr(handler, "async_step_reauth"):
return
self.async_start_reauth(hass, context, data)
async def _async_init_reauth(
self,
hass: HomeAssistant,
+2 -2
View File
@@ -458,7 +458,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
raise ConfigEntryAuthFailed from err
if self.config_entry:
self.config_entry.async_start_reauth_if_available(self.hass)
self.config_entry.async_start_reauth(self.hass)
return
# Recoverable error
@@ -536,7 +536,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
raise
if self.config_entry:
self.config_entry.async_start_reauth_if_available(self.hass)
self.config_entry.async_start_reauth(self.hass)
except NotImplementedError as err:
self.last_exception = err
self.last_update_success = False
+1 -1
View File
@@ -2492,7 +2492,7 @@ pyrisco==0.7.0
pyrituals==0.0.7
# homeassistant.components.thread
pyroute2==0.7.5
pyroute2==0.9.6
# homeassistant.components.rympro
pyrympro==0.0.9
+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")
+1 -1
View File
@@ -235,7 +235,7 @@ async def test_setup_oauth_reauth_error(
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
mock_async_start_reauth.assert_called_once_with(hass, None, None)
mock_async_start_reauth.assert_called_once_with(hass)
async def test_setup_oauth_transient_error(
+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."""
+38 -30
View File
@@ -1702,6 +1702,44 @@ async def test_setup_raise_not_ready(
assert entry.reason is None
async def test_setup_not_ready_exponential_backoff(
hass: HomeAssistant,
manager: config_entries.ConfigEntries,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test setup retry uses exponential backoff capped at 10 minutes."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
attempts = 0
async def _mock_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
nonlocal attempts
attempts += 1
raise ConfigEntryNotReady
mock_integration(hass, MockModule("test", async_setup_entry=_mock_setup_entry))
mock_platform(hass, "test.config_flow", None)
await manager.async_setup(entry.entry_id)
assert attempts == 1
expected_waits = [5, 10, 20, 40, 80, 160, 320, 600, 600]
for i, wait in enumerate(expected_waits):
# Advance to just before the retry should fire
freezer.tick(wait - 1)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert attempts == i + 1, f"Retry {i + 1} fired too early"
# Advance past the retry point (+ 1s for jitter)
freezer.tick(2)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert attempts == i + 2, f"Retry {i + 1} did not fire"
assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY
async def test_setup_raise_not_ready_from_exception(
hass: HomeAssistant,
manager: config_entries.ConfigEntries,
@@ -5755,36 +5793,6 @@ async def test_setup_raise_auth_failed_from_future_coordinator_update(
assert len(flows) == 1
async def test_setup_raise_auth_failed_without_reauth_flow(
hass: HomeAssistant,
manager: config_entries.ConfigEntries,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test ConfigEntryAuthFailed when the integration has no reauth flow."""
entry = MockConfigEntry(title="test_title", domain="test")
entry.add_to_hass(hass)
mock_setup_entry = AsyncMock(
side_effect=ConfigEntryAuthFailed("The password is no longer valid")
)
mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry))
mock_platform(hass, "test.config_flow", None)
class NoReauthFlow(config_entries.ConfigFlow):
"""Config flow without reauth support."""
VERSION = 1
with mock_config_flow("test", NoReauthFlow):
await manager.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert "could not authenticate: The password is no longer valid" in caplog.text
assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR
assert entry.reason == "The password is no longer valid"
assert len(hass.config_entries.flow.async_progress()) == 0
async def test_initialize_and_shutdown(hass: HomeAssistant) -> None:
"""Test we call the shutdown function at stop."""
manager = config_entries.ConfigEntries(hass, {})