From aaf58075c6f23f314044bd3dd1bfc1b6cdb9e743 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 14 Oct 2025 12:08:41 +0200 Subject: [PATCH 01/51] Rename security panel to safety panel (#154435) --- homeassistant/components/frontend/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index ebd354c5e8390..72bef68b7b18b 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -453,7 +453,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.http.app.router.register_resource(IndexView(repo_path, hass)) async_register_built_in_panel(hass, "light") - async_register_built_in_panel(hass, "security") + async_register_built_in_panel(hass, "safety") async_register_built_in_panel(hass, "climate") async_register_built_in_panel(hass, "profile") From 487940872efb3d272408c45f9aa7ba2f9d7ac4ad Mon Sep 17 00:00:00 2001 From: Magnus Date: Tue, 14 Oct 2025 12:37:37 +0200 Subject: [PATCH 02/51] Dependency update py-melissa-climate to 3.0.2 (#154285) --- homeassistant/components/melissa/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melissa/manifest.json b/homeassistant/components/melissa/manifest.json index a583c3b88fad9..f00e0397d92dd 100644 --- a/homeassistant/components/melissa/manifest.json +++ b/homeassistant/components/melissa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["melissa"], "quality_scale": "legacy", - "requirements": ["py-melissa-climate==2.1.4"] + "requirements": ["py-melissa-climate==3.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index efaed1186fd92..c4a4144fa14ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1794,7 +1794,7 @@ py-improv-ble-client==1.0.3 py-madvr2==1.6.40 # homeassistant.components.melissa -py-melissa-climate==2.1.4 +py-melissa-climate==3.0.2 # homeassistant.components.nextbus py-nextbusnext==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6168fd3505e0b..62954cba8be9a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1523,7 +1523,7 @@ py-improv-ble-client==1.0.3 py-madvr2==1.6.40 # homeassistant.components.melissa -py-melissa-climate==2.1.4 +py-melissa-climate==3.0.2 # homeassistant.components.nextbus py-nextbusnext==2.3.0 From d108d5f1063616291b68b908062ab25b31e5e0db Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 14 Oct 2025 14:07:37 +0300 Subject: [PATCH 03/51] Use Shelly RPC cover methods from upstream and fix cover status update (#154345) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/shelly/cover.py | 33 +++-- tests/components/shelly/conftest.py | 10 ++ tests/components/shelly/test_cover.py | 149 ++++++----------------- 3 files changed, 71 insertions(+), 121 deletions(-) diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index b56a2b103ac93..40f75230c59e2 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -29,6 +29,7 @@ ShellyRpcAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rpc, + rpc_call, ) from .utils import get_device_entry_gen @@ -192,6 +193,7 @@ class RpcShellyCover(ShellyRpcAttributeEntity, CoverEntity): _attr_supported_features: CoverEntityFeature = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP ) + _id: int def __init__( self, @@ -260,7 +262,7 @@ async def update_position(self) -> None: """Update the cover position every second.""" try: while self.is_closing or self.is_opening: - await self.coordinator.device.update_status() + await self.coordinator.device.update_cover_status(self._id) self.async_write_ha_state() await asyncio.sleep(RPC_COVER_UPDATE_TIME_SEC) finally: @@ -274,39 +276,46 @@ def _update_callback(self) -> None: if self.is_closing or self.is_opening: self.launch_update_task() + @rpc_call async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - await self.call_rpc("Cover.Close", {"id": self._id}) + await self.coordinator.device.cover_close(self._id) + @rpc_call async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" - await self.call_rpc("Cover.Open", {"id": self._id}) + await self.coordinator.device.cover_open(self._id) + @rpc_call async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - await self.call_rpc( - "Cover.GoToPosition", {"id": self._id, "pos": kwargs[ATTR_POSITION]} + await self.coordinator.device.cover_set_position( + self._id, pos=kwargs[ATTR_POSITION] ) + @rpc_call async def async_stop_cover(self, **_kwargs: Any) -> None: """Stop the cover.""" - await self.call_rpc("Cover.Stop", {"id": self._id}) + await self.coordinator.device.cover_stop(self._id) + @rpc_call async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" - await self.call_rpc("Cover.GoToPosition", {"id": self._id, "slat_pos": 100}) + await self.coordinator.device.cover_set_position(self._id, slat_pos=100) + @rpc_call async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - await self.call_rpc("Cover.GoToPosition", {"id": self._id, "slat_pos": 0}) + await self.coordinator.device.cover_set_position(self._id, slat_pos=0) + @rpc_call async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - await self.call_rpc( - "Cover.GoToPosition", - {"id": self._id, "slat_pos": kwargs[ATTR_TILT_POSITION]}, + await self.coordinator.device.cover_set_position( + self._id, slat_pos=kwargs[ATTR_TILT_POSITION] ) + @rpc_call async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover.""" - await self.call_rpc("Cover.Stop", {"id": self._id}) + await self.coordinator.device.cover_stop(self._id) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index f709d4291adf9..57fa8bec95031 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -617,6 +617,13 @@ def initialized(): {}, RpcUpdateType.INITIALIZED ) + current_pos = iter(range(50, -1, -10)) # from 50 to 0 in steps of 10 + + async def update_cover_status(cover_id: int): + device.status[f"cover:{cover_id}"]["current_pos"] = next( + current_pos, device.status[f"cover:{cover_id}"]["current_pos"] + ) + device = _mock_rpc_device() rpc_device_mock.return_value = device rpc_device_mock.return_value.mock_disconnected = Mock(side_effect=disconnected) @@ -624,6 +631,9 @@ def initialized(): rpc_device_mock.return_value.mock_event = Mock(side_effect=event) rpc_device_mock.return_value.mock_online = Mock(side_effect=online) rpc_device_mock.return_value.mock_initialized = Mock(side_effect=initialized) + rpc_device_mock.return_value.update_cover_status = AsyncMock( + side_effect=update_cover_status + ) yield rpc_device_mock.return_value diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index 63dac548ed154..188660a6346ef 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -139,6 +139,8 @@ async def test_rpc_device_services( {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, blocking=True, ) + + mock_rpc_device.cover_set_position.assert_called_once_with(0, pos=50) assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_POSITION] == 50 @@ -153,6 +155,7 @@ async def test_rpc_device_services( ) mock_rpc_device.mock_update() + mock_rpc_device.cover_open.assert_called_once_with(0) assert (state := hass.states.get(entity_id)) assert state.state == CoverState.OPENING @@ -167,6 +170,7 @@ async def test_rpc_device_services( ) mock_rpc_device.mock_update() + mock_rpc_device.cover_close.assert_called_once_with(0) assert (state := hass.states.get(entity_id)) assert state.state == CoverState.CLOSING @@ -178,6 +182,8 @@ async def test_rpc_device_services( blocking=True, ) mock_rpc_device.mock_update() + + mock_rpc_device.cover_stop.assert_called_once_with(0) assert (state := hass.states.get(entity_id)) assert state.state == CoverState.CLOSED @@ -262,9 +268,11 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 50) mock_rpc_device.mock_update() + mock_rpc_device.cover_set_position.assert_called_once_with(0, slat_pos=50) assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + mock_rpc_device.cover_set_position.reset_mock() await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER_TILT, @@ -274,9 +282,11 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 100) mock_rpc_device.mock_update() + mock_rpc_device.cover_set_position.assert_called_once_with(0, slat_pos=100) assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + mock_rpc_device.cover_set_position.reset_mock() await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER_TILT, @@ -292,157 +302,78 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 10) mock_rpc_device.mock_update() + mock_rpc_device.cover_stop.assert_called_once_with(0) assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 -async def test_update_position_closing( +async def test_rpc_cover_position_update( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test update_position while the cover is closing.""" + """Test RPC update_position while the cover is moving.""" entity_id = "cover.test_name_test_cover_0" await init_integration(hass, 2) - # Set initial state to closing + # Set initial state to closing, position 50 set by update_cover_status mock mutate_rpc_device_status( monkeypatch, mock_rpc_device, "cover:0", "state", "closing" ) - mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "current_pos", 40) mock_rpc_device.mock_update() assert (state := hass.states.get(entity_id)) assert state.state == CoverState.CLOSING - assert state.attributes[ATTR_CURRENT_POSITION] == 40 - - # Simulate position decrement - async def simulated_update(*args, **kwargs): - pos = mock_rpc_device.status["cover:0"]["current_pos"] - if pos > 0: - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "current_pos", pos - 10 - ) - else: - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "current_pos", 0 - ) - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "state", "closed" - ) - - # Patching the mock update_status method - monkeypatch.setattr(mock_rpc_device, "update_status", simulated_update) + assert state.attributes[ATTR_CURRENT_POSITION] == 50 # Simulate position updates during closing for position in range(40, -1, -10): - assert (state := hass.states.get(entity_id)) - assert state.attributes[ATTR_CURRENT_POSITION] == position - assert state.state == CoverState.CLOSING + mock_rpc_device.update_cover_status.reset_mock() await mock_polling_rpc_update(hass, freezer, RPC_COVER_UPDATE_TIME_SEC) - # Final state should be closed - assert (state := hass.states.get(entity_id)) - assert state.state == CoverState.CLOSED - assert state.attributes[ATTR_CURRENT_POSITION] == 0 - - -async def test_update_position_opening( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_rpc_device: Mock, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test update_position while the cover is opening.""" - entity_id = "cover.test_name_test_cover_0" - await init_integration(hass, 2) - - # Set initial state to opening at 60 - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "state", "opening" - ) - mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "current_pos", 60) - mock_rpc_device.mock_update() - - assert (state := hass.states.get(entity_id)) - assert state.state == CoverState.OPENING - assert state.attributes[ATTR_CURRENT_POSITION] == 60 - - # Simulate position increment - async def simulated_update(*args, **kwargs): - pos = mock_rpc_device.status["cover:0"]["current_pos"] - if pos < 100: - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "current_pos", pos + 10 - ) - else: - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "current_pos", 100 - ) - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "state", "open" - ) - - # Patching the mock update_status method - monkeypatch.setattr(mock_rpc_device, "update_status", simulated_update) - - # Check position updates during opening - for position in range(60, 101, 10): + mock_rpc_device.update_cover_status.assert_called_once_with(0) assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_POSITION] == position - assert state.state == CoverState.OPENING - await mock_polling_rpc_update(hass, freezer, RPC_COVER_UPDATE_TIME_SEC) - - # Final state should be open - assert (state := hass.states.get(entity_id)) - assert state.state == CoverState.OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 - - -async def test_update_position_no_movement( - hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch -) -> None: - """Test update_position when the cover is not moving.""" - entity_id = "cover.test_name_test_cover_0" - await init_integration(hass, 2) + assert state.state == CoverState.CLOSING - # Set initial state to open - mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "open") - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "current_pos", 100 - ) + # Simulate cover reaching final position + mock_rpc_device.update_cover_status.reset_mock() + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "closed") mock_rpc_device.mock_update() assert (state := hass.states.get(entity_id)) - assert state.state == CoverState.OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 - - # Call update_position and ensure no changes occur - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + assert state.state == CoverState.CLOSED - assert (state := hass.states.get(entity_id)) - assert state.state == CoverState.OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 + # Ensure update_position does not call update_cover_status when the cover is not moving + await mock_polling_rpc_update(hass, freezer, RPC_COVER_UPDATE_TIME_SEC) + mock_rpc_device.update_cover_status.assert_not_called() async def test_rpc_not_initialized_update( - hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + freezer: FrozenDateTimeFactory, ) -> None: """Test update not called when device is not initialized.""" entity_id = "cover.test_name_test_cover_0" await init_integration(hass, 2) - assert (state := hass.states.get(entity_id)) - assert state.state == CoverState.OPEN + # Set initial state to closing + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "state", "closing" + ) + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "current_pos", 40) + # mock device not initialized (e.g. disconnected) monkeypatch.setattr(mock_rpc_device, "initialized", False) mock_rpc_device.mock_update() + # wait for update interval to allow update_position to call update_cover_status + await mock_polling_rpc_update(hass, freezer, RPC_COVER_UPDATE_TIME_SEC) + + mock_rpc_device.update_cover_status.assert_not_called() assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE From 81fd9e1c5a3e40b76031c0f42442d0b7a84ef131 Mon Sep 17 00:00:00 2001 From: nasWebio <140073814+nasWebio@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:21:19 +0200 Subject: [PATCH 04/51] Move state conversion from library to nasweb integration code (#153208) --- homeassistant/components/nasweb/manifest.json | 2 +- homeassistant/components/nasweb/sensor.py | 22 ++++++++++--------- homeassistant/components/nasweb/switch.py | 9 +++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/nasweb/manifest.json b/homeassistant/components/nasweb/manifest.json index 8a4ecdbee84a7..6c447376bfeb2 100644 --- a/homeassistant/components/nasweb/manifest.json +++ b/homeassistant/components/nasweb/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nasweb", "integration_type": "hub", "iot_class": "local_push", - "requirements": ["webio-api==0.1.11"] + "requirements": ["webio-api==0.1.12"] } diff --git a/homeassistant/components/nasweb/sensor.py b/homeassistant/components/nasweb/sensor.py index eb342d7ce9257..e01e401b2ba9d 100644 --- a/homeassistant/components/nasweb/sensor.py +++ b/homeassistant/components/nasweb/sensor.py @@ -6,6 +6,13 @@ import time from webio_api import Input as NASwebInput, TempSensor +from webio_api.const import ( + STATE_INPUT_ACTIVE, + STATE_INPUT_NORMAL, + STATE_INPUT_PROBLEM, + STATE_INPUT_TAMPER, + STATE_INPUT_UNDEFINED, +) from homeassistant.components.sensor import ( DOMAIN as DOMAIN_SENSOR, @@ -28,11 +35,6 @@ from .const import DOMAIN, KEY_TEMP_SENSOR, STATUS_UPDATE_MAX_TIME_INTERVAL SENSOR_INPUT_TRANSLATION_KEY = "sensor_input" -STATE_UNDEFINED = "undefined" -STATE_TAMPER = "tamper" -STATE_ACTIVE = "active" -STATE_NORMAL = "normal" -STATE_PROBLEM = "problem" _LOGGER = logging.getLogger(__name__) @@ -122,11 +124,11 @@ class InputStateSensor(BaseSensorEntity): _attr_device_class = SensorDeviceClass.ENUM _attr_options: list[str] = [ - STATE_UNDEFINED, - STATE_TAMPER, - STATE_ACTIVE, - STATE_NORMAL, - STATE_PROBLEM, + STATE_INPUT_ACTIVE, + STATE_INPUT_NORMAL, + STATE_INPUT_PROBLEM, + STATE_INPUT_TAMPER, + STATE_INPUT_UNDEFINED, ] _attr_translation_key = SENSOR_INPUT_TRANSLATION_KEY diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py index 740db1ed1a1cf..06d3f57121e66 100644 --- a/homeassistant/components/nasweb/switch.py +++ b/homeassistant/components/nasweb/switch.py @@ -7,6 +7,7 @@ from typing import Any from webio_api import Output as NASwebOutput +from webio_api.const import STATE_ENTITY_UNAVAILABLE, STATE_OUTPUT_OFF, STATE_OUTPUT_ON from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity from homeassistant.core import HomeAssistant, callback @@ -25,6 +26,12 @@ OUTPUT_TRANSLATION_KEY = "switch_output" +NASWEB_STATE_TO_HA_STATE = { + STATE_ENTITY_UNAVAILABLE: None, + STATE_OUTPUT_ON: True, + STATE_OUTPUT_OFF: False, +} + _LOGGER = logging.getLogger(__name__) @@ -105,7 +112,7 @@ async def async_added_to_hass(self) -> None: @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self._attr_is_on = self._output.state + self._attr_is_on = NASWEB_STATE_TO_HA_STATE[self._output.state] if ( self.coordinator.last_update is None or time.time() - self._output.last_update >= STATUS_UPDATE_MAX_TIME_INTERVAL diff --git a/requirements_all.txt b/requirements_all.txt index c4a4144fa14ce..8512f01af2f75 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3147,7 +3147,7 @@ weatherflow4py==1.4.1 webexpythonsdk==2.0.1 # homeassistant.components.nasweb -webio-api==0.1.11 +webio-api==0.1.12 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 62954cba8be9a..c19fe24fa7f0b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2606,7 +2606,7 @@ watergate-local-api==2024.4.1 weatherflow4py==1.4.1 # homeassistant.components.nasweb -webio-api==0.1.11 +webio-api==0.1.12 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 From fcea5e0da60b4356faf52cc6182793062bb66d1e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:23:50 +0200 Subject: [PATCH 05/51] Simplify DPType lookup in Tuya (#150117) --- homeassistant/components/tuya/entity.py | 32 --------------------- homeassistant/components/tuya/light.py | 8 +++--- homeassistant/components/tuya/sensor.py | 3 +- homeassistant/components/tuya/util.py | 38 ++++++++++++++++++++++++- 4 files changed, 43 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 1ed9aae1f2219..1de4f8841df0c 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -13,16 +13,6 @@ from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY, DPCode, DPType from .models import EnumTypeData, IntegerTypeData -_DPTYPE_MAPPING: dict[str, DPType] = { - "bitmap": DPType.BITMAP, - "bool": DPType.BOOLEAN, - "enum": DPType.ENUM, - "json": DPType.JSON, - "raw": DPType.RAW, - "string": DPType.STRING, - "value": DPType.INTEGER, -} - class TuyaEntity(Entity): """Tuya base device.""" @@ -125,28 +115,6 @@ def find_dpcode( return None - def get_dptype( - self, dpcode: DPCode | None, *, prefer_function: bool = False - ) -> DPType | None: - """Find a matching DPCode data type available on for this device.""" - if dpcode is None: - return None - - order = ["status_range", "function"] - if prefer_function: - order = ["function", "status_range"] - for key in order: - if dpcode in getattr(self.device, key): - current_type = getattr(self.device, key)[dpcode].type - try: - return DPType(current_type) - except ValueError: - # Sometimes, we get ill-formed DPTypes from the cloud, - # this fixes them and maps them to the correct DPType. - return _DPTYPE_MAPPING.get(current_type) - - return None - async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index d2cceaa46204d..ebd13be689ae0 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -29,7 +29,7 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode from .entity import TuyaEntity from .models import IntegerTypeData -from .util import get_dpcode, remap_value +from .util import get_dpcode, get_dptype, remap_value @dataclass @@ -478,9 +478,9 @@ def __init__( description.brightness_min, dptype=DPType.INTEGER ) - if ( - dpcode := get_dpcode(self.device, description.color_data) - ) and self.get_dptype(dpcode, prefer_function=True) == DPType.JSON: + if (dpcode := get_dpcode(self.device, description.color_data)) and ( + get_dptype(self.device, dpcode, prefer_function=True) == DPType.JSON + ): self._color_data_dpcode = dpcode color_modes.add(ColorMode.HS) if dpcode in self.device.function: diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 0ad28cbc09647..7c9cabaff45c0 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -44,6 +44,7 @@ ) from .entity import TuyaEntity from .models import ComplexValue, ElectricityValue, EnumTypeData, IntegerTypeData +from .util import get_dptype _WIND_DIRECTIONS = { "north": 0.0, @@ -1689,7 +1690,7 @@ def __init__( self._type_data = enum_type self._type = DPType.ENUM else: - self._type = self.get_dptype(DPCode(description.key)) + self._type = get_dptype(self.device, DPCode(description.key)) # Logic to ensure the set device class and API received Unit Of Measurement # match Home Assistants requirements. diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index af6a78c147637..6734f9a0a2a38 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -6,7 +6,17 @@ from homeassistant.exceptions import ServiceValidationError -from .const import DOMAIN, DPCode +from .const import DOMAIN, DPCode, DPType + +_DPTYPE_MAPPING: dict[str, DPType] = { + "bitmap": DPType.BITMAP, + "bool": DPType.BOOLEAN, + "enum": DPType.ENUM, + "json": DPType.JSON, + "raw": DPType.RAW, + "string": DPType.STRING, + "value": DPType.INTEGER, +} def get_dpcode( @@ -32,6 +42,32 @@ def get_dpcode( return None +def get_dptype( + device: CustomerDevice, dpcode: DPCode | None, *, prefer_function: bool = False +) -> DPType | None: + """Find a matching DPType type information for this device DPCode.""" + if dpcode is None: + return None + + lookup_tuple = ( + (device.function, device.status_range) + if prefer_function + else (device.status_range, device.function) + ) + + for device_specs in lookup_tuple: + if current_definition := device_specs.get(dpcode): + current_type = current_definition.type + try: + return DPType(current_type) + except ValueError: + # Sometimes, we get ill-formed DPTypes from the cloud, + # this fixes them and maps them to the correct DPType. + return _DPTYPE_MAPPING.get(current_type) + + return None + + def remap_value( value: float, from_min: float = 0, From fbf875b5af71bc76d8a5748ad3880550ae17ebd7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Oct 2025 13:34:25 +0200 Subject: [PATCH 06/51] Deprecate has_mean in favor of mean_type in recorder statistic API (#154093) --- homeassistant/components/recorder/core.py | 19 +--- .../components/recorder/statistics.py | 19 ++++ .../components/recorder/websocket_api.py | 16 ++- tests/components/energy/test_websocket_api.py | 25 +++-- tests/components/kitchen_sink/test_init.py | 2 +- tests/components/opower/test_coordinator.py | 7 +- .../statistics/test_duplicates.py | 3 +- tests/components/recorder/test_statistics.py | 61 ++++++----- .../components/recorder/test_websocket_api.py | 103 ++++++++++++------ tests/components/sensor/test_recorder.py | 2 +- 10 files changed, 162 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index a0f5c779c0eef..4f1a9a0d87843 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -78,13 +78,7 @@ StatisticsShortTerm, ) from .executor import DBInterruptibleThreadPoolExecutor -from .models import ( - DatabaseEngine, - StatisticData, - StatisticMeanType, - StatisticMetaData, - UnsupportedDialect, -) +from .models import DatabaseEngine, StatisticData, StatisticMetaData, UnsupportedDialect from .pool import POOL_SIZE, MutexPool, RecorderPool from .table_managers.event_data import EventDataManager from .table_managers.event_types import EventTypeManager @@ -621,17 +615,6 @@ def async_import_statistics( table: type[Statistics | StatisticsShortTerm], ) -> None: """Schedule import of statistics.""" - if "mean_type" not in metadata: - # Backwards compatibility for old metadata format - # Can be removed after 2026.4 - metadata["mean_type"] = ( # type: ignore[unreachable] - StatisticMeanType.ARITHMETIC - if metadata.get("has_mean") - else StatisticMeanType.NONE - ) - # Remove deprecated has_mean as it's not needed anymore in core - metadata.pop("has_mean", None) - self.queue_task(ImportStatisticsTask(metadata, stats, table)) @callback diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 0c504d106ef27..647053fe9c2f4 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2595,6 +2595,13 @@ def _async_import_statistics( statistics: Iterable[StatisticData], ) -> None: """Validate timestamps and insert an import_statistics job in the queue.""" + if "mean_type" not in metadata: + metadata["mean_type"] = ( # type: ignore[unreachable] + StatisticMeanType.ARITHMETIC + if metadata.pop("has_mean", False) + else StatisticMeanType.NONE + ) + # If unit class is not set, we try to set it based on the unit of measurement # Note: This can't happen from the type checker's perspective, but we need # to guard against custom integrations that have not been updated to set @@ -2661,6 +2668,12 @@ def async_import_statistics( if not metadata["source"] or metadata["source"] != DOMAIN: raise HomeAssistantError("Invalid source") + if "mean_type" not in metadata and not _called_from_ws_api: # type: ignore[unreachable] + report_usage( # type: ignore[unreachable] + "doesn't specify mean_type when calling async_import_statistics", + breaks_in_ha_version="2026.11", + exclude_integrations={DOMAIN}, + ) if "unit_class" not in metadata and not _called_from_ws_api: # type: ignore[unreachable] report_usage( # type: ignore[unreachable] "doesn't specify unit_class when calling async_import_statistics", @@ -2692,6 +2705,12 @@ def async_add_external_statistics( if not metadata["source"] or metadata["source"] != domain: raise HomeAssistantError("Invalid source") + if "mean_type" not in metadata and not _called_from_ws_api: # type: ignore[unreachable] + report_usage( # type: ignore[unreachable] + "doesn't specify mean_type when calling async_import_statistics", + breaks_in_ha_version="2026.11", + exclude_integrations={DOMAIN}, + ) if "unit_class" not in metadata and not _called_from_ws_api: # type: ignore[unreachable] report_usage( # type: ignore[unreachable] "doesn't specify unit_class when calling async_add_external_statistics", diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index a4a7db15a7ce1..6aa8c44ad4a12 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -544,7 +544,11 @@ def valid_units( { vol.Required("type"): "recorder/import_statistics", vol.Required("metadata"): { - vol.Required("has_mean"): bool, + vol.Optional("has_mean"): bool, + vol.Optional("mean_type"): vol.All( + vol.In(StatisticMeanType.__members__.values()), + vol.Coerce(StatisticMeanType), + ), vol.Required("has_sum"): bool, vol.Required("name"): vol.Any(str, None), vol.Required("source"): str, @@ -574,10 +578,12 @@ def ws_import_statistics( The unit_class specifies which unit conversion class to use, if applicable. """ metadata = msg["metadata"] - # The WS command will be changed in a follow up PR - metadata["mean_type"] = ( - StatisticMeanType.ARITHMETIC if metadata["has_mean"] else StatisticMeanType.NONE - ) + if "mean_type" not in metadata: + _LOGGER.warning( + "WS command recorder/import_statistics called without specifying " + "mean_type in metadata, this is deprecated and will stop working " + "in HA Core 2026.11" + ) if "unit_class" not in metadata: _LOGGER.warning( "WS command recorder/import_statistics called without specifying " diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index af8233d46fd7f..da518e78ef9ae 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -7,6 +7,7 @@ from homeassistant.components.energy import data, is_configured from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.models import StatisticMeanType from homeassistant.components.recorder.statistics import async_add_external_statistics from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -365,8 +366,8 @@ async def test_fossil_energy_consumption_no_co2( }, ) external_energy_metadata_1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_1", @@ -400,8 +401,8 @@ async def test_fossil_energy_consumption_no_co2( }, ) external_energy_metadata_2 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_2", @@ -532,8 +533,8 @@ async def test_fossil_energy_consumption_hole( }, ) external_energy_metadata_1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_1", @@ -567,8 +568,8 @@ async def test_fossil_energy_consumption_hole( }, ) external_energy_metadata_2 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_2", @@ -697,8 +698,8 @@ async def test_fossil_energy_consumption_no_data( }, ) external_energy_metadata_1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_1", @@ -732,8 +733,8 @@ async def test_fossil_energy_consumption_no_data( }, ) external_energy_metadata_2 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_2", @@ -851,8 +852,8 @@ async def test_fossil_energy_consumption( }, ) external_energy_metadata_1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_1", @@ -886,8 +887,8 @@ async def test_fossil_energy_consumption( }, ) external_energy_metadata_2 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_2", @@ -917,8 +918,8 @@ async def test_fossil_energy_consumption( }, ) external_co2_metadata = { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Fossil percentage", "source": "test", "statistic_id": "test:fossil_percentage", @@ -1105,8 +1106,8 @@ async def test_fossil_energy_consumption_check_missing_hour( }, ) energy_metadata_1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -1140,8 +1141,8 @@ async def test_fossil_energy_consumption_check_missing_hour( }, ) co2_metadata = { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Fossil percentage", "source": "test", "statistic_id": "test:fossil_percentage", @@ -1202,8 +1203,8 @@ async def test_fossil_energy_consumption_missing_sum( {"start": period4, "last_reset": None, "state": 3, "mean": 5}, ) external_energy_metadata_1 = { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Mean imported energy", "source": "test", "statistic_id": "test:mean_energy_import_tariff", diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 088a9e9c349c3..6d7b0af1d5d74 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -83,7 +83,7 @@ async def test_demo_statistics_growth(hass: HomeAssistant) -> None: "statistic_id": statistic_id, "unit_class": "volume", "unit_of_measurement": "m³", - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } statistics = [ diff --git a/tests/components/opower/test_coordinator.py b/tests/components/opower/test_coordinator.py index 29a27f66a0cce..1e251e8688da9 100644 --- a/tests/components/opower/test_coordinator.py +++ b/tests/components/opower/test_coordinator.py @@ -10,7 +10,11 @@ from homeassistant.components.opower.const import DOMAIN from homeassistant.components.opower.coordinator import OpowerCoordinator from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -186,6 +190,7 @@ async def test_coordinator_migration( statistic_id = "opower:pge_elec_111111_energy_consumption" metadata = StatisticMetaData( has_sum=True, + mean_type=StatisticMeanType.NONE, name="Opower pge elec 111111 consumption", source=DOMAIN, statistic_id=statistic_id, diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index 65d74f3651c73..e51129b643dee 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -14,6 +14,7 @@ delete_statistics_duplicates, delete_statistics_meta_duplicates, ) +from homeassistant.components.recorder.models import StatisticMeanType from homeassistant.components.recorder.statistics import async_add_external_statistics from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant @@ -59,8 +60,8 @@ async def test_duplicate_statistics_handle_integrity_error( period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) external_energy_metadata_1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_1", diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 303780eacc339..e532e06226838 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -408,8 +408,8 @@ def sensor_stats(entity_id, start): """Generate fake statistics.""" return { "meta": { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": None, "statistic_id": entity_id, "unit_class": None, @@ -675,8 +675,8 @@ async def test_rename_entity_collision( # Insert metadata for sensor.test99 metadata_1 = { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "test", "statistic_id": "sensor.test99", @@ -782,8 +782,8 @@ async def test_rename_entity_collision_states_meta_check_disabled( # Insert metadata for sensor.test99 metadata_1 = { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "test", "statistic_id": "sensor.test99", @@ -867,6 +867,13 @@ async def test_statistics_duplicated( {"unit_class": "energy"}, ], ) +@pytest.mark.parametrize( + ("external_metadata_extra_2"), + [ + {"has_mean": False}, + {"mean_type": StatisticMeanType.NONE}, + ], +) @pytest.mark.parametrize( ("source", "statistic_id", "import_fn"), [ @@ -880,6 +887,7 @@ async def test_import_statistics( hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, external_metadata_extra: dict[str, str], + external_metadata_extra_2: dict[str, Any], source, statistic_id, import_fn, @@ -910,14 +918,17 @@ async def test_import_statistics( "sum": 3, } - external_metadata = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": source, - "statistic_id": statistic_id, - "unit_of_measurement": "kWh", - } | external_metadata_extra + external_metadata = ( + { + "has_sum": True, + "name": "Total imported energy", + "source": source, + "statistic_id": statistic_id, + "unit_of_measurement": "kWh", + } + | external_metadata_extra + | external_metadata_extra_2 + ) import_fn(hass, external_metadata, (external_statistics1, external_statistics2)) await async_wait_recording_done(hass) @@ -1144,8 +1155,8 @@ async def test_external_statistics_errors( } _external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -1233,8 +1244,8 @@ async def test_import_statistics_errors( } _external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.total_energy_import", @@ -1571,8 +1582,8 @@ async def test_daily_statistics_sum( }, ) external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -1753,8 +1764,8 @@ async def test_multiple_daily_statistics_sum( }, ) external_metadata1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy 1", "source": "test", "statistic_id": "test:total_energy_import2", @@ -1762,8 +1773,8 @@ async def test_multiple_daily_statistics_sum( "unit_of_measurement": "kWh", } external_metadata2 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy 2", "source": "test", "statistic_id": "test:total_energy_import1", @@ -1953,8 +1964,8 @@ async def test_weekly_statistics_mean( }, ) external_metadata = { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -2100,8 +2111,8 @@ async def test_weekly_statistics_sum( }, ) external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -2282,8 +2293,8 @@ async def test_monthly_statistics_sum( }, ) external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -2612,8 +2623,8 @@ async def test_change( }, ) external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.total_energy_import", @@ -2949,8 +2960,8 @@ async def test_change_multiple( }, ) external_metadata1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.total_energy_import1", @@ -2958,8 +2969,8 @@ async def test_change_multiple( "unit_of_measurement": "kWh", } external_metadata2 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.total_energy_import2", @@ -3340,8 +3351,8 @@ async def test_change_with_none( }, ) external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -3895,8 +3906,8 @@ async def test_get_statistics_service( }, ) external_metadata1 = { - "has_mean": True, "has_sum": True, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.total_energy_import1", @@ -3904,8 +3915,8 @@ async def test_get_statistics_service( "unit_of_measurement": "kWh", } external_metadata2 = { - "has_mean": True, "has_sum": True, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.total_energy_import2", diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 14787301d3eeb..223c34fa9ae2b 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -6,6 +6,7 @@ import math from statistics import fmean import sys +from typing import Any from unittest.mock import ANY, patch from _pytest.python_api import ApproxBase @@ -319,8 +320,8 @@ async def test_statistic_during_period( ) imported_metadata = { - "has_mean": True, "has_sum": True, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.test", @@ -1095,8 +1096,8 @@ async def test_statistic_during_period_hole( ] imported_metadata = { - "has_mean": True, "has_sum": True, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.test", @@ -1440,8 +1441,8 @@ async def test_statistic_during_period_partial_overlap( statId = "sensor.test_overlapping" imported_metadata = { - "has_mean": True, "has_sum": True, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy overlapping", "source": "recorder", "statistic_id": statId, @@ -3550,8 +3551,8 @@ async def test_get_statistics_metadata( }, ) external_energy_metadata_1 = { - "has_mean": has_mean, "has_sum": has_sum, + "mean_type": mean_type, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_gas", @@ -3647,6 +3648,16 @@ async def test_get_statistics_metadata( ] +@pytest.mark.parametrize( + ("external_metadata_extra_2"), + [ + # Neither has_mean nor mean_type interpreted as False/None + {}, + {"has_mean": False}, + # The WS API accepts integer, not enum + {"mean_type": int(StatisticMeanType.NONE)}, + ], +) @pytest.mark.parametrize( ("external_metadata_extra", "unit_1", "unit_2", "unit_3", "expected_unit_class"), [ @@ -3675,6 +3686,7 @@ async def test_import_statistics( hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, external_metadata_extra: dict[str, str], + external_metadata_extra_2: dict[str, Any], unit_1: str, unit_2: str, unit_3: str, @@ -3705,14 +3717,17 @@ async def test_import_statistics( "sum": 3, } - imported_metadata = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": source, - "statistic_id": statistic_id, - "unit_of_measurement": unit_1, - } | external_metadata_extra + imported_metadata = ( + { + "has_sum": True, + "name": "Total imported energy", + "source": source, + "statistic_id": statistic_id, + "unit_of_measurement": unit_1, + } + | external_metadata_extra + | external_metadata_extra_2 + ) await client.send_json_auto_id( { @@ -3997,8 +4012,8 @@ async def test_import_statistics_with_error( } imported_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": int(StatisticMeanType.NONE), "name": "Total imported energy", "source": source, "statistic_id": statistic_id, @@ -4046,6 +4061,15 @@ async def test_import_statistics_with_error( {"unit_class": "energy"}, ], ) +@pytest.mark.parametrize( + ("external_metadata_extra_2"), + [ + {"has_mean": False}, + { + "mean_type": int(StatisticMeanType.NONE) + }, # The WS API accepts integer, not enum + ], +) @pytest.mark.parametrize( ("source", "statistic_id"), [ @@ -4059,6 +4083,7 @@ async def test_adjust_sum_statistics_energy( hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, external_metadata_extra: dict[str, str], + external_metadata_extra_2: dict[str, Any], source, statistic_id, ) -> None: @@ -4085,14 +4110,17 @@ async def test_adjust_sum_statistics_energy( "sum": 3, } - imported_metadata = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": source, - "statistic_id": statistic_id, - "unit_of_measurement": "kWh", - } | external_metadata_extra + imported_metadata = ( + { + "has_sum": True, + "name": "Total imported energy", + "source": source, + "statistic_id": statistic_id, + "unit_of_measurement": "kWh", + } + | external_metadata_extra + | external_metadata_extra_2 + ) await client.send_json_auto_id( { @@ -4250,6 +4278,15 @@ async def test_adjust_sum_statistics_energy( {"unit_class": "volume"}, ], ) +@pytest.mark.parametrize( + ("external_metadata_extra_2"), + [ + {"has_mean": False}, + { + "mean_type": int(StatisticMeanType.NONE) + }, # The WS API accepts integer, not enum + ], +) @pytest.mark.parametrize( ("source", "statistic_id"), [ @@ -4263,6 +4300,7 @@ async def test_adjust_sum_statistics_gas( hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, external_metadata_extra: dict[str, str], + external_metadata_extra_2: dict[str, Any], source, statistic_id, ) -> None: @@ -4289,14 +4327,17 @@ async def test_adjust_sum_statistics_gas( "sum": 3, } - imported_metadata = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": source, - "statistic_id": statistic_id, - "unit_of_measurement": "m³", - } | external_metadata_extra + imported_metadata = ( + { + "has_sum": True, + "name": "Total imported energy", + "source": source, + "statistic_id": statistic_id, + "unit_of_measurement": "m³", + } + | external_metadata_extra + | external_metadata_extra_2 + ) await client.send_json_auto_id( { @@ -4503,8 +4544,8 @@ async def test_adjust_sum_statistics_errors( } imported_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": int(StatisticMeanType.NONE), "name": "Total imported energy", "source": source, "statistic_id": statistic_id, @@ -4667,8 +4708,8 @@ async def test_import_statistics_with_last_reset( } external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 9f82b5fe6081d..2f8e8ce07297f 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -6141,8 +6141,8 @@ async def test_validate_statistics_other_domain( # Create statistics for another domain metadata: StatisticMetaData = { - "has_mean": True, "has_sum": True, + "mean_type": StatisticMeanType.ARITHMETIC, "name": None, "source": RECORDER_DOMAIN, "statistic_id": "number.test", From c3e2f0e19b411bb52a5b66d6d0c9bb019facf0fe Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 14 Oct 2025 13:35:00 +0200 Subject: [PATCH 07/51] Always run install of packages with same python as script (#154253) --- script/install_integration_requirements.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/script/install_integration_requirements.py b/script/install_integration_requirements.py index 74fd1c93be55c..4e4c911c827f6 100644 --- a/script/install_integration_requirements.py +++ b/script/install_integration_requirements.py @@ -46,6 +46,8 @@ def main() -> int | None: "-c", "homeassistant/package_constraints.txt", "-U", + "--python", + sys.executable, *sorted(all_requirements), # Sort for consistent output ] print(" ".join(cmd)) From 8464dad8e056cc69abe79d61ec886ced5bad89d0 Mon Sep 17 00:00:00 2001 From: Domochip <33293764+Domochip@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:38:14 +0200 Subject: [PATCH 08/51] Add milliPascal (mPa) as unit of measurement for Pressure (#153087) --- homeassistant/components/number/const.py | 2 +- homeassistant/components/sensor/const.py | 2 +- homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 2 ++ homeassistant/util/unit_system.py | 1 + tests/components/sensor/test_recorder.py | 20 ++++++++++++++++---- tests/util/test_unit_conversion.py | 5 +++++ tests/util/test_unit_system.py | 1 + 8 files changed, 28 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 9824d736fe962..9df044ebc8bba 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -330,7 +330,7 @@ class NumberDeviceClass(StrEnum): Unit of measurement: - `mbar`, `cbar`, `bar` - - `Pa`, `hPa`, `kPa` + - `mPa`, `Pa`, `hPa`, `kPa` - `inHg` - `psi` - `inH₂O` diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 356d2313c3100..86af837e2b3ca 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -365,7 +365,7 @@ class SensorDeviceClass(StrEnum): Unit of measurement: - `mbar`, `cbar`, `bar` - - `Pa`, `hPa`, `kPa` + - `mPa`, `Pa`, `hPa`, `kPa` - `inHg` - `psi` - `inH₂O` diff --git a/homeassistant/const.py b/homeassistant/const.py index f5d6dd5b4a9c6..13f61eec6b188 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -613,6 +613,7 @@ class UnitOfFrequency(StrEnum): class UnitOfPressure(StrEnum): """Pressure units.""" + MILLIPASCAL = "mPa" PA = "Pa" HPA = "hPa" KPA = "kPa" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index d57913ee39794..44ea5cbdd900f 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -464,6 +464,7 @@ class PressureConverter(BaseUnitConverter): UNIT_CLASS = "pressure" _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfPressure.MILLIPASCAL: 1 * 1000, UnitOfPressure.PA: 1, UnitOfPressure.HPA: 1 / 100, UnitOfPressure.KPA: 1 / 1000, @@ -478,6 +479,7 @@ class PressureConverter(BaseUnitConverter): / (_MM_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY), } VALID_UNITS = { + UnitOfPressure.MILLIPASCAL, UnitOfPressure.PA, UnitOfPressure.HPA, UnitOfPressure.KPA, diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 3268520e3f66e..4bc79a4da22c8 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -376,6 +376,7 @@ def _deprecated_unit_system(value: str) -> str: ("pressure", UnitOfPressure.MBAR): UnitOfPressure.PSI, ("pressure", UnitOfPressure.CBAR): UnitOfPressure.PSI, ("pressure", UnitOfPressure.BAR): UnitOfPressure.PSI, + ("pressure", UnitOfPressure.MILLIPASCAL): UnitOfPressure.PSI, ("pressure", UnitOfPressure.PA): UnitOfPressure.PSI, ("pressure", UnitOfPressure.HPA): UnitOfPressure.PSI, ("pressure", UnitOfPressure.KPA): UnitOfPressure.PSI, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 2f8e8ce07297f..cfb74b563a844 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -263,6 +263,7 @@ async def assert_validation_result( ("distance", "mi", "mi", "mi", "distance", 13.050847, -10, 30), ("humidity", "%", "%", "%", "unitless", 13.050847, -10, 30), ("humidity", None, None, None, "unitless", 13.050847, -10, 30), + ("pressure", "mPa", "mPa", "mPa", "pressure", 13.050847, -10, 30), ("pressure", "Pa", "Pa", "Pa", "pressure", 13.050847, -10, 30), ("pressure", "hPa", "hPa", "hPa", "pressure", 13.050847, -10, 30), ("pressure", "mbar", "mbar", "mbar", "pressure", 13.050847, -10, 30), @@ -2601,6 +2602,7 @@ async def test_compile_hourly_energy_statistics_multiple( ("distance", "mi", 30), ("humidity", "%", 30), ("humidity", None, 30), + ("pressure", "mPa", 30), ("pressure", "Pa", 30), ("pressure", "hPa", 30), ("pressure", "mbar", 30), @@ -2767,6 +2769,7 @@ async def test_compile_hourly_statistics_partially_unavailable( ("distance", "mi", 30), ("humidity", "%", 30), ("humidity", None, 30), + ("pressure", "mPa", 30), ("pressure", "Pa", 30), ("pressure", "hPa", 30), ("pressure", "mbar", 30), @@ -3045,6 +3048,15 @@ async def test_compile_hourly_statistics_fails( "volume", StatisticMeanType.ARITHMETIC, ), + ( + "measurement", + "pressure", + "mPa", + "mPa", + "mPa", + "pressure", + StatisticMeanType.ARITHMETIC, + ), ( "measurement", "pressure", @@ -5125,7 +5137,7 @@ def set_state(entity_id, state, **kwargs): "pressure", "psi", "bar", - "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, @@ -5133,7 +5145,7 @@ def set_state(entity_id, state, **kwargs): "pressure", "Pa", "bar", - "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mPa, mbar, mmHg, psi", ), ], ) @@ -5364,7 +5376,7 @@ async def test_validate_statistics_unit_ignore_device_class( "pressure", "psi", "bar", - "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, @@ -5372,7 +5384,7 @@ async def test_validate_statistics_unit_ignore_device_class( "pressure", "Pa", "bar", - "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index ba4926eba6d4e..91a9ed084796c 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -675,6 +675,7 @@ PressureConverter: [ (1000, UnitOfPressure.HPA, 14.5037743897, UnitOfPressure.PSI), (1000, UnitOfPressure.HPA, 29.5299801647, UnitOfPressure.INHG), + (1000, UnitOfPressure.HPA, 100000000, UnitOfPressure.MILLIPASCAL), (1000, UnitOfPressure.HPA, 100000, UnitOfPressure.PA), (1000, UnitOfPressure.HPA, 100, UnitOfPressure.KPA), (1000, UnitOfPressure.HPA, 1000, UnitOfPressure.MBAR), @@ -682,6 +683,7 @@ (1000, UnitOfPressure.HPA, 401.46307866177, UnitOfPressure.INH2O), (100, UnitOfPressure.KPA, 14.5037743897, UnitOfPressure.PSI), (100, UnitOfPressure.KPA, 29.5299801647, UnitOfPressure.INHG), + (100, UnitOfPressure.KPA, 100000000, UnitOfPressure.MILLIPASCAL), (100, UnitOfPressure.KPA, 100000, UnitOfPressure.PA), (100, UnitOfPressure.KPA, 1000, UnitOfPressure.HPA), (100, UnitOfPressure.KPA, 1000, UnitOfPressure.MBAR), @@ -689,6 +691,7 @@ (100, UnitOfPressure.INH2O, 3.6127291827353996, UnitOfPressure.PSI), (100, UnitOfPressure.INH2O, 186.83201548767, UnitOfPressure.MMHG), (100, UnitOfPressure.INH2O, 7.3555912463681, UnitOfPressure.INHG), + (100, UnitOfPressure.INH2O, 24908890.833333, UnitOfPressure.MILLIPASCAL), (100, UnitOfPressure.INH2O, 24908.890833333, UnitOfPressure.PA), (100, UnitOfPressure.INH2O, 249.08890833333, UnitOfPressure.HPA), (100, UnitOfPressure.INH2O, 249.08890833333, UnitOfPressure.MBAR), @@ -698,6 +701,7 @@ (30, UnitOfPressure.INHG, 101.59167, UnitOfPressure.KPA), (30, UnitOfPressure.INHG, 1015.9167, UnitOfPressure.HPA), (30, UnitOfPressure.INHG, 101591.67, UnitOfPressure.PA), + (30, UnitOfPressure.INHG, 101591670, UnitOfPressure.MILLIPASCAL), (30, UnitOfPressure.INHG, 1015.9167, UnitOfPressure.MBAR), (30, UnitOfPressure.INHG, 101.59167, UnitOfPressure.CBAR), (30, UnitOfPressure.INHG, 762, UnitOfPressure.MMHG), @@ -706,6 +710,7 @@ (30, UnitOfPressure.MMHG, 3.99967, UnitOfPressure.KPA), (30, UnitOfPressure.MMHG, 39.9967, UnitOfPressure.HPA), (30, UnitOfPressure.MMHG, 3999.67, UnitOfPressure.PA), + (30, UnitOfPressure.MMHG, 3999670, UnitOfPressure.MILLIPASCAL), (30, UnitOfPressure.MMHG, 39.9967, UnitOfPressure.MBAR), (30, UnitOfPressure.MMHG, 3.99967, UnitOfPressure.CBAR), (30, UnitOfPressure.MMHG, 1.181102, UnitOfPressure.INHG), diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index 54e9d4080e3ec..c98611bd16a8d 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -608,6 +608,7 @@ def test_get_metric_converted_unit_( UnitOfPressure.KPA, UnitOfPressure.MBAR, UnitOfPressure.MMHG, + UnitOfPressure.MILLIPASCAL, UnitOfPressure.PA, ), SensorDeviceClass.SPEED: ( From 106a74c9548977f41ff18d2dc7d626db72309785 Mon Sep 17 00:00:00 2001 From: mmstano <133520537+mmstano@users.noreply.github.com> Date: Tue, 14 Oct 2025 07:39:56 -0400 Subject: [PATCH 09/51] Prevent AttributeError in luci device tracker (#148357) Co-authored-by: Erik Montnemery --- homeassistant/components/luci/device_tracker.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py index 0ce9253847260..a2e9a809acbc6 100644 --- a/homeassistant/components/luci/device_tracker.py +++ b/homeassistant/components/luci/device_tracker.py @@ -84,9 +84,12 @@ def get_extra_attributes(self, device): (ip), reachable status (reachable), associated router (host), hostname if known (hostname) among others. """ - device = next( - (result for result in self.last_results if result.mac == device), None - ) + if not ( + device := next( + (result for result in self.last_results if result.mac == device), None + ) + ): + return {} return device._asdict() def _update_info(self): From cdc6c44a497f4e792e043f2755e31d301c61e2f4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 14 Oct 2025 13:46:53 +0200 Subject: [PATCH 10/51] Fix reconfigure flow in esphome uses create_entry (#154107) --- .../components/esphome/config_flow.py | 10 ++++++++++ tests/components/esphome/test_config_flow.py | 18 ++++++++---------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index fc81dfdbc4321..9fcad5aa4f1c0 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -500,6 +500,16 @@ async def async_step_name_conflict_overwrite( ) -> ConfigFlowResult: """Handle creating a new entry by removing the old one and creating new.""" assert self._entry_with_name_conflict is not None + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + return self.async_update_reload_and_abort( + self._entry_with_name_conflict, + title=self._name, + unique_id=self.unique_id, + data=self._async_make_config_data(), + options={ + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + }, + ) await self.hass.config_entries.async_remove( self._entry_with_name_conflict.entry_id ) diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index fb7458a1a5b73..0dbab47b6f569 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -2208,7 +2208,6 @@ async def test_user_flow_name_conflict_overwrite( result["flow_id"], user_input={"next_step_id": "name_conflict_overwrite"} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { CONF_HOST: "127.0.0.1", CONF_PORT: 6053, @@ -2572,16 +2571,15 @@ async def test_reconfig_name_conflict_overwrite( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": "name_conflict_overwrite"} ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" - assert result["data"] == { - CONF_HOST: "127.0.0.2", - CONF_PORT: 6053, - CONF_PASSWORD: "", - CONF_NOISE_PSK: "", - CONF_DEVICE_NAME: "test", - } - assert result["context"]["unique_id"] == "11:22:33:44:55:bb" + assert ( + hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, "11:22:33:44:55:bb" + ) + is not None + ) assert ( hass.config_entries.async_entry_for_domain_unique_id( DOMAIN, "11:22:33:44:55:aa" From 06e49220211d53bc25375365952063daf21fd1f3 Mon Sep 17 00:00:00 2001 From: Yvan13120 <137772797+Yvan13120@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:49:32 +0200 Subject: [PATCH 11/51] Fix state class for Overkiz water consumption (#154164) --- homeassistant/components/overkiz/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index b0a15b3970ee6..dbdf833314625 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -120,7 +120,7 @@ class OverkizSensorDescription(SensorEntityDescription): icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.WATER, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, ), OverkizSensorDescription( key=OverkizState.IO_OUTLET_ENGINE, From 64f48564ff862417ab4ab81b68ab44fc1aa2fcd2 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 14 Oct 2025 14:02:22 +0200 Subject: [PATCH 12/51] Change device identifier and binary_sensor unique_id for airOS (#153085) Co-authored-by: G Johansson --- homeassistant/components/airos/__init__.py | 62 ++++++++++++-- .../components/airos/binary_sensor.py | 2 +- homeassistant/components/airos/config_flow.py | 4 +- homeassistant/components/airos/entity.py | 2 +- .../airos/snapshots/test_binary_sensor.ambr | 10 +-- tests/components/airos/test_init.py | 83 +++++++++++++++++-- 6 files changed, 144 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py index 9eea047f9b7ef..d449c9a05e803 100644 --- a/homeassistant/components/airos/__init__.py +++ b/homeassistant/components/airos/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from airos.airos8 import AirOS8 from homeassistant.const import ( @@ -12,10 +14,11 @@ CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, SECTION_ADVANCED_SETTINGS +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator _PLATFORMS: list[Platform] = [ @@ -23,6 +26,8 @@ Platform.SENSOR, ] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: """Set up Ubiquiti airOS from a config entry.""" @@ -54,11 +59,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: """Migrate old config entry.""" - if entry.version > 1: - # This means the user has downgraded from a future version + # This means the user has downgraded from a future version + if entry.version > 2: return False + # 1.1 Migrate config_entry to add advanced ssl settings if entry.version == 1 and entry.minor_version == 1: + new_minor_version = 2 new_data = {**entry.data} advanced_data = { CONF_SSL: DEFAULT_SSL, @@ -69,7 +76,52 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> b hass.config_entries.async_update_entry( entry, data=new_data, - minor_version=2, + minor_version=new_minor_version, + ) + + # 2.1 Migrate binary_sensor entity unique_id from device_id to mac_address + # Step 1 - migrate binary_sensor entity unique_id + # Step 2 - migrate device entity identifier + if entry.version == 1: + new_version = 2 + new_minor_version = 1 + + mac_adress = dr.format_mac(entry.unique_id) + + device_registry = dr.async_get(hass) + if device_entry := device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac_adress)} + ): + old_device_id = next( + ( + device_id + for domain, device_id in device_entry.identifiers + if domain == DOMAIN + ), + ) + + @callback + def update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + """Update unique id from device_id to mac address.""" + if old_device_id and entity_entry.unique_id.startswith(old_device_id): + suffix = entity_entry.unique_id.removeprefix(old_device_id) + new_unique_id = f"{mac_adress}{suffix}" + return {"new_unique_id": new_unique_id} + return None + + await er.async_migrate_entries(hass, entry.entry_id, update_unique_id) + + new_identifiers = device_entry.identifiers.copy() + new_identifiers.discard((DOMAIN, old_device_id)) + new_identifiers.add((DOMAIN, mac_adress)) + device_registry.async_update_device( + device_entry.id, new_identifiers=new_identifiers + ) + + hass.config_entries.async_update_entry( + entry, version=new_version, minor_version=new_minor_version ) return True diff --git a/homeassistant/components/airos/binary_sensor.py b/homeassistant/components/airos/binary_sensor.py index 1fc89d5301ab1..994caeb2071e9 100644 --- a/homeassistant/components/airos/binary_sensor.py +++ b/homeassistant/components/airos/binary_sensor.py @@ -98,7 +98,7 @@ def __init__( super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.data.host.device_id}_{description.key}" + self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}" @property def is_on(self) -> bool: diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py index fac4ccef804c5..0115bf0939b10 100644 --- a/homeassistant/components/airos/config_flow.py +++ b/homeassistant/components/airos/config_flow.py @@ -57,8 +57,8 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ubiquiti airOS.""" - VERSION = 1 - MINOR_VERSION = 2 + VERSION = 2 + MINOR_VERSION = 1 def __init__(self) -> None: """Initialize the config flow.""" diff --git a/homeassistant/components/airos/entity.py b/homeassistant/components/airos/entity.py index baa1695d08e07..2a54bf2415d0c 100644 --- a/homeassistant/components/airos/entity.py +++ b/homeassistant/components/airos/entity.py @@ -33,7 +33,7 @@ def __init__(self, coordinator: AirOSDataUpdateCoordinator) -> None: self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, airos_data.derived.mac)}, configuration_url=configuration_url, - identifiers={(DOMAIN, str(airos_data.host.device_id))}, + identifiers={(DOMAIN, airos_data.derived.mac)}, manufacturer=MANUFACTURER, model=airos_data.host.devmodel, model_id=( diff --git a/tests/components/airos/snapshots/test_binary_sensor.ambr b/tests/components/airos/snapshots/test_binary_sensor.ambr index d9815e0c62bd1..65705c7f62964 100644 --- a/tests/components/airos/snapshots/test_binary_sensor.ambr +++ b/tests/components/airos/snapshots/test_binary_sensor.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhcp_client', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_client', + 'unique_id': '01:23:45:67:89:AB_dhcp_client', 'unit_of_measurement': None, }) # --- @@ -79,7 +79,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhcp_server', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_server', + 'unique_id': '01:23:45:67:89:AB_dhcp_server', 'unit_of_measurement': None, }) # --- @@ -128,7 +128,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhcp6_server', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp6_server', + 'unique_id': '01:23:45:67:89:AB_dhcp6_server', 'unit_of_measurement': None, }) # --- @@ -177,7 +177,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'port_forwarding', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_portfw', + 'unique_id': '01:23:45:67:89:AB_portfw', 'unit_of_measurement': None, }) # --- @@ -225,7 +225,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pppoe', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_pppoe', + 'unique_id': '01:23:45:67:89:AB_pppoe', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/airos/test_init.py b/tests/components/airos/test_init.py index 30e2498d7d763..f0c9d0831069f 100644 --- a/tests/components/airos/test_init.py +++ b/tests/components/airos/test_init.py @@ -4,12 +4,16 @@ from unittest.mock import ANY, MagicMock +import pytest + from homeassistant.components.airos.const import ( DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS, ) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -19,6 +23,7 @@ CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -108,8 +113,10 @@ async def test_setup_entry_without_ssl( assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False -async def test_migrate_entry(hass: HomeAssistant, mock_airos_client: MagicMock) -> None: - """Test migrate entry unique id.""" +async def test_ssl_migrate_entry( + hass: HomeAssistant, mock_airos_client: MagicMock +) -> None: + """Test migrate entry SSL options.""" entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, @@ -124,11 +131,77 @@ async def test_migrate_entry(hass: HomeAssistant, mock_airos_client: MagicMock) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert entry.version == 1 - assert entry.minor_version == 2 + assert entry.version == 2 + assert entry.minor_version == 1 assert entry.data == MOCK_CONFIG_V1_2 +@pytest.mark.parametrize( + ("sensor_domain", "sensor_name", "mock_id"), + [ + (BINARY_SENSOR_DOMAIN, "port_forwarding", "device_id_12345"), + (SENSOR_DOMAIN, "antenna_gain", "01:23:45:67:89:ab"), + ], +) +async def test_uid_migrate_entry( + hass: HomeAssistant, + mock_airos_client: MagicMock, + device_registry: dr.DeviceRegistry, + sensor_domain: str, + sensor_name: str, + mock_id: str, +) -> None: + """Test migrate entry unique id.""" + entity_registry = er.async_get(hass) + + MOCK_MAC = dr.format_mac("01:23:45:67:89:AB") + MOCK_ID = "device_id_12345" + old_unique_id = f"{mock_id}_{sensor_name}" + new_unique_id = f"{MOCK_MAC}_{sensor_name}" + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=MOCK_CONFIG_V1_2, + entry_id="1", + unique_id=mock_id, + version=1, + minor_version=2, + ) + entry.add_to_hass(hass) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, MOCK_ID)}, + connections={ + (dr.CONNECTION_NETWORK_MAC, MOCK_MAC), + }, + ) + await hass.async_block_till_done() + + old_entity_entry = entity_registry.async_get_or_create( + DOMAIN, sensor_domain, old_unique_id, config_entry=entry + ) + original_entity_id = old_entity_entry.entity_id + + hass.config_entries.async_update_entry(entry, unique_id=MOCK_MAC) + await hass.async_block_till_done() + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + updated_entity_entry = entity_registry.async_get(original_entity_id) + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 2 + assert entry.minor_version == 1 + assert ( + entity_registry.async_get_entity_id(sensor_domain, DOMAIN, old_unique_id) + is None + ) + assert updated_entity_entry.unique_id == new_unique_id + + async def test_migrate_future_return( hass: HomeAssistant, mock_airos_client: MagicMock, @@ -140,7 +213,7 @@ async def test_migrate_future_return( data=MOCK_CONFIG_V1_2, entry_id="1", unique_id="airos_device", - version=2, + version=3, ) entry.add_to_hass(hass) From bddbf9c73cb351a40a6cd4cd2a52a4a4a728a72d Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:04:57 +0200 Subject: [PATCH 13/51] Simplify current ids callback in config entries (#154082) --- homeassistant/config_entries.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 3b18cdb3446b8..5eb2d0f2bb8cd 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3031,8 +3031,9 @@ def _async_current_ids(self, include_ignore: bool = True) -> set[str | None]: """Return current unique IDs.""" return { entry.unique_id - for entry in self.hass.config_entries.async_entries(self.handler) - if include_ignore or entry.source != SOURCE_IGNORE + for entry in self.hass.config_entries.async_entries( + self.handler, include_ignore=include_ignore + ) } @callback From 85b26479deecf02795e9947256949297997df0ff Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 14 Oct 2025 15:09:29 +0300 Subject: [PATCH 14/51] Shut down core event loop on unrecoverable errors (#144806) --- homeassistant/runner.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 6fa59923e8192..89d06b3132d75 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -7,6 +7,7 @@ from contextlib import contextmanager import dataclasses from datetime import datetime +import errno import fcntl from io import TextIOWrapper import json @@ -207,11 +208,20 @@ def new_event_loop(self) -> asyncio.AbstractEventLoop: @callback -def _async_loop_exception_handler(_: Any, context: dict[str, Any]) -> None: +def _async_loop_exception_handler( + loop: asyncio.AbstractEventLoop, + context: dict[str, Any], +) -> None: """Handle all exception inside the core loop.""" + fatal_error: str | None = None kwargs = {} if exception := context.get("exception"): kwargs["exc_info"] = (type(exception), exception, exception.__traceback__) + if isinstance(exception, OSError) and exception.errno == errno.EMFILE: + # Too many open files – something is leaking them, and it's likely + # to be quite unrecoverable if the event loop can't pump messages + # (e.g. unable to accept a socket). + fatal_error = str(exception) logger = logging.getLogger(__package__) if source_traceback := context.get("source_traceback"): @@ -232,6 +242,14 @@ def _async_loop_exception_handler(_: Any, context: dict[str, Any]) -> None: **kwargs, # type: ignore[arg-type] ) + if fatal_error: + logger.error( + "Fatal error '%s' raised in event loop, shutting it down", + fatal_error, + ) + loop.stop() + loop.close() + async def setup_and_run_hass(runtime_config: RuntimeConfig) -> int: """Set up Home Assistant and run.""" From 21f24c2f6a676ac38a35957f23eec074376b2b78 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:10:14 +0200 Subject: [PATCH 15/51] Get Enphase_envoy collar grid status from admin_state_str rather then from grid_state (#153766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .../components/enphase_envoy/icons.json | 19 ++++++++ .../components/enphase_envoy/sensor.py | 16 +++++++ .../components/enphase_envoy/strings.json | 20 +++++++- .../enphase_envoy/snapshots/test_sensor.ambr | 48 +++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enphase_envoy/icons.json b/homeassistant/components/enphase_envoy/icons.json index 21262d1dc897d..da1fdce32b4eb 100644 --- a/homeassistant/components/enphase_envoy/icons.json +++ b/homeassistant/components/enphase_envoy/icons.json @@ -38,6 +38,25 @@ }, "available_energy": { "default": "mdi:battery-50" + }, + "grid_status": { + "default": "mdi:transmission-tower", + "state": { + "off_grid": "mdi:transmission-tower-off", + "synchronizing": "mdi:sync-alert" + } + }, + "mid_state": { + "default": "mdi:electric-switch-closed", + "state": { + "open": "mdi:electric-switch" + } + }, + "admin_state": { + "default": "mdi:transmission-tower", + "state": { + "off_grid": "mdi:transmission-tower-off" + } } }, "switch": { diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index ed3864e6f836d..807798c48cf7c 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -824,6 +824,12 @@ class EnvoyCollarSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[EnvoyCollar], datetime.datetime | int | float | str] +# translations don't accept uppercase +ADMIN_STATE_MAP = { + "ENCMN_MDE_ON_GRID": "on_grid", + "ENCMN_MDE_OFF_GRID": "off_grid", +} + COLLAR_SENSORS = ( EnvoyCollarSensorEntityDescription( key="temperature", @@ -838,11 +844,21 @@ class EnvoyCollarSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda collar: dt_util.utc_from_timestamp(collar.last_report_date), ), + # grid_state does not seem to change when off-grid, but rather admin_state_str EnvoyCollarSensorEntityDescription( key="grid_state", translation_key="grid_status", value_fn=lambda collar: collar.grid_state, ), + # grid_status off-grid shows in admin_state rather than in grid_state + # map values as translations don't accept uppercase which these are + EnvoyCollarSensorEntityDescription( + key="admin_state_str", + translation_key="admin_state", + value_fn=lambda collar: ADMIN_STATE_MAP.get( + collar.admin_state_str, collar.admin_state_str + ), + ), EnvoyCollarSensorEntityDescription( key="mid_state", translation_key="mid_state", diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 17ed8eff67ec5..9e4b014e24350 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -409,10 +409,26 @@ "name": "Last report duration" }, "grid_status": { - "name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]" + "name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]", + "state": { + "on_grid": "On grid", + "off_grid": "Off grid", + "synchronizing": "Synchronizing to grid" + } }, "mid_state": { - "name": "MID state" + "name": "MID state", + "state": { + "open": "[%key:common::state::open%]", + "close": "[%key:common::state::closed%]" + } + }, + "admin_state": { + "name": "Admin state", + "state": { + "on_grid": "[%key:component::enphase_envoy::entity::sensor::grid_status::state::on_grid%]", + "off_grid": "[%key:component::enphase_envoy::entity::sensor::grid_status::state::off_grid%]" + } } }, "switch": { diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index 00cb30fce09d0..dcd4d8ba3d8df 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -13996,6 +13996,54 @@ 'state': '2025-07-19T17:17:31+00:00', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_admin_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.collar_482520020939_admin_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Admin state', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'admin_state', + 'unique_id': '482520020939_admin_state_str', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_admin_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Collar 482520020939 Admin state', + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_admin_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on_grid', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_grid_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From d140eb4c765fe07f3b91836a46e143abef3c5429 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 14 Oct 2025 14:14:37 +0200 Subject: [PATCH 16/51] Protect internal coordinator state (#153685) --- .../components/canary/alarm_control_panel.py | 2 +- homeassistant/components/canary/camera.py | 3 +- homeassistant/components/canary/sensor.py | 2 +- homeassistant/helpers/debounce.py | 25 ++++++- homeassistant/helpers/update_coordinator.py | 20 ++++-- tests/components/canary/test_sensor.py | 7 +- tests/components/hassio/test_init.py | 4 +- .../playstation_network/test_init.py | 12 ++-- tests/helpers/test_update_coordinator.py | 65 +++++++++++++++++++ 9 files changed, 116 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index 9fe2dfb598dac..7fb8b1450e709 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -31,7 +31,7 @@ async def async_setup_entry( for location_id, location in coordinator.data["locations"].items() ] - async_add_entities(alarms, True) + async_add_entities(alarms) class CanaryAlarm( diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 07645f2f403d1..2fe7e9694aedc 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -68,8 +68,7 @@ async def async_setup_entry( for location_id, location in coordinator.data["locations"].items() for device in location.devices if device.is_online - ), - True, + ) ) diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index d92166926e9c0..9643fb6805aac 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -80,7 +80,7 @@ async def async_setup_entry( if device_type.get("name") in sensor_type[4] ) - async_add_entities(sensors, True) + async_add_entities(sensors) class CanarySensor(CoordinatorEntity[CanaryDataUpdateCoordinator], SensorEntity): diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index a562f86f1f925..67d6ad55a3a40 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import AsyncGenerator, Callable +from contextlib import asynccontextmanager from logging import Logger +from typing import Any from homeassistant.core import HassJob, HomeAssistant, callback @@ -36,6 +38,7 @@ def __init__( self._timer_task: asyncio.TimerHandle | None = None self._execute_at_end_of_timer: bool = False self._execute_lock = asyncio.Lock() + self._execute_lock_owner: asyncio.Task[Any] | None = None self._background = background self._job: HassJob[[], _R_co] | None = ( None @@ -46,6 +49,22 @@ def __init__( ) self._shutdown_requested = False + @asynccontextmanager + async def async_lock(self) -> AsyncGenerator[None]: + """Return an async context manager to lock the debouncer.""" + if self._execute_lock_owner is asyncio.current_task(): + raise RuntimeError("Debouncer lock is not re-entrant") + + if self._execute_lock.locked(): + self.logger.debug("Debouncer lock is already acquired, waiting") + + async with self._execute_lock: + self._execute_lock_owner = asyncio.current_task() + try: + yield + finally: + self._execute_lock_owner = None + @property def function(self) -> Callable[[], _R_co] | None: """Return the function being wrapped by the Debouncer.""" @@ -98,7 +117,7 @@ async def async_call(self) -> None: if not self._async_schedule_or_call_now(): return - async with self._execute_lock: + async with self.async_lock(): # Abort if timer got set while we're waiting for the lock. if self._timer_task: return @@ -122,7 +141,7 @@ async def _handle_timer_finish(self) -> None: if self._execute_lock.locked(): return - async with self._execute_lock: + async with self.async_lock(): # Abort if timer got set while we're waiting for the lock. if self._timer_task: return diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 85da7eaf87f89..27164e8fcf395 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -128,10 +128,10 @@ def __init__( logger, cooldown=REQUEST_REFRESH_DEFAULT_COOLDOWN, immediate=REQUEST_REFRESH_DEFAULT_IMMEDIATE, - function=self.async_refresh, + function=self._async_refresh, ) else: - request_refresh_debouncer.function = self.async_refresh + request_refresh_debouncer.function = self._async_refresh self._debounced_refresh = request_refresh_debouncer @@ -277,7 +277,8 @@ def __wrap_handle_refresh_interval(self) -> None: async def _handle_refresh_interval(self, _now: datetime | None = None) -> None: """Handle a refresh interval occurrence.""" self._unsub_refresh = None - await self._async_refresh(log_failures=True, scheduled=True) + async with self._debounced_refresh.async_lock(): + await self._async_refresh(log_failures=True, scheduled=True) async def async_request_refresh(self) -> None: """Request a refresh. @@ -295,6 +296,16 @@ async def _async_update_data(self) -> _DataT: async def async_config_entry_first_refresh(self) -> None: """Refresh data for the first time when a config entry is setup. + Will automatically raise ConfigEntryNotReady if the refresh + fails. Additionally logging is handled by config entry setup + to ensure that multiple retries do not cause log spam. + """ + async with self._debounced_refresh.async_lock(): + await self._async_config_entry_first_refresh() + + async def _async_config_entry_first_refresh(self) -> None: + """Refresh data for the first time when a config entry is setup. + Will automatically raise ConfigEntryNotReady if the refresh fails. Additionally logging is handled by config entry setup to ensure that multiple retries do not cause log spam. @@ -364,7 +375,8 @@ async def _async_setup(self) -> None: async def async_refresh(self) -> None: """Refresh data and log errors.""" - await self._async_refresh(log_failures=True) + async with self._debounced_refresh.async_lock(): + await self._async_refresh(log_failures=True) async def _async_refresh( # noqa: C901 self, diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py index b5a79724ddbac..adbf985c8ade6 100644 --- a/tests/components/canary/test_sensor.py +++ b/tests/components/canary/test_sensor.py @@ -19,7 +19,6 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util.dt import utcnow from . import init_integration, mock_device, mock_location, mock_reading @@ -126,8 +125,7 @@ async def test_sensors_attributes_pro(hass: HomeAssistant, canary) -> None: future = utcnow() + timedelta(seconds=30) async_fire_time_changed(hass, future) - await async_update_entity(hass, entity_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state2 = hass.states.get(entity_id) assert state2 @@ -142,8 +140,7 @@ async def test_sensors_attributes_pro(hass: HomeAssistant, canary) -> None: future += timedelta(seconds=30) async_fire_time_changed(hass, future) - await async_update_entity(hass, entity_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state3 = hass.states.get(entity_id) assert state3 diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index f96ab8aca2a09..9d7c66b0d2474 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -922,7 +922,7 @@ async def test_coordinator_updates( supervisor_client.refresh_updates.assert_not_called() async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Scheduled refresh, no update refresh call supervisor_client.refresh_updates.assert_not_called() @@ -944,7 +944,7 @@ async def test_coordinator_updates( async_fire_time_changed( hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) supervisor_client.refresh_updates.assert_called_once() supervisor_client.refresh_updates.reset_mock() diff --git a/tests/components/playstation_network/test_init.py b/tests/components/playstation_network/test_init.py index e5a361a3cfbe9..f3fc37a726426 100644 --- a/tests/components/playstation_network/test_init.py +++ b/tests/components/playstation_network/test_init.py @@ -157,8 +157,8 @@ async def test_trophy_title_coordinator_auth_failed( freezer.tick(timedelta(days=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done(wait_background_tasks=True) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -194,8 +194,8 @@ async def test_trophy_title_coordinator_update_data_failed( freezer.tick(timedelta(days=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done(wait_background_tasks=True) runtime_data: PlaystationNetworkRuntimeData = config_entry.runtime_data assert runtime_data.trophy_titles.last_update_success is False @@ -254,8 +254,8 @@ async def test_trophy_title_coordinator_play_new_game( freezer.tick(timedelta(days=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 2 diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index c81128ac8bb1f..d91a53fffe914 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -1,5 +1,6 @@ """Tests for the update coordinator.""" +import asyncio from datetime import datetime, timedelta import logging from unittest.mock import AsyncMock, Mock, patch @@ -405,6 +406,70 @@ async def test_update_interval_not_present( assert crd.data is None +async def test_update_locks( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: + """Test update interval works.""" + start = asyncio.Event() + block = asyncio.Event() + + async def _update_method() -> int: + start.set() + await block.wait() + block.clear() + return 0 + + crd.update_method = _update_method + + # Add subscriber + update_callback = Mock() + crd.async_add_listener(update_callback) + + assert crd.update_interval + + # Trigger timed update, ensure it is started + freezer.tick(crd.update_interval) + async_fire_time_changed(hass) + await start.wait() + start.clear() + + # Trigger direct update + task = hass.async_create_background_task(crd.async_refresh(), "", eager_start=True) + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + + # Ensure it has not started + assert not start.is_set() + + # Unblock interval update + block.set() + + # Check that direct update starts + await start.wait() + start.clear() + + # Request update. This should not be blocking + # since the lock is held, it should be queued + await crd.async_request_refresh() + assert not start.is_set() + + # Unblock second update + block.set() + # Check that task finishes + await task + + # Check that queued update starts + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await start.wait() + start.clear() + + # Unblock queued update + block.set() + + async def test_refresh_recover( crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture ) -> None: From 61a9094d5f45329d9cd2785a1b33f6cfd4006bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Tue, 14 Oct 2025 14:23:41 +0200 Subject: [PATCH 17/51] Update WLED Select Options after update (#154205) Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com> --- homeassistant/components/wled/select.py | 47 +++++++++----- tests/components/wled/test_select.py | 86 ++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index 76837652ae5be..e5835d0e67162 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -79,10 +79,6 @@ def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: super().__init__(coordinator=coordinator) self._attr_unique_id = f"{coordinator.data.info.mac_address}_preset" - sorted_values = sorted( - coordinator.data.presets.values(), key=lambda preset: preset.name - ) - self._attr_options = [preset.name for preset in sorted_values] @property def available(self) -> bool: @@ -100,6 +96,14 @@ def current_option(self) -> str | None: return preset.name return None + @property + def options(self) -> list[str]: + """Return a list of selectable options.""" + sorted_values = sorted( + self.coordinator.data.presets.values(), key=lambda preset: preset.name + ) + return [preset.name for preset in sorted_values] + @wled_exception_handler async def async_select_option(self, option: str) -> None: """Set WLED segment to the selected preset.""" @@ -116,10 +120,6 @@ def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: super().__init__(coordinator=coordinator) self._attr_unique_id = f"{coordinator.data.info.mac_address}_playlist" - sorted_values = sorted( - coordinator.data.playlists.values(), key=lambda playlist: playlist.name - ) - self._attr_options = [playlist.name for playlist in sorted_values] @property def available(self) -> bool: @@ -137,6 +137,14 @@ def current_option(self) -> str | None: return playlist.name return None + @property + def options(self) -> list[str]: + """Return a list of selectable options.""" + sorted_values = sorted( + self.coordinator.data.playlists.values(), key=lambda playlist: playlist.name + ) + return [playlist.name for playlist in sorted_values] + @wled_exception_handler async def async_select_option(self, option: str) -> None: """Set WLED segment to the selected playlist.""" @@ -161,10 +169,6 @@ def __init__(self, coordinator: WLEDDataUpdateCoordinator, segment: int) -> None self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = f"{coordinator.data.info.mac_address}_palette_{segment}" - sorted_values = sorted( - coordinator.data.palettes.values(), key=lambda palette: palette.name - ) - self._attr_options = [palette.name for palette in sorted_values] self._segment = segment @property @@ -180,9 +184,22 @@ def available(self) -> bool: @property def current_option(self) -> str | None: """Return the current selected color palette.""" - return self.coordinator.data.palettes[ - int(self.coordinator.data.state.segments[self._segment].palette_id) - ].name + if not self.coordinator.data.palettes: + return None + if (segment := self.coordinator.data.state.segments.get(self._segment)) is None: + return None + palette_id = int(segment.palette_id) + if (palette := self.coordinator.data.palettes.get(palette_id)) is None: + return None + return palette.name + + @property + def options(self) -> list[str]: + """Return a list of selectable options.""" + sorted_values = sorted( + self.coordinator.data.palettes.values(), key=lambda palette: palette.name + ) + return [palette.name for palette in sorted_values] @wled_exception_handler async def async_select_option(self, option: str) -> None: diff --git a/tests/components/wled/test_select.py b/tests/components/wled/test_select.py index 99e205e91b9be..6325905fa0a37 100644 --- a/tests/components/wled/test_select.py +++ b/tests/components/wled/test_select.py @@ -1,11 +1,19 @@ """Tests for the WLED select platform.""" +import typing from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError +from wled import ( + Device as WLEDDevice, + Palette as WLEDPalette, + Playlist as WLEDPlaylist, + Preset as WLEDPreset, + WLEDConnectionError, + WLEDError, +) from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN from homeassistant.components.wled.const import DOMAIN, SCAN_INTERVAL @@ -168,3 +176,79 @@ async def test_playlist_unavailable_without_playlists(hass: HomeAssistant) -> No """Test WLED playlist entity is unavailable when playlists are not available.""" assert (state := hass.states.get("select.wled_rgb_light_playlist")) assert state.state == STATE_UNAVAILABLE + + +PLAYLIST = {"ps": [1], "dur": [100], "transition": [7], "repeat": 0, "end": 0, "r": 0} + + +@pytest.mark.parametrize( + ("entity_id", "data_attr", "new_data", "new_options"), + [ + ( + "select.wled_rgb_light_preset", + "presets", + { + 1: WLEDPreset.from_dict({"preset_id": 1, "n": "Preset 1"}), + 2: WLEDPreset.from_dict({"preset_id": 2, "n": "Preset 2"}), + }, + ["Preset 1", "Preset 2"], + ), + ( + "select.wled_rgb_light_playlist", + "playlists", + { + 1: WLEDPlaylist.from_dict( + {"playlist_id": 1, "n": "Playlist 1", "playlist": PLAYLIST} + ), + 2: WLEDPlaylist.from_dict( + {"playlist_id": 2, "n": "Playlist 2", "playlist": PLAYLIST} + ), + }, + ["Playlist 1", "Playlist 2"], + ), + ( + "select.wled_rgb_light_color_palette", + "palettes", + { + 0: WLEDPalette.from_dict({"palette_id": 0, "name": "Palette 1"}), + 1: WLEDPalette.from_dict({"palette_id": 1, "name": "Palette 2"}), + }, + ["Palette 1", "Palette 2"], + ), + ], +) +async def test_select_load_new_options_after_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_wled: MagicMock, + entity_id: str, + data_attr: str, + new_data: typing.Any, + new_options: list[str], +) -> None: + """Test WLED select entity is updated when new options are added.""" + setattr( + mock_wled.update.return_value, + data_attr, + {}, + ) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.attributes["options"] == [] + + setattr( + mock_wled.update.return_value, + data_attr, + new_data, + ) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.attributes["options"] == new_options From 8db6505a97214d6ef1177cff19430ecd0601e4a3 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:35:12 +0100 Subject: [PATCH 18/51] Set initial integration_hub in manifest for Squeezebox (#154438) --- homeassistant/components/squeezebox/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 0336ede3eeac9..47147f21f4036 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -10,6 +10,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/squeezebox", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pysqueezebox"], "requirements": ["pysqueezebox==0.13.0"] From f3c42880267aa8f0a7360b871110e32e91a7f632 Mon Sep 17 00:00:00 2001 From: Jamin Date: Tue, 14 Oct 2025 07:36:31 -0500 Subject: [PATCH 19/51] Use contact header for outgoing call transport (#151847) --- homeassistant/components/voip/__init__.py | 14 ++-- .../components/voip/assist_satellite.py | 6 +- homeassistant/components/voip/const.py | 4 ++ homeassistant/components/voip/devices.py | 35 ++++++++-- homeassistant/components/voip/store.py | 54 ++++++++++++++ tests/components/voip/test_devices.py | 70 +++++++++++++++++++ 6 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/voip/store.py diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py index 96e758e91f4b8..cfdaf4dc19255 100644 --- a/homeassistant/components/voip/__init__.py +++ b/homeassistant/components/voip/__init__.py @@ -17,6 +17,7 @@ from .const import CONF_SIP_PORT, DOMAIN from .devices import VoIPDevices +from .store import VoipStore from .voip import HassVoipDatagramProtocol PLATFORMS = ( @@ -35,6 +36,8 @@ "async_unload_entry", ] +type VoipConfigEntry = ConfigEntry[VoipStore] + @dataclass class DomainData: @@ -45,7 +48,7 @@ class DomainData: devices: VoIPDevices -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool: """Set up VoIP integration from a config entry.""" # Make sure there is a valid user ID for VoIP in the config entry if ( @@ -59,9 +62,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, data={**entry.data, "user": voip_user.id} ) + entry.runtime_data = VoipStore(hass, entry.entry_id) sip_port = entry.options.get(CONF_SIP_PORT, SIP_PORT) devices = VoIPDevices(hass, entry) - devices.async_setup() + await devices.async_setup() transport, protocol = await _create_sip_server( hass, lambda: HassVoipDatagramProtocol(hass, devices), @@ -102,7 +106,7 @@ async def _create_sip_server( return transport, protocol -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool: """Unload VoIP.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): _LOGGER.debug("Shutting down VoIP server") @@ -121,9 +125,11 @@ async def async_remove_config_entry_device( return True -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> None: """Remove VoIP entry.""" if "user" in entry.data and ( user := await hass.auth.async_get_user(entry.data["user"]) ): await hass.auth.async_remove_user(user) + + await entry.runtime_data.async_remove() diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 8d11cf2ff8932..20b5f9a7182f8 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -119,6 +119,8 @@ def __init__( AssistSatelliteEntity.__init__(self) RtpDatagramProtocol.__init__(self) + _LOGGER.debug("Assist satellite with device: %s", voip_device) + self.config_entry = config_entry self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() @@ -254,7 +256,7 @@ async def _do_announce( ) try: - # VoIP ID is SIP header + # VoIP ID is SIP header - This represents what is set as the To header destination_endpoint = SipEndpoint(self.voip_device.voip_id) except ValueError: # VoIP ID is IP address @@ -269,10 +271,12 @@ async def _do_announce( # Make the call sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol + _LOGGER.debug("Outgoing call to contact %s", self.voip_device.contact) call_info = sip_protocol.outgoing_call( source=source_endpoint, destination=destination_endpoint, rtp_port=self._rtp_port, + contact=self.voip_device.contact, ) # Check if caller didn't pick up diff --git a/homeassistant/components/voip/const.py b/homeassistant/components/voip/const.py index 9a4403f9df2c5..87d6dc5f41514 100644 --- a/homeassistant/components/voip/const.py +++ b/homeassistant/components/voip/const.py @@ -1,5 +1,7 @@ """Constants for the Voice over IP integration.""" +from typing import Final + DOMAIN = "voip" RATE = 16000 @@ -14,3 +16,5 @@ CONF_SIP_PORT = "sip_port" CONF_SIP_USER = "sip_user" + +STORAGE_VER: Final = 1 diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py index c33ec048cbd38..028d51280b423 100644 --- a/homeassistant/components/voip/devices.py +++ b/homeassistant/components/voip/devices.py @@ -4,15 +4,20 @@ from collections.abc import Callable, Iterator from dataclasses import dataclass, field +import logging from typing import Any from voip_utils import CallInfo, VoipDatagramProtocol +from voip_utils.sip import SipEndpoint from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN +from .store import DeviceContact, DeviceContacts, VoipStore + +_LOGGER = logging.getLogger(__name__) @dataclass @@ -24,6 +29,7 @@ class VoIPDevice: is_active: bool = False update_listeners: list[Callable[[VoIPDevice], None]] = field(default_factory=list) protocol: VoipDatagramProtocol | None = None + contact: SipEndpoint | None = None @callback def set_is_active(self, active: bool) -> None: @@ -80,9 +86,9 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self.config_entry = config_entry self._new_device_listeners: list[Callable[[VoIPDevice], None]] = [] self.devices: dict[str, VoIPDevice] = {} + self.device_store: VoipStore = config_entry.runtime_data - @callback - def async_setup(self) -> None: + async def async_setup(self) -> None: """Set up devices.""" for device in dr.async_entries_for_config_entry( dr.async_get(self.hass), self.config_entry.entry_id @@ -92,9 +98,13 @@ def async_setup(self) -> None: ) if voip_id is None: continue + devices_data: DeviceContacts = await self.device_store.async_load_devices() + device_data: DeviceContact | None = devices_data.get(voip_id) + _LOGGER.debug("Loaded device data for %s: %s", voip_id, device_data) self.devices[voip_id] = VoIPDevice( voip_id=voip_id, device_id=device.id, + contact=SipEndpoint(device_data.contact) if device_data else None, ) @callback @@ -185,12 +195,29 @@ def entity_migrator(entry: er.RegistryEntry) -> dict[str, Any] | None: ) if voip_device is not None: + if ( + call_info.contact_endpoint is not None + and voip_device.contact != call_info.contact_endpoint + ): + # Update VOIP device with contact information from call info + voip_device.contact = call_info.contact_endpoint + self.hass.async_create_task( + self.device_store.async_update_device( + voip_id, call_info.contact_endpoint.sip_header + ) + ) return voip_device voip_device = self.devices[voip_id] = VoIPDevice( - voip_id=voip_id, - device_id=device.id, + voip_id=voip_id, device_id=device.id, contact=call_info.contact_endpoint ) + if call_info.contact_endpoint is not None: + self.hass.async_create_task( + self.device_store.async_update_device( + voip_id, call_info.contact_endpoint.sip_header + ) + ) + for listener in self._new_device_listeners: listener(voip_device) diff --git a/homeassistant/components/voip/store.py b/homeassistant/components/voip/store.py new file mode 100644 index 0000000000000..5ceb73ae4c29d --- /dev/null +++ b/homeassistant/components/voip/store.py @@ -0,0 +1,54 @@ +"""VOIP contact storage.""" + +from dataclasses import dataclass +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import STORAGE_VER + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DeviceContact: + """Device contact data.""" + + contact: str + + +class DeviceContacts(dict[str, DeviceContact]): + """Map of device contact data.""" + + +class VoipStore(Store): + """Store for VOIP device contact information.""" + + def __init__(self, hass: HomeAssistant, storage_key: str) -> None: + """Initialize the VOIP Storage.""" + super().__init__(hass, STORAGE_VER, f"voip-{storage_key}") + + async def async_load_devices(self) -> DeviceContacts: + """Load data from store as DeviceContacts.""" + raw_data: dict[str, dict[str, str]] = await self.async_load() or {} + return self._dict_to_devices(raw_data) + + async def async_update_device(self, voip_id: str, contact_header: str) -> None: + """Update the device store with the contact information.""" + _LOGGER.debug("Saving new VOIP device %s contact %s", voip_id, contact_header) + devices_data: DeviceContacts = await self.async_load_devices() + _LOGGER.debug("devices_data: %s", devices_data) + device_data: DeviceContact | None = devices_data.get(voip_id) + if device_data is not None: + device_data.contact = contact_header + else: + devices_data[voip_id] = DeviceContact(contact_header) + await self.async_save(devices_data) + _LOGGER.debug("Saved new VOIP device contact") + + def _dict_to_devices(self, raw_data: dict[str, dict[str, str]]) -> DeviceContacts: + contacts = DeviceContacts() + for k, v in (raw_data or {}).items(): + contacts[k] = DeviceContact(**v) + return contacts diff --git a/tests/components/voip/test_devices.py b/tests/components/voip/test_devices.py index 4e2e129d4be14..d23b107a7d4f9 100644 --- a/tests/components/voip/test_devices.py +++ b/tests/components/voip/test_devices.py @@ -2,11 +2,15 @@ from __future__ import annotations +from unittest.mock import AsyncMock + import pytest from voip_utils import CallInfo +from voip_utils.sip import SipEndpoint from homeassistant.components.voip import DOMAIN from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices +from homeassistant.components.voip.store import VoipStore from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -63,6 +67,72 @@ async def test_device_registry_info_from_unknown_phone( assert device.sw_version is None +async def test_device_registry_info_update_contact( + hass: HomeAssistant, + voip_devices: VoIPDevices, + call_info: CallInfo, + device_registry: dr.DeviceRegistry, +) -> None: + """Test info in device registry.""" + voip_device = voip_devices.async_get_or_create(call_info) + assert not voip_device.async_allow_call(hass) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} + ) + assert device is not None + assert device.name == call_info.caller_endpoint.host + assert device.manufacturer == "Grandstream" + assert device.model == "HT801" + assert device.sw_version == "1.0.17.5" + + # Test we update the device if the fw updates + call_info.headers["user-agent"] = "Grandstream HT801 2.0.0.0" + call_info.contact_endpoint = SipEndpoint("Test ") + voip_device = voip_devices.async_get_or_create(call_info) + + assert voip_device.contact == SipEndpoint("Test ") + assert not voip_device.async_allow_call(hass) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} + ) + assert device.sw_version == "2.0.0.0" + + +async def test_device_load_contact( + hass: HomeAssistant, + call_info: CallInfo, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test loading contact endpoint from Store.""" + voip_id = call_info.caller_endpoint.uri + mock_store = VoipStore(hass, "test") + mock_store.async_load = AsyncMock( + return_value={voip_id: {"contact": "Test "}} + ) + + config_entry.runtime_data = mock_store + + # Initialize voip device + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, voip_id)}, + name=call_info.caller_endpoint.host, + manufacturer="Grandstream", + model="HT801", + sw_version="1.0.0.0", + configuration_url=f"http://{call_info.caller_ip}", + ) + + voip = VoIPDevices(hass, config_entry) + + await voip.async_setup() + voip_device = voip.devices.get(voip_id) + assert voip_device.contact == SipEndpoint("Test ") + + async def test_remove_device_registry_entry( hass: HomeAssistant, voip_device: VoIPDevice, From 8dba1edbe55017fd9f62301972d27ecfa0b9721b Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 14 Oct 2025 14:39:38 +0200 Subject: [PATCH 20/51] Machine container: Remove codenotary configuration (#153855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- machine/build.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/machine/build.yaml b/machine/build.yaml index 2f8aa3fe5c3e4..9926c5088fb39 100644 --- a/machine/build.yaml +++ b/machine/build.yaml @@ -5,9 +5,6 @@ build_from: armhf: "ghcr.io/home-assistant/armhf-homeassistant:" amd64: "ghcr.io/home-assistant/amd64-homeassistant:" i386: "ghcr.io/home-assistant/i386-homeassistant:" -codenotary: - signer: notary@home-assistant.io - base_image: notary@home-assistant.io cosign: base_identity: https://github.com/home-assistant/core/.* identity: https://github.com/home-assistant/core/.* From 38d0299951dedaaeecc4585183d98f56fa53a6fc Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:47:12 +0200 Subject: [PATCH 21/51] Remove URL from ViCare strings.json (#154243) --- homeassistant/components/vicare/config_flow.py | 7 +++++++ homeassistant/components/vicare/const.py | 2 ++ homeassistant/components/vicare/entity.py | 4 ++-- homeassistant/components/vicare/strings.json | 4 ++-- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index c1d4adda62a9b..1963563e0f336 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -23,6 +23,7 @@ DEFAULT_HEATING_TYPE, DOMAIN, VICARE_NAME, + VIESSMANN_DEVELOPER_PORTAL, HeatingType, ) from .utils import login @@ -70,6 +71,9 @@ async def async_step_user( return self.async_show_form( step_id="user", + description_placeholders={ + "viessmann_developer_portal": VIESSMANN_DEVELOPER_PORTAL + }, data_schema=USER_SCHEMA, errors=errors, ) @@ -102,6 +106,9 @@ async def async_step_reauth_confirm( return self.async_show_form( step_id="reauth_confirm", + description_placeholders={ + "viessmann_developer_portal": VIESSMANN_DEVELOPER_PORTAL + }, data_schema=self.add_suggested_values_to_schema( REAUTH_SCHEMA, reauth_entry.data ), diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index c874b9f173c6d..c228020753118 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -29,6 +29,8 @@ VICARE_NAME = "ViCare" VICARE_TOKEN_FILENAME = "vicare_token.save" +VIESSMANN_DEVELOPER_PORTAL = "https://app.developer.viessmann-climatesolutions.com" + CONF_CIRCUIT = "circuit" CONF_HEATING_TYPE = "heating_type" diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index f6a8fc3afafeb..fdf250a60c0eb 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from .const import DOMAIN +from .const import DOMAIN, VIESSMANN_DEVELOPER_PORTAL class ViCareEntity(Entity): @@ -49,5 +49,5 @@ def __init__( name=model, manufacturer="Viessmann", model=model, - configuration_url="https://developer.viessmann.com/", + configuration_url=VIESSMANN_DEVELOPER_PORTAL, ) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 99c78e262a65a..ee6ef6d1c75ac 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -3,7 +3,7 @@ "flow_title": "{name}", "step": { "user": { - "description": "Set up ViCare integration. To generate client ID go to https://app.developer.viessmann.com", + "description": "Set up ViCare integration.", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]", @@ -13,7 +13,7 @@ "data_description": { "username": "The email address to log in to your ViCare account.", "password": "The password to log in to your ViCare account.", - "client_id": "The ID of the API client created in the Viessmann developer portal.", + "client_id": "The ID of the API client created in the [Viessmann developer portal]({viessmann_developer_portal}).", "heating_type": "Allows to overrule the device auto detection." } }, From ae3d32073c7f446f474f0feaceaa89b8ada77a5e Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 14 Oct 2025 15:47:22 +0300 Subject: [PATCH 22/51] Move URL out of Switcher strings.json (#154240) --- homeassistant/components/switcher_kis/config_flow.py | 8 ++++++-- homeassistant/components/switcher_kis/const.py | 4 ++++ homeassistant/components/switcher_kis/strings.json | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switcher_kis/config_flow.py b/homeassistant/components/switcher_kis/config_flow.py index ee015cb1a25c1..d0803b117e268 100644 --- a/homeassistant/components/switcher_kis/config_flow.py +++ b/homeassistant/components/switcher_kis/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_TOKEN, CONF_USERNAME -from .const import DOMAIN +from .const import DOMAIN, PREREQUISITES_URL from .utils import async_discover_devices _LOGGER = logging.getLogger(__name__) @@ -77,7 +77,10 @@ async def async_step_credentials( errors["base"] = "invalid_auth" return self.async_show_form( - step_id="credentials", data_schema=CONFIG_SCHEMA, errors=errors + step_id="credentials", + data_schema=CONFIG_SCHEMA, + errors=errors, + description_placeholders={"prerequisites_url": PREREQUISITES_URL}, ) async def async_step_reauth( @@ -106,6 +109,7 @@ async def async_step_reauth_confirm( step_id="reauth_confirm", data_schema=CONFIG_SCHEMA, errors=errors, + description_placeholders={"prerequisites_url": PREREQUISITES_URL}, ) async def _create_entry(self) -> ConfigFlowResult: diff --git a/homeassistant/components/switcher_kis/const.py b/homeassistant/components/switcher_kis/const.py index 9edc69e49463b..6abf1c4431273 100644 --- a/homeassistant/components/switcher_kis/const.py +++ b/homeassistant/components/switcher_kis/const.py @@ -14,3 +14,7 @@ # Defines the maximum interval device must send an update before it marked unavailable MAX_UPDATE_INTERVAL_SEC = 30 + +PREREQUISITES_URL = ( + "https://www.home-assistant.io/integrations/switcher_kis/#prerequisites" +) diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index 5eece295aa83a..33bbdc345d33f 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -5,7 +5,7 @@ "description": "[%key:common::config_flow::description::confirm_setup%]" }, "credentials": { - "description": "Found a Switcher device that requires a token\nEnter your username and token\nFor more information see https://www.home-assistant.io/integrations/switcher_kis/#prerequisites", + "description": "Found a Switcher device that requires a token\nEnter your username and token\nFor more information see {prerequisites_url}", "data": { "username": "[%key:common::config_flow::data::username%]", "token": "[%key:common::config_flow::data::access_token%]" From a92e73ff17d88445c0847569a7a73c6536552a97 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:47:32 +0200 Subject: [PATCH 23/51] Move URL out of sfr_box strings.json (#154364) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sfr_box/config_flow.py | 8 +++++++- homeassistant/components/sfr_box/strings.json | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sfr_box/config_flow.py b/homeassistant/components/sfr_box/config_flow.py index 629f6ad291fa4..186bccbd93ba9 100644 --- a/homeassistant/components/sfr_box/config_flow.py +++ b/homeassistant/components/sfr_box/config_flow.py @@ -61,7 +61,13 @@ async def async_step_user( data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors + step_id="user", + data_schema=data_schema, + errors=errors, + description_placeholders={ + "sample_ip": "192.168.1.1", + "sample_url": "https://sfrbox.example.com", + }, ) async def async_step_choose_auth( diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 5139ec52badd9..47f4b6a7bf319 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -27,7 +27,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname, IP address, or full URL of your SFR device. e.g.: '192.168.1.1' or 'https://sfrbox.example.com'" + "host": "The hostname, IP address, or full URL of your SFR device. e.g.: `{sample_ip}` or `{sample_url}`" }, "description": "Setting the credentials is optional, but enables additional functionality." } From 7ddfcd350b52d60209fda46fb91d2c63c8491f31 Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Tue, 14 Oct 2025 15:47:50 +0300 Subject: [PATCH 24/51] Move URLs out of SABnzbd strings.json (#154333) Co-authored-by: Claude --- homeassistant/components/sabnzbd/config_flow.py | 4 ++++ homeassistant/components/sabnzbd/strings.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sabnzbd/config_flow.py b/homeassistant/components/sabnzbd/config_flow.py index ce9b0a13b18da..c7d299c825b24 100644 --- a/homeassistant/components/sabnzbd/config_flow.py +++ b/homeassistant/components/sabnzbd/config_flow.py @@ -90,4 +90,8 @@ async def async_step_user( else user_input, ), errors=errors, + description_placeholders={ + "sabnzbd_full_url_local": "http://localhost:8080", + "sabnzbd_full_url_addon": "http://a02368d7-sabnzbd:8080", + }, ) diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 601f1153b8231..e9c5b6884ab2a 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -7,7 +7,7 @@ "url": "[%key:common::config_flow::data::url%]" }, "data_description": { - "url": "The full URL, including port, of the SABnzbd server. Example: `http://localhost:8080` or `http://a02368d7-sabnzbd:8080`, if you are using the add-on.", + "url": "The full URL, including port, of the SABnzbd server. Example: `{sabnzbd_full_url_local}` or `{sabnzbd_full_url_addon}`, if you are using the add-on.", "api_key": "The API key of the SABnzbd server. This can be found in the SABnzbd web interface under Config cog (top right) > General > Security." } } From 7f5128eb154ba91b8866edd14bd45f9bdd7c1028 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:48:11 +0200 Subject: [PATCH 25/51] Add description placeholders to pyLoad config flow (#154254) --- homeassistant/components/pyload/config_flow.py | 7 ++++++- homeassistant/components/pyload/strings.json | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index 1a1481f9c264f..4a07192193818 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -72,6 +72,7 @@ ), } ) +PLACEHOLDER = {"example_url": "https://example.com:8000/path"} async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> None: @@ -134,6 +135,7 @@ async def async_step_user( STEP_USER_DATA_SCHEMA, user_input ), errors=errors, + description_placeholders=PLACEHOLDER, ) async def async_step_reauth( @@ -211,7 +213,10 @@ async def async_step_reconfigure( STEP_USER_DATA_SCHEMA, suggested_values, ), - description_placeholders={CONF_NAME: reconfig_entry.data[CONF_USERNAME]}, + description_placeholders={ + CONF_NAME: reconfig_entry.data[CONF_USERNAME], + **PLACEHOLDER, + }, errors=errors, ) diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index 66435fd280618..05e5c47fee4cf 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -9,7 +9,7 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "url": "Specify the full URL of your pyLoad web interface, including the protocol (HTTP or HTTPS), hostname or IP address, port (pyLoad uses 8000 by default), and any path prefix if applicable.\nExample: `https://example.com:8000/path`", + "url": "Specify the full URL of your pyLoad web interface, including the protocol (HTTP or HTTPS), hostname or IP address, port (pyLoad uses 8000 by default), and any path prefix if applicable.\nExample: `{example_url}`", "username": "The username used to access the pyLoad instance.", "password": "The password associated with the pyLoad account.", "verify_ssl": "If checked, the SSL certificate will be validated to ensure a secure connection." From 6e515d4829a451d80bdb25dd8f33fe8caf36ddb8 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 14 Oct 2025 13:48:36 +0100 Subject: [PATCH 26/51] Move URL out of Mealie strings.json (#154230) --- homeassistant/components/mealie/config_flow.py | 5 +++++ homeassistant/components/mealie/strings.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mealie/config_flow.py b/homeassistant/components/mealie/config_flow.py index 25e46ec6262e4..a88347894f57c 100644 --- a/homeassistant/components/mealie/config_flow.py +++ b/homeassistant/components/mealie/config_flow.py @@ -32,6 +32,8 @@ } ) +EXAMPLE_URL = "http://192.168.1.123:1234" + class MealieConfigFlow(ConfigFlow, domain=DOMAIN): """Mealie config flow.""" @@ -93,6 +95,7 @@ async def async_step_user( step_id="user", data_schema=USER_SCHEMA, errors=errors, + description_placeholders={"example_url": EXAMPLE_URL}, ) async def async_step_reauth( @@ -123,6 +126,7 @@ async def async_step_reauth_confirm( step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, errors=errors, + description_placeholders={"example_url": EXAMPLE_URL}, ) async def async_step_reconfigure( @@ -151,6 +155,7 @@ async def async_step_reconfigure( step_id="reconfigure", data_schema=USER_SCHEMA, errors=errors, + description_placeholders={"example_url": EXAMPLE_URL}, ) async def async_step_hassio( diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index 8e51da6d7d11a..a8a750da0ae98 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -1,6 +1,6 @@ { "common": { - "data_description_host": "The URL of your Mealie instance, for example, http://192.168.1.123:1234", + "data_description_host": "The URL of your Mealie instance, for example, {example_url}.", "data_description_api_token": "The API token of your Mealie instance from your user profile within Mealie.", "data_description_verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates." }, From b517774be09f49f30eac74a56e0a1da6300fb0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Gr=C3=BCndel?= <45913260+ogruendel@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:49:45 +0200 Subject: [PATCH 27/51] Move Ecobee authorization URL out of strings.json (#154332) --- homeassistant/components/ecobee/config_flow.py | 5 ++++- homeassistant/components/ecobee/strings.json | 2 +- tests/components/ecobee/test_config_flow.py | 10 ++++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py index ac834e92ca872..9c9d85223614d 100644 --- a/homeassistant/components/ecobee/config_flow.py +++ b/homeassistant/components/ecobee/config_flow.py @@ -61,5 +61,8 @@ async def async_step_authorize( return self.async_show_form( step_id="authorize", errors=errors, - description_placeholders={"pin": self._ecobee.pin}, + description_placeholders={ + "pin": self._ecobee.pin, + "auth_url": "https://www.ecobee.com/consumerportal/index.html", + }, ) diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index b5cec2858111c..7a1e4e0f8365e 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -8,7 +8,7 @@ } }, "authorize": { - "description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with PIN code:\n\n{pin}\n\nThen, select **Submit**." + "description": "Please authorize this app at {auth_url} with PIN code:\n\n{pin}\n\nThen, select **Submit**." } }, "error": { diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py index 9edb1d4233141..8ecb71ddfe076 100644 --- a/tests/components/ecobee/test_config_flow.py +++ b/tests/components/ecobee/test_config_flow.py @@ -48,7 +48,10 @@ async def test_pin_request_succeeds(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authorize" - assert result["description_placeholders"] == {"pin": "test-pin"} + assert result["description_placeholders"] == { + "pin": "test-pin", + "auth_url": "https://www.ecobee.com/consumerportal/index.html", + } async def test_pin_request_fails(hass: HomeAssistant) -> None: @@ -107,4 +110,7 @@ async def test_token_request_fails(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authorize" assert result["errors"]["base"] == "token_request_failed" - assert result["description_placeholders"] == {"pin": "test-pin"} + assert result["description_placeholders"] == { + "pin": "test-pin", + "auth_url": "https://www.ecobee.com/consumerportal/index.html", + } From 13e828038dd8a8500a3a494507e8dd53d8082792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Gr=C3=BCndel?= <45913260+ogruendel@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:50:12 +0200 Subject: [PATCH 28/51] Move developer url out of strings.json for coinbase setup flow (#154339) --- homeassistant/components/coinbase/config_flow.py | 2 ++ homeassistant/components/coinbase/strings.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 6aad3a81d1776..a79bd2493e1ef 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -166,6 +166,7 @@ async def async_step_reauth_confirm( data_schema=STEP_USER_DATA_SCHEMA, description_placeholders={ "account_name": self.reauth_entry.title, + "developer_url": "https://www.coinbase.com/developer-platform", }, errors=errors, ) @@ -195,6 +196,7 @@ async def async_step_reauth_confirm( data_schema=STEP_USER_DATA_SCHEMA, description_placeholders={ "account_name": self.reauth_entry.title, + "developer_url": "https://www.coinbase.com/developer-platform", }, errors=errors, ) diff --git a/homeassistant/components/coinbase/strings.json b/homeassistant/components/coinbase/strings.json index b0774baf403d0..6c37dd5cdc49e 100644 --- a/homeassistant/components/coinbase/strings.json +++ b/homeassistant/components/coinbase/strings.json @@ -11,7 +11,7 @@ }, "reauth_confirm": { "title": "Update Coinbase API credentials", - "description": "Your current Coinbase API key appears to be for the deprecated v2 API. Please reconfigure with a new API key created for the v3 API. Visit https://www.coinbase.com/developer-platform to create new credentials for {account_name}.", + "description": "Your current Coinbase API key appears to be for the deprecated v2 API. Please reconfigure with a new API key created for the v3 API. Visit the [Developer Platform]({developer_url}) to create new credentials for {account_name}.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "api_token": "API secret" From 26fec2fdcc8cd435f602c42e34ae185458f27b39 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 14 Oct 2025 14:50:28 +0200 Subject: [PATCH 29/51] Move Electricity Maps url out of strings.json (#154284) --- homeassistant/components/co2signal/config_flow.py | 6 ++++++ homeassistant/components/co2signal/quality_scale.yaml | 1 - homeassistant/components/co2signal/strings.json | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 00acd2829a66d..2401121b76e05 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -38,6 +38,10 @@ _LOGGER = logging.getLogger(__name__) +DESCRIPTION_PLACEHOLDER = { + "register_link": "https://electricitymaps.com/free-tier", +} + class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Co2signal.""" @@ -70,6 +74,7 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=data_schema, + description_placeholders=DESCRIPTION_PLACEHOLDER, ) data = {CONF_API_KEY: user_input[CONF_API_KEY]} @@ -179,4 +184,5 @@ async def _validate_and_create( step_id=step_id, data_schema=data_schema, errors=errors, + description_placeholders=DESCRIPTION_PLACEHOLDER, ) diff --git a/homeassistant/components/co2signal/quality_scale.yaml b/homeassistant/components/co2signal/quality_scale.yaml index d2ddb091e5ef7..3d518092633de 100644 --- a/homeassistant/components/co2signal/quality_scale.yaml +++ b/homeassistant/components/co2signal/quality_scale.yaml @@ -18,7 +18,6 @@ rules: status: todo comment: | The config flow misses data descriptions. - Remove URLs from data descriptions, they should be replaced with placeholders. Make use of Electricity Maps zone keys in country code as dropdown. Make use of location selector for coordinates. dependency-transparency: done diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index a4ec916bd4282..69925f5899385 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -6,7 +6,7 @@ "location": "[%key:common::config_flow::data::location%]", "api_key": "[%key:common::config_flow::data::access_token%]" }, - "description": "Visit https://electricitymaps.com/free-tier to request a token." + "description": "Visit the [Electricity Maps page]({register_link}) to request a token." }, "coordinates": { "data": { From 1237010b4a4c13f0578dd4f08c5137ea5322f259 Mon Sep 17 00:00:00 2001 From: Mateusz Date: Tue, 14 Oct 2025 14:50:38 +0200 Subject: [PATCH 30/51] auth: add required issuer to OAuth (#152385) --- homeassistant/components/auth/login_flow.py | 27 ++++++++++++--------- tests/components/auth/test_login_flow.py | 7 +++++- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 675c2d10fea37..a425c123e3ef5 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -136,17 +136,22 @@ async def get(self, request: web.Request) -> web.Response: url_prefix = get_url(hass, require_current_request=True) except NoURLAvailableError: url_prefix = "" - return self.json( - { - "authorization_endpoint": f"{url_prefix}/auth/authorize", - "token_endpoint": f"{url_prefix}/auth/token", - "revocation_endpoint": f"{url_prefix}/auth/revoke", - "response_types_supported": ["code"], - "service_documentation": ( - "https://developers.home-assistant.io/docs/auth_api" - ), - } - ) + + metadata = { + "authorization_endpoint": f"{url_prefix}/auth/authorize", + "token_endpoint": f"{url_prefix}/auth/token", + "revocation_endpoint": f"{url_prefix}/auth/revoke", + "response_types_supported": ["code"], + "service_documentation": ( + "https://developers.home-assistant.io/docs/auth_api" + ), + } + + # Add issuer only when we have a valid base URL (RFC 8414 compliance) + if url_prefix: + metadata["issuer"] = url_prefix + + return self.json(metadata) class AuthProvidersView(HomeAssistantView): diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index f7d20687c926f..10d379427db62 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -374,7 +374,7 @@ async def test_login_exist_user_ip_changes( @pytest.mark.usefixtures("current_request_with_host") # Has example.com host @pytest.mark.parametrize( - ("config", "expected_url_prefix"), + ("config", "expected_url_prefix", "extra_response_data"), [ ( { @@ -383,6 +383,7 @@ async def test_login_exist_user_ip_changes( "external_url": "https://example.com", }, "https://example.com", + {"issuer": "https://example.com"}, ), ( { @@ -391,6 +392,7 @@ async def test_login_exist_user_ip_changes( "external_url": "https://other.com", }, "https://example.com", + {"issuer": "https://example.com"}, ), ( { @@ -399,6 +401,7 @@ async def test_login_exist_user_ip_changes( "external_url": "https://again.com", }, "", + {}, ), ], ids=["external_url", "internal_url", "no_match"], @@ -408,6 +411,7 @@ async def test_well_known_auth_info( aiohttp_client: ClientSessionGenerator, config: dict[str, str], expected_url_prefix: str, + extra_response_data: dict[str, str], ) -> None: """Test the well-known OAuth authorization server endpoint with different URL configurations.""" await async_process_ha_core_config(hass, config) @@ -417,6 +421,7 @@ async def test_well_known_auth_info( ) assert resp.status == 200 assert await resp.json() == { + **extra_response_data, "authorization_endpoint": f"{expected_url_prefix}/auth/authorize", "token_endpoint": f"{expected_url_prefix}/auth/token", "revocation_endpoint": f"{expected_url_prefix}/auth/revoke", From d2af875d631e165ddb6dea1537ded42a312fe70a Mon Sep 17 00:00:00 2001 From: DannyS95 Date: Tue, 14 Oct 2025 14:01:39 +0100 Subject: [PATCH 31/51] Move igloohome API access URL into constant placeholders (#154430) --- homeassistant/components/igloohome/config_flow.py | 7 +++++-- homeassistant/components/igloohome/const.py | 1 + homeassistant/components/igloohome/strings.json | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/igloohome/config_flow.py b/homeassistant/components/igloohome/config_flow.py index a1d84900a033a..89d072a128aa6 100644 --- a/homeassistant/components/igloohome/config_flow.py +++ b/homeassistant/components/igloohome/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .const import API_ACCESS_URL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -57,5 +57,8 @@ async def async_step_user( ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={"api_access_url": API_ACCESS_URL}, ) diff --git a/homeassistant/components/igloohome/const.py b/homeassistant/components/igloohome/const.py index 379c3bfbc1a49..759bd6ffb5dca 100644 --- a/homeassistant/components/igloohome/const.py +++ b/homeassistant/components/igloohome/const.py @@ -1,3 +1,4 @@ """Constants for the igloohome integration.""" DOMAIN = "igloohome" +API_ACCESS_URL = "https://access.igloocompany.co/api-access" diff --git a/homeassistant/components/igloohome/strings.json b/homeassistant/components/igloohome/strings.json index 463964c58edd8..9a72ad1454889 100644 --- a/homeassistant/components/igloohome/strings.json +++ b/homeassistant/components/igloohome/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Copy & paste your [API access credentials](https://access.igloocompany.co/api-access) to give Home Assistant access to your account.", + "description": "Copy & paste your [API access credentials]({api_access_url}) to give Home Assistant access to your account.", "data": { "client_id": "Client ID", "client_secret": "Client secret" From 416f6b922ca9e18cdd7fa6e92c62e65b0cadd2d3 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 14 Oct 2025 15:05:10 +0200 Subject: [PATCH 32/51] Add reconfigure flow to airOS (#154447) --- homeassistant/components/airos/config_flow.py | 60 ++++++- homeassistant/components/airos/strings.json | 23 +++ tests/components/airos/test_config_flow.py | 151 +++++++++++++++++- 3 files changed, 231 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py index 0115bf0939b10..52ad0078d1641 100644 --- a/homeassistant/components/airos/config_flow.py +++ b/homeassistant/components/airos/config_flow.py @@ -15,7 +15,12 @@ ) import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -119,7 +124,7 @@ async def _validate_and_get_device_info( else: await self.async_set_unique_id(airos_data.derived.mac) - if self.source == SOURCE_REAUTH: + if self.source in [SOURCE_REAUTH, SOURCE_RECONFIGURE]: self._abort_if_unique_id_mismatch() else: self._abort_if_unique_id_configured() @@ -164,3 +169,54 @@ async def async_step_reauth_confirm( ), errors=self.errors, ) + + async def async_step_reconfigure( + self, + user_input: Mapping[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle reconfiguration of airOS.""" + self.errors = {} + entry = self._get_reconfigure_entry() + current_data = entry.data + + if user_input is not None: + validate_data = {**current_data, **user_input} + if await self._validate_and_get_device_info(config_data=validate_data): + return self.async_update_reload_and_abort( + entry, + data_updates=validate_data, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Required( + CONF_SSL, + default=current_data[SECTION_ADVANCED_SETTINGS][ + CONF_SSL + ], + ): bool, + vol.Required( + CONF_VERIFY_SSL, + default=current_data[SECTION_ADVANCED_SETTINGS][ + CONF_VERIFY_SSL + ], + ): bool, + } + ), + {"collapsed": True}, + ), + } + ), + errors=self.errors, + ) diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json index 8630ee8c7af8f..46c4e287321bd 100644 --- a/homeassistant/components/airos/strings.json +++ b/homeassistant/components/airos/strings.json @@ -10,6 +10,27 @@ "password": "[%key:component::airos::config::step::user::data_description::password%]" } }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::airos::config::step::user::data_description::password%]" + }, + "sections": { + "advanced_settings": { + "name": "[%key:component::airos::config::step::user::sections::advanced_settings::name%]", + "data": { + "ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::ssl%]", + "verify_ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::verify_ssl%]" + } + } + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", @@ -23,6 +44,7 @@ }, "sections": { "advanced_settings": { + "name": "Advanced settings", "data": { "ssl": "Use HTTPS", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" @@ -44,6 +66,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unique_id_mismatch": "Re-authentication should be used for the same device not a new one" } }, diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py index 8f668166ea667..59aae6ad4ca3e 100644 --- a/tests/components/airos/test_config_flow.py +++ b/tests/components/airos/test_config_flow.py @@ -11,7 +11,7 @@ import pytest from homeassistant.components.airos.const import DOMAIN, SECTION_ADVANCED_SETTINGS -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -26,6 +26,7 @@ NEW_PASSWORD = "new_password" REAUTH_STEP = "reauth_confirm" +RECONFIGURE_STEP = "reconfigure" MOCK_CONFIG = { CONF_HOST: "1.1.1.1", @@ -253,3 +254,151 @@ async def test_reauth_unique_id_mismatch( updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) assert updated_entry.data[CONF_PASSWORD] != NEW_PASSWORD + + +async def test_successful_reconfigure( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful reconfigure.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == RECONFIGURE_STEP + + user_input = { + CONF_PASSWORD: NEW_PASSWORD, + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: True, + CONF_VERIFY_SSL: True, + }, + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD + assert updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True + assert updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is True + + assert updated_entry.data[CONF_HOST] == MOCK_CONFIG[CONF_HOST] + assert updated_entry.data[CONF_USERNAME] == MOCK_CONFIG[CONF_USERNAME] + + +@pytest.mark.parametrize( + ("reconfigure_exception", "expected_error"), + [ + (AirOSConnectionAuthenticationError, "invalid_auth"), + (AirOSDeviceConnectionError, "cannot_connect"), + (AirOSKeyDataMissingError, "key_data_missing"), + (Exception, "unknown"), + ], + ids=[ + "invalid_auth", + "cannot_connect", + "key_data_missing", + "unknown", + ], +) +async def test_reconfigure_flow_failure( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, + reconfigure_exception: Exception, + expected_error: str, +) -> None: + """Test reconfigure from start (failure) to finish (success).""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + + user_input = { + CONF_PASSWORD: NEW_PASSWORD, + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: True, + CONF_VERIFY_SSL: True, + }, + } + + mock_airos_client.login.side_effect = reconfigure_exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == RECONFIGURE_STEP + assert result["errors"] == {"base": expected_error} + + mock_airos_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD + + +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration failure when the unique ID changes.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + flow_id = result["flow_id"] + + mock_airos_client.status.return_value.derived.mac = "FF:23:45:67:89:AB" + + user_input = { + CONF_PASSWORD: NEW_PASSWORD, + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: True, + CONF_VERIFY_SSL: True, + }, + } + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input=user_input, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_PASSWORD] == MOCK_CONFIG[CONF_PASSWORD] + assert ( + updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] + == MOCK_CONFIG[SECTION_ADVANCED_SETTINGS][CONF_SSL] + ) From c6e334ca6038330d0e1df1d3e4a94a7868e84af8 Mon Sep 17 00:00:00 2001 From: David Recordon Date: Tue, 14 Oct 2025 06:09:04 -0700 Subject: [PATCH 33/51] Skip adding Control4 rooms with no audio/video sources as media player devices (#154348) Co-authored-by: Joostlek --- .../components/control4/media_player.py | 9 ++ tests/components/control4/__init__.py | 17 +++ tests/components/control4/conftest.py | 103 ++++++++++++++++++ .../control4/fixtures/director_all_items.json | 18 +++ .../control4/fixtures/ui_configuration.json | 15 +++ .../control4/snapshots/test_media_player.ambr | 60 ++++++++++ tests/components/control4/test_config_flow.py | 33 +++--- .../components/control4/test_media_player.py | 26 +++++ 8 files changed, 266 insertions(+), 15 deletions(-) create mode 100644 tests/components/control4/conftest.py create mode 100644 tests/components/control4/fixtures/director_all_items.json create mode 100644 tests/components/control4/fixtures/ui_configuration.json create mode 100644 tests/components/control4/snapshots/test_media_player.ambr create mode 100644 tests/components/control4/test_media_player.py diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py index 824ce431aea1e..7fb271d57f29a 100644 --- a/homeassistant/components/control4/media_player.py +++ b/homeassistant/components/control4/media_player.py @@ -148,6 +148,15 @@ async def async_update_data() -> dict[int, dict[str, Any]]: source_type={dev_type}, idx=dev_id, name=name ) + # Skip rooms with no audio/video sources + if not sources: + _LOGGER.debug( + "Skipping room '%s' (ID: %s) - no audio/video sources found", + room.get("name"), + room_id, + ) + continue + try: hidden = room["roomHidden"] entity_list.append( diff --git a/tests/components/control4/__init__.py b/tests/components/control4/__init__.py index 8995968d5ddf6..5c937acca4a00 100644 --- a/tests/components/control4/__init__.py +++ b/tests/components/control4/__init__.py @@ -1 +1,18 @@ """Tests for the Control4 integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Set up the Control4 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 diff --git a/tests/components/control4/conftest.py b/tests/components/control4/conftest.py new file mode 100644 index 0000000000000..f8e174b9d9579 --- /dev/null +++ b/tests/components/control4/conftest.py @@ -0,0 +1,103 @@ +"""Common fixtures for the Control4 tests.""" + +from collections.abc import AsyncGenerator, Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.control4.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry, load_fixture + +MOCK_HOST = "192.168.1.100" +MOCK_USERNAME = "test-username" +MOCK_PASSWORD = "test-password" +MOCK_CONTROLLER_UNIQUE_ID = "control4_test_123" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: MOCK_HOST, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + "controller_unique_id": MOCK_CONTROLLER_UNIQUE_ID, + }, + unique_id="00:aa:00:aa:00:aa", + ) + + +@pytest.fixture +def mock_c4_account() -> Generator[MagicMock]: + """Mock a Control4 Account client.""" + with patch( + "homeassistant.components.control4.C4Account", autospec=True + ) as mock_account_class: + mock_account = mock_account_class.return_value + mock_account.getAccountBearerToken = AsyncMock() + mock_account.getAccountControllers = AsyncMock( + return_value={"href": "https://example.com"} + ) + mock_account.getDirectorBearerToken = AsyncMock(return_value={"token": "test"}) + mock_account.getControllerOSVersion = AsyncMock(return_value="3.2.0") + yield mock_account + + +@pytest.fixture +def mock_c4_director() -> Generator[MagicMock]: + """Mock a Control4 Director client.""" + with patch( + "homeassistant.components.control4.C4Director", autospec=True + ) as mock_director_class: + mock_director = mock_director_class.return_value + # Default: Multi-room setup (room with sources, room without sources) + # Note: The API returns JSON strings, so we load fixtures as strings + mock_director.getAllItemInfo = AsyncMock( + return_value=load_fixture("director_all_items.json", DOMAIN) + ) + mock_director.getUiConfiguration = AsyncMock( + return_value=load_fixture("ui_configuration.json", DOMAIN) + ) + yield mock_director + + +@pytest.fixture +def mock_update_variables() -> Generator[AsyncMock]: + """Mock the update_variables_for_config_entry function.""" + + async def _mock_update_variables(*args, **kwargs): + return { + 1: { + "POWER_STATE": True, + "CURRENT_VOLUME": 50, + "IS_MUTED": False, + "CURRENT_VIDEO_DEVICE": 100, + "CURRENT MEDIA INFO": {}, + "PLAYING": False, + "PAUSED": False, + "STOPPED": False, + } + } + + with patch( + "homeassistant.components.control4.media_player.update_variables_for_config_entry", + new=_mock_update_variables, + ) as mock_update: + yield mock_update + + +@pytest.fixture +def platforms() -> list[str]: + """Platforms which should be loaded during the test.""" + return ["media_player"] + + +@pytest.fixture(autouse=True) +async def mock_patch_platforms(platforms: list[str]) -> AsyncGenerator[None]: + """Fixture to set up platforms for tests.""" + with patch("homeassistant.components.control4.PLATFORMS", platforms): + yield diff --git a/tests/components/control4/fixtures/director_all_items.json b/tests/components/control4/fixtures/director_all_items.json new file mode 100644 index 0000000000000..40e44c1178b90 --- /dev/null +++ b/tests/components/control4/fixtures/director_all_items.json @@ -0,0 +1,18 @@ +[ + { + "id": 1, + "typeName": "room", + "name": "Living Room", + "roomHidden": false + }, + { + "id": 2, + "typeName": "room", + "name": "Thermostat Room", + "roomHidden": false + }, + { + "id": 100, + "name": "TV" + } +] diff --git a/tests/components/control4/fixtures/ui_configuration.json b/tests/components/control4/fixtures/ui_configuration.json new file mode 100644 index 0000000000000..67f33b2318f87 --- /dev/null +++ b/tests/components/control4/fixtures/ui_configuration.json @@ -0,0 +1,15 @@ +{ + "experiences": [ + { + "room_id": 1, + "type": "watch", + "sources": { + "source": [ + { + "id": 100 + } + ] + } + } + ] +} diff --git a/tests/components/control4/snapshots/test_media_player.ambr b/tests/components/control4/snapshots/test_media_player.ambr new file mode 100644 index 0000000000000..63ea93b7859c8 --- /dev/null +++ b/tests/components/control4/snapshots/test_media_player.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_media_player_with_and_without_sources[media_player.living_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'TV', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.living_room', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'control4', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_with_and_without_sources[media_player.living_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tv', + 'friendly_name': 'Living Room', + 'is_volume_muted': False, + 'source_list': list([ + 'TV', + ]), + 'supported_features': , + 'volume_level': 0.5, + }), + 'context': , + 'entity_id': 'media_player.living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/control4/test_config_flow.py b/tests/components/control4/test_config_flow.py index 9a1b392f61ccf..edab8c271647b 100644 --- a/tests/components/control4/test_config_flow.py +++ b/tests/components/control4/test_config_flow.py @@ -17,6 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME + from tests.common import MockConfigEntry @@ -69,9 +71,9 @@ async def test_form(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_HOST: MOCK_HOST, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, }, ) await hass.async_block_till_done() @@ -79,11 +81,12 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "control4_model_00AA00AA00AA" assert result2["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_HOST: MOCK_HOST, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, "controller_unique_id": "control4_model_00AA00AA00AA", } + assert result2["result"].unique_id == "00:aa:00:aa:00:aa" assert len(mock_setup_entry.mock_calls) == 1 @@ -100,9 +103,9 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_HOST: MOCK_HOST, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, }, ) @@ -123,9 +126,9 @@ async def test_form_unexpected_exception(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_HOST: MOCK_HOST, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, }, ) @@ -152,9 +155,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_HOST: MOCK_HOST, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, }, ) diff --git a/tests/components/control4/test_media_player.py b/tests/components/control4/test_media_player.py new file mode 100644 index 0000000000000..8b08a9ee65f4b --- /dev/null +++ b/tests/components/control4/test_media_player.py @@ -0,0 +1,26 @@ +"""Test Control4 Media Player.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_c4_account", "mock_c4_director", "mock_update_variables") +async def test_media_player_with_and_without_sources( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that rooms with sources create entities and rooms without are skipped.""" + # The default mock_c4_director fixture provides multi-room data: + # Room 1 has video source, Room 2 has no sources (thermostat-only room) + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From c0fc7b66f0d971be0a6db17aadea939e6e16c4a6 Mon Sep 17 00:00:00 2001 From: Anuj Soni <41353742+sonianuj287@users.noreply.github.com> Date: Tue, 14 Oct 2025 18:41:47 +0530 Subject: [PATCH 34/51] Move translatable URLs out of strings.json for huawei lte (#154368) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/huawei_lte/config_flow.py | 14 +++++++++++++- homeassistant/components/huawei_lte/strings.json | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 002f19bc9e06a..120d96e7d7859 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -112,6 +112,9 @@ async def _async_show_user_form( } ), errors=errors or {}, + description_placeholders={ + "sample_ip": "http://192.168.X.1", + }, ) async def _async_show_reauth_form( @@ -132,6 +135,9 @@ async def _async_show_reauth_form( } ), errors=errors or {}, + description_placeholders={ + "sample_ip": "http://192.168.X.1", + }, ) async def _connect( @@ -406,4 +412,10 @@ async def async_step_init( ): bool, } ) - return self.async_show_form(step_id="init", data_schema=data_schema) + return self.async_show_form( + step_id="init", + data_schema=data_schema, + description_placeholders={ + "sample_ip": "http://192.168.X.1", + }, + ) diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 2845338b9cfd7..f9b968ed70162 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -41,7 +41,7 @@ }, "data_description": { "password": "Password for accessing the router's API. Typically, the same as the one used for the router's web interface.", - "url": "Base URL to the API of the router. Typically, something like `http://192.168.X.1`. This is the beginning of the location shown in a browser when accessing the router's web interface.", + "url": "Base URL to the API of the router. Typically, something like `{sample_ip}`. This is the beginning of the location shown in a browser when accessing the router's web interface.", "username": "Username for accessing the router's API. Typically, the same as the one used for the router's web interface. Usually, either `admin`, or left empty (recommended if that works).", "verify_ssl": "Whether to verify the SSL certificate of the router when accessing it. Applicable only if the router is accessed via HTTPS." }, From 2812d7c7125ba160e56e38e5552ef35730e49893 Mon Sep 17 00:00:00 2001 From: Heindrich Paul Date: Tue, 14 Oct 2025 15:21:09 +0200 Subject: [PATCH 35/51] Add the coordinator pattern to the NS integration (#154149) Signed-off-by: Heindrich Paul Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .../nederlandse_spoorwegen/__init__.py | 46 ++-- .../nederlandse_spoorwegen/config_flow.py | 8 +- .../nederlandse_spoorwegen/const.py | 11 +- .../nederlandse_spoorwegen/coordinator.py | 212 +++++++++++++++ .../nederlandse_spoorwegen/sensor.py | 248 ++++++------------ .../nederlandse_spoorwegen/conftest.py | 27 +- .../nederlandse_spoorwegen/const.py | 12 + .../snapshots/test_init.ambr | 63 +++++ .../snapshots/test_sensor.ambr | 72 +++++ .../test_config_flow.py | 6 +- .../nederlandse_spoorwegen/test_init.py | 30 +++ .../nederlandse_spoorwegen/test_sensor.py | 85 ++++++ 12 files changed, 617 insertions(+), 203 deletions(-) create mode 100644 homeassistant/components/nederlandse_spoorwegen/coordinator.py create mode 100644 tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr create mode 100644 tests/components/nederlandse_spoorwegen/test_init.py diff --git a/homeassistant/components/nederlandse_spoorwegen/__init__.py b/homeassistant/components/nederlandse_spoorwegen/__init__.py index 9f7177f743268..f241f1e4cf753 100644 --- a/homeassistant/components/nederlandse_spoorwegen/__init__.py +++ b/homeassistant/components/nederlandse_spoorwegen/__init__.py @@ -4,45 +4,39 @@ import logging -from ns_api import NSAPI, RequestParametersError -import requests - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -_LOGGER = logging.getLogger(__name__) +from .const import SUBENTRY_TYPE_ROUTE +from .coordinator import NSConfigEntry, NSDataUpdateCoordinator +_LOGGER = logging.getLogger(__name__) -type NSConfigEntry = ConfigEntry[NSAPI] PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: NSConfigEntry) -> bool: """Set up Nederlandse Spoorwegen from a config entry.""" - api_key = entry.data[CONF_API_KEY] - - client = NSAPI(api_key) - - try: - await hass.async_add_executor_job(client.get_stations) - except ( - requests.exceptions.ConnectionError, - requests.exceptions.HTTPError, - ) as error: - _LOGGER.error("Could not connect to the internet: %s", error) - raise ConfigEntryNotReady from error - except RequestParametersError as error: - _LOGGER.error("Could not fetch stations, please check configuration: %s", error) - raise ConfigEntryNotReady from error - - entry.runtime_data = client + coordinators: dict[str, NSDataUpdateCoordinator] = {} + + # Set up coordinators for all existing routes + for subentry_id, subentry in entry.subentries.items(): + if subentry.subentry_type == SUBENTRY_TYPE_ROUTE: + coordinator = NSDataUpdateCoordinator( + hass, + entry, + subentry_id, + subentry, + ) + coordinators[subentry_id] = coordinator + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinators entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True diff --git a/homeassistant/components/nederlandse_spoorwegen/config_flow.py b/homeassistant/components/nederlandse_spoorwegen/config_flow.py index f614e41a9593b..6173d4c53ea47 100644 --- a/homeassistant/components/nederlandse_spoorwegen/config_flow.py +++ b/homeassistant/components/nederlandse_spoorwegen/config_flow.py @@ -21,7 +21,7 @@ ConfigSubentryFlow, SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import callback from homeassistant.helpers.selector import ( SelectOptionDict, @@ -32,12 +32,12 @@ from .const import ( CONF_FROM, - CONF_NAME, CONF_ROUTES, CONF_TIME, CONF_TO, CONF_VIA, DOMAIN, + INTEGRATION_TITLE, ) _LOGGER = logging.getLogger(__name__) @@ -68,7 +68,7 @@ async def async_step_user( errors["base"] = "unknown" if not errors: return self.async_create_entry( - title="Nederlandse Spoorwegen", + title=INTEGRATION_TITLE, data={CONF_API_KEY: user_input[CONF_API_KEY]}, ) return self.async_show_form( @@ -113,7 +113,7 @@ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResu ) return self.async_create_entry( - title="Nederlandse Spoorwegen", + title=INTEGRATION_TITLE, data={CONF_API_KEY: import_data[CONF_API_KEY]}, subentries=subentries, ) diff --git a/homeassistant/components/nederlandse_spoorwegen/const.py b/homeassistant/components/nederlandse_spoorwegen/const.py index 3c350ed22ae90..e3af02d12a0f6 100644 --- a/homeassistant/components/nederlandse_spoorwegen/const.py +++ b/homeassistant/components/nederlandse_spoorwegen/const.py @@ -1,13 +1,22 @@ """Constants for the Nederlandse Spoorwegen integration.""" +from datetime import timedelta +from zoneinfo import ZoneInfo + DOMAIN = "nederlandse_spoorwegen" +INTEGRATION_TITLE = "Nederlandse Spoorwegen" +SUBENTRY_TYPE_ROUTE = "route" +ROUTE_MODEL = "Route" +# Europe/Amsterdam timezone for Dutch rail API expectations +AMS_TZ = ZoneInfo("Europe/Amsterdam") +# Update every 2 minutes +SCAN_INTERVAL = timedelta(minutes=2) CONF_ROUTES = "routes" CONF_FROM = "from" CONF_TO = "to" CONF_VIA = "via" CONF_TIME = "time" -CONF_NAME = "name" # Attribute and schema keys ATTR_ROUTE = "route" diff --git a/homeassistant/components/nederlandse_spoorwegen/coordinator.py b/homeassistant/components/nederlandse_spoorwegen/coordinator.py new file mode 100644 index 0000000000000..0930915d69a93 --- /dev/null +++ b/homeassistant/components/nederlandse_spoorwegen/coordinator.py @@ -0,0 +1,212 @@ +"""DataUpdateCoordinator for Nederlandse Spoorwegen.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import logging + +from ns_api import NSAPI, Trip +from requests.exceptions import ConnectionError, HTTPError, Timeout + +from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import ( + AMS_TZ, + CONF_FROM, + CONF_TIME, + CONF_TO, + CONF_VIA, + DOMAIN, + SCAN_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +def _now_nl() -> datetime: + """Return current time in Europe/Amsterdam timezone.""" + return dt_util.now(AMS_TZ) + + +type NSConfigEntry = ConfigEntry[dict[str, NSDataUpdateCoordinator]] + + +@dataclass +class NSRouteResult: + """Data class for Nederlandse Spoorwegen API results.""" + + trips: list[Trip] + first_trip: Trip | None = None + next_trip: Trip | None = None + + +class NSDataUpdateCoordinator(DataUpdateCoordinator[NSRouteResult]): + """Class to manage fetching Nederlandse Spoorwegen data from the API for a single route.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: NSConfigEntry, + route_id: str, + subentry: ConfigSubentry, + ) -> None: + """Initialize the coordinator for a specific route.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_{route_id}", + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + ) + self.id = route_id + self.nsapi = NSAPI(config_entry.data[CONF_API_KEY]) + self.name = subentry.data[CONF_NAME] + self.departure = subentry.data[CONF_FROM] + self.destination = subentry.data[CONF_TO] + self.via = subentry.data.get(CONF_VIA) + self.departure_time = subentry.data.get(CONF_TIME) # str | None + + async def _async_update_data(self) -> NSRouteResult: + """Fetch data from NS API for this specific route.""" + trips: list[Trip] = [] + first_trip: Trip | None = None + next_trip: Trip | None = None + try: + trips = await self._get_trips( + self.departure, + self.destination, + self.via, + departure_time=self.departure_time, + ) + + except (ConnectionError, Timeout, HTTPError, ValueError) as err: + # Surface API failures to Home Assistant so the entities become unavailable + raise UpdateFailed(f"API communication error: {err}") from err + + # Filter out trips that have already departed (trips are already sorted) + future_trips = self._remove_trips_in_the_past(trips) + + # Process trips to find current and next departure + first_trip, next_trip = self._get_first_and_next_trips(future_trips) + + return NSRouteResult( + trips=trips, + first_trip=first_trip, + next_trip=next_trip, + ) + + def _get_time_from_route(self, time_str: str | None) -> str: + """Combine today's date with a time string if needed.""" + if not time_str: + return _now_nl().strftime("%d-%m-%Y %H:%M") + + if ( + isinstance(time_str, str) + and len(time_str.split(":")) in (2, 3) + and " " not in time_str + ): + today = _now_nl().strftime("%d-%m-%Y") + return f"{today} {time_str[:5]}" + # Fallback: use current date and time + return _now_nl().strftime("%d-%m-%Y %H:%M") + + async def _get_trips( + self, + departure: str, + destination: str, + via: str | None = None, + departure_time: str | None = None, + ) -> list[Trip]: + """Get trips from NS API, sorted by departure time.""" + + # Convert time to full date-time string if needed and default to Dutch local time if not provided + time_str = self._get_time_from_route(departure_time) + + trips = await self.hass.async_add_executor_job( + self.nsapi.get_trips, + time_str, # trip_time + departure, # departure + via, # via + destination, # destination + True, # exclude_high_speed + 0, # year_card + 2, # max_number_of_transfers + ) + + if not trips: + return [] + + return sorted( + trips, + key=lambda trip: ( + trip.departure_time_actual + if trip.departure_time_actual is not None + else trip.departure_time_planned + if trip.departure_time_planned is not None + else _now_nl() + ), + ) + + def _get_first_and_next_trips( + self, trips: list[Trip] + ) -> tuple[Trip | None, Trip | None]: + """Process trips to find the first and next departure.""" + if not trips: + return None, None + + # First trip is the earliest future trip + first_trip = trips[0] + + # Find next trip with different departure time + next_trip = self._find_next_trip(trips, first_trip) + + return first_trip, next_trip + + def _remove_trips_in_the_past(self, trips: list[Trip]) -> list[Trip]: + """Filter out trips that have already departed.""" + # Compare against Dutch local time to align with ns_api timezone handling + now = _now_nl() + future_trips = [] + for trip in trips: + departure_time = ( + trip.departure_time_actual + if trip.departure_time_actual is not None + else trip.departure_time_planned + ) + if departure_time is not None and ( + departure_time.tzinfo is None + or departure_time.tzinfo.utcoffset(departure_time) is None + ): + # Make naive datetimes timezone-aware using current reference tz + departure_time = departure_time.replace(tzinfo=now.tzinfo) + + if departure_time and departure_time > now: + future_trips.append(trip) + return future_trips + + def _find_next_trip( + self, future_trips: list[Trip], first_trip: Trip + ) -> Trip | None: + """Find the next trip with a different departure time than the first trip.""" + next_trip = None + if len(future_trips) > 1: + first_time = ( + first_trip.departure_time_actual + if first_trip.departure_time_actual is not None + else first_trip.departure_time_planned + ) + for trip in future_trips[1:]: + trip_time = ( + trip.departure_time_actual + if trip.departure_time_actual is not None + else trip.departure_time_planned + ) + if trip_time and first_time and trip_time > first_time: + next_trip = trip + break + return next_trip diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index f3b6f1b28799e..9a1ace9994aed 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -2,13 +2,10 @@ from __future__ import annotations -import datetime as dt -from datetime import datetime, timedelta +from datetime import datetime import logging from typing import Any -from ns_api import NSAPI, Trip -import requests import voluptuous as vol from homeassistant.components.sensor import ( @@ -21,28 +18,28 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle, dt as dt_util -from homeassistant.util.dt import parse_time - -from . import NSConfigEntry -from .const import DOMAIN +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + CONF_FROM, + CONF_ROUTES, + CONF_TIME, + CONF_TO, + CONF_VIA, + DOMAIN, + INTEGRATION_TITLE, + ROUTE_MODEL, +) +from .coordinator import NSConfigEntry, NSDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -CONF_ROUTES = "routes" -CONF_FROM = "from" -CONF_TO = "to" -CONF_VIA = "via" -CONF_TIME = "time" - - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) - ROUTE_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.string, @@ -88,7 +85,7 @@ async def async_setup_platform( translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}", translation_placeholders={ "domain": DOMAIN, - "integration_title": "Nederlandse Spoorwegen", + "integration_title": INTEGRATION_TITLE, }, ) return @@ -104,7 +101,7 @@ async def async_setup_platform( translation_key="deprecated_yaml", translation_placeholders={ "domain": DOMAIN, - "integration_title": "Nederlandse Spoorwegen", + "integration_title": INTEGRATION_TITLE, }, ) @@ -116,34 +113,20 @@ async def async_setup_entry( ) -> None: """Set up the departure sensor from a config entry.""" - client = config_entry.runtime_data - - for subentry in config_entry.subentries.values(): - if subentry.subentry_type != "route": - continue - - async_add_entities( - [ - NSDepartureSensor( - client, - subentry.data[CONF_NAME], - subentry.data[CONF_FROM], - subentry.data[CONF_TO], - subentry.subentry_id, - subentry.data.get(CONF_VIA), - ( - parse_time(subentry.data[CONF_TIME]) - if CONF_TIME in subentry.data - else None - ), - ) - ], - config_subentry_id=subentry.subentry_id, - update_before_add=True, + coordinators = config_entry.runtime_data + + for subentry_id, coordinator in coordinators.items(): + # Build entity from coordinator fields directly + entity = NSDepartureSensor( + subentry_id, + coordinator, ) + # Add entity with proper subentry association + async_add_entities([entity], config_subentry_id=subentry_id) -class NSDepartureSensor(SensorEntity): + +class NSDepartureSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity): """Implementation of a NS Departure Sensor.""" _attr_device_class = SensorDeviceClass.TIMESTAMP @@ -152,71 +135,86 @@ class NSDepartureSensor(SensorEntity): def __init__( self, - nsapi: NSAPI, - name: str, - departure: str, - heading: str, subentry_id: str, - via: str | None, - time: dt.time | None, + coordinator: NSDataUpdateCoordinator, ) -> None: """Initialize the sensor.""" - self._nsapi = nsapi - self._name = name - self._departure = departure - self._via = via - self._heading = heading - self._time = time - self._trips: list[Trip] | None = None - self._first_trip: Trip | None = None - self._next_trip: Trip | None = None + super().__init__(coordinator) + self._name = coordinator.name + self._subentry_id = subentry_id self._attr_unique_id = f"{subentry_id}-actual_departure" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._subentry_id)}, + name=self._name, + manufacturer=INTEGRATION_TITLE, + model=ROUTE_MODEL, + ) @property def name(self) -> str: """Return the name of the sensor.""" return self._name + @property + def native_value(self) -> datetime | None: + """Return the native value of the sensor.""" + route_data = self.coordinator.data + if not route_data.first_trip: + return None + + first_trip = route_data.first_trip + if first_trip.departure_time_actual: + return first_trip.departure_time_actual + return first_trip.departure_time_planned + @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - if not self._trips or self._first_trip is None: + route_data = self.coordinator.data + if not route_data: return None - if self._first_trip.trip_parts: - route = [self._first_trip.departure] - route.extend(k.destination for k in self._first_trip.trip_parts) + first_trip = route_data.first_trip + next_trip = route_data.next_trip + + if not first_trip: + return None + + route = [] + if first_trip.trip_parts: + route = [first_trip.departure] + route.extend(k.destination for k in first_trip.trip_parts) # Static attributes attributes = { - "going": self._first_trip.going, + "going": first_trip.going, "departure_time_planned": None, "departure_time_actual": None, "departure_delay": False, - "departure_platform_planned": self._first_trip.departure_platform_planned, - "departure_platform_actual": self._first_trip.departure_platform_actual, + "departure_platform_planned": first_trip.departure_platform_planned, + "departure_platform_actual": first_trip.departure_platform_actual, "arrival_time_planned": None, "arrival_time_actual": None, "arrival_delay": False, - "arrival_platform_planned": self._first_trip.arrival_platform_planned, - "arrival_platform_actual": self._first_trip.arrival_platform_actual, + "arrival_platform_planned": first_trip.arrival_platform_planned, + "arrival_platform_actual": first_trip.arrival_platform_actual, "next": None, - "status": self._first_trip.status.lower(), - "transfers": self._first_trip.nr_transfers, + "status": first_trip.status.lower() if first_trip.status else None, + "transfers": first_trip.nr_transfers, "route": route, "remarks": None, } # Planned departure attributes - if self._first_trip.departure_time_planned is not None: + if first_trip.departure_time_planned is not None: attributes["departure_time_planned"] = ( - self._first_trip.departure_time_planned.strftime("%H:%M") + first_trip.departure_time_planned.strftime("%H:%M") ) # Actual departure attributes - if self._first_trip.departure_time_actual is not None: + if first_trip.departure_time_actual is not None: attributes["departure_time_actual"] = ( - self._first_trip.departure_time_actual.strftime("%H:%M") + first_trip.departure_time_actual.strftime("%H:%M") ) # Delay departure attributes @@ -229,15 +227,15 @@ def extra_state_attributes(self) -> dict[str, Any] | None: attributes["departure_delay"] = True # Planned arrival attributes - if self._first_trip.arrival_time_planned is not None: + if first_trip.arrival_time_planned is not None: attributes["arrival_time_planned"] = ( - self._first_trip.arrival_time_planned.strftime("%H:%M") + first_trip.arrival_time_planned.strftime("%H:%M") ) # Actual arrival attributes - if self._first_trip.arrival_time_actual is not None: - attributes["arrival_time_actual"] = ( - self._first_trip.arrival_time_actual.strftime("%H:%M") + if first_trip.arrival_time_actual is not None: + attributes["arrival_time_actual"] = first_trip.arrival_time_actual.strftime( + "%H:%M" ) # Delay arrival attributes @@ -248,89 +246,11 @@ def extra_state_attributes(self) -> dict[str, Any] | None: ): attributes["arrival_delay"] = True - assert self._next_trip is not None - # Next attributes - if self._next_trip.departure_time_actual is not None: - attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M") - elif self._next_trip.departure_time_planned is not None: - attributes["next"] = self._next_trip.departure_time_planned.strftime( - "%H:%M" - ) - else: - attributes["next"] = None + # Next trip attributes + if next_trip: + if next_trip.departure_time_actual is not None: + attributes["next"] = next_trip.departure_time_actual.strftime("%H:%M") + elif next_trip.departure_time_planned is not None: + attributes["next"] = next_trip.departure_time_planned.strftime("%H:%M") return attributes - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self) -> None: - """Get the trip information.""" - - # If looking for a specific trip time, update around that trip time only. - if self._time and ( - (datetime.now() + timedelta(minutes=30)).time() < self._time - or (datetime.now() - timedelta(minutes=30)).time() > self._time - ): - self._attr_native_value = None - self._trips = None - self._first_trip = None - return - - # Set the search parameter to search from a specific trip time - # or to just search for next trip. - if self._time: - trip_time = ( - datetime.today() - .replace(hour=self._time.hour, minute=self._time.minute) - .strftime("%d-%m-%Y %H:%M") - ) - else: - trip_time = dt_util.now().strftime("%d-%m-%Y %H:%M") - - try: - self._trips = self._nsapi.get_trips( - trip_time, self._departure, self._via, self._heading, True, 0, 2 - ) - if self._trips: - all_times = [] - - # If a train is delayed we can observe this through departure_time_actual. - for trip in self._trips: - if trip.departure_time_actual is None: - all_times.append(trip.departure_time_planned) - else: - all_times.append(trip.departure_time_actual) - - # Remove all trains that already left. - filtered_times = [ - (i, time) - for i, time in enumerate(all_times) - if time > dt_util.now() - ] - - if len(filtered_times) > 0: - sorted_times = sorted(filtered_times, key=lambda x: x[1]) - self._first_trip = self._trips[sorted_times[0][0]] - self._attr_native_value = sorted_times[0][1] - - # Filter again to remove trains that leave at the exact same time. - filtered_times = [ - (i, time) - for i, time in enumerate(all_times) - if time > sorted_times[0][1] - ] - - if len(filtered_times) > 0: - sorted_times = sorted(filtered_times, key=lambda x: x[1]) - self._next_trip = self._trips[sorted_times[0][0]] - else: - self._next_trip = None - - else: - self._first_trip = None - self._attr_native_value = None - - except ( - requests.exceptions.ConnectionError, - requests.exceptions.HTTPError, - ) as error: - _LOGGER.error("Couldn't fetch trip info: %s", error) diff --git a/tests/components/nederlandse_spoorwegen/conftest.py b/tests/components/nederlandse_spoorwegen/conftest.py index 4589ecd2c2e39..8729a0cd5b34d 100644 --- a/tests/components/nederlandse_spoorwegen/conftest.py +++ b/tests/components/nederlandse_spoorwegen/conftest.py @@ -8,14 +8,17 @@ from homeassistant.components.nederlandse_spoorwegen.const import ( CONF_FROM, + CONF_TIME, CONF_TO, CONF_VIA, DOMAIN, + INTEGRATION_TITLE, + SUBENTRY_TYPE_ROUTE, ) from homeassistant.config_entries import ConfigSubentryDataWithId from homeassistant.const import CONF_API_KEY, CONF_NAME -from .const import API_KEY +from .const import API_KEY, SUBENTRY_ID_1, SUBENTRY_ID_2 from tests.common import MockConfigEntry, load_json_object_fixture @@ -39,7 +42,7 @@ def mock_nsapi() -> Generator[AsyncMock]: autospec=True, ) as mock_nsapi, patch( - "homeassistant.components.nederlandse_spoorwegen.NSAPI", + "homeassistant.components.nederlandse_spoorwegen.coordinator.NSAPI", new=mock_nsapi, ), ): @@ -57,7 +60,7 @@ def mock_nsapi() -> Generator[AsyncMock]: def mock_config_entry() -> MockConfigEntry: """Mock config entry.""" return MockConfigEntry( - title="Nederlandse Spoorwegen", + title=INTEGRATION_TITLE, data={CONF_API_KEY: API_KEY}, domain=DOMAIN, subentries_data=[ @@ -67,11 +70,25 @@ def mock_config_entry() -> MockConfigEntry: CONF_FROM: "Ams", CONF_TO: "Rot", CONF_VIA: "Ht", + CONF_TIME: None, }, - subentry_type="route", + subentry_type=SUBENTRY_TYPE_ROUTE, title="Test Route", unique_id=None, - subentry_id="01K721DZPMEN39R5DK0ATBMSY8", + subentry_id=SUBENTRY_ID_1, + ), + ConfigSubentryDataWithId( + data={ + CONF_NAME: "To home", + CONF_FROM: "Hag", + CONF_TO: "Utr", + CONF_VIA: None, + CONF_TIME: "08:00", + }, + subentry_type=SUBENTRY_TYPE_ROUTE, + title="Test Route", + unique_id=None, + subentry_id=SUBENTRY_ID_2, ), ], ) diff --git a/tests/components/nederlandse_spoorwegen/const.py b/tests/components/nederlandse_spoorwegen/const.py index 92c2a6e58f908..cc13d0fd9a88f 100644 --- a/tests/components/nederlandse_spoorwegen/const.py +++ b/tests/components/nederlandse_spoorwegen/const.py @@ -1,3 +1,15 @@ """Constants for the Nederlandse Spoorwegen integration tests.""" API_KEY = "abc1234567" + +# Date/time format strings +DATETIME_FORMAT_LENGTH = 16 # "DD-MM-YYYY HH:MM" format +DATE_SEPARATOR = "-" +DATETIME_SPACE = " " +TIME_SEPARATOR = ":" + +# Test route names +TEST_ROUTE_TITLE_1 = "Route 1" +TEST_ROUTE_TITLE_2 = "Route 2" +SUBENTRY_ID_1 = "01K721DZPMEN39R5DK0ATBMSY8" +SUBENTRY_ID_2 = "01K721DZPMEN39R5DK0ATBMSY9" diff --git a/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr b/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr new file mode 100644 index 0000000000000..96b3def82e77f --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_device_registry_integration + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'nederlandse_spoorwegen', + '01K721DZPMEN39R5DK0ATBMSY8', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Nederlandse Spoorwegen', + 'model': 'Route', + 'model_id': None, + 'name': 'To work', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'nederlandse_spoorwegen', + '01K721DZPMEN39R5DK0ATBMSY9', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Nederlandse Spoorwegen', + 'model': 'Route', + 'model_id': None, + 'name': 'To home', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- diff --git a/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr b/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr index d3c311acb664f..565970b953f62 100644 --- a/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr +++ b/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr @@ -1,4 +1,76 @@ # serializer version: 1 +# name: test_sensor[sensor.to_home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.to_home', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:train', + 'original_name': 'To home', + 'platform': 'nederlandse_spoorwegen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01K721DZPMEN39R5DK0ATBMSY9-actual_departure', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.to_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'arrival_delay': False, + 'arrival_platform_actual': '12', + 'arrival_platform_planned': '12', + 'arrival_time_actual': '18:37', + 'arrival_time_planned': '18:37', + 'attribution': 'Data provided by NS', + 'departure_delay': True, + 'departure_platform_actual': '4', + 'departure_platform_planned': '4', + 'departure_time_actual': '16:35', + 'departure_time_planned': '16:34', + 'device_class': 'timestamp', + 'friendly_name': 'To home', + 'going': True, + 'icon': 'mdi:train', + 'next': '16:46', + 'remarks': None, + 'route': list([ + 'Amsterdam Centraal', + "'s-Hertogenbosch", + 'Breda', + 'Rotterdam Centraal', + ]), + 'status': 'normal', + 'transfers': 2, + }), + 'context': , + 'entity_id': 'sensor.to_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-09-15T14:35:00+00:00', + }) +# --- # name: test_sensor[sensor.to_work-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/nederlandse_spoorwegen/test_config_flow.py b/tests/components/nederlandse_spoorwegen/test_config_flow.py index 8d0c8e2b451ab..264a057a0c577 100644 --- a/tests/components/nederlandse_spoorwegen/test_config_flow.py +++ b/tests/components/nederlandse_spoorwegen/test_config_flow.py @@ -53,7 +53,7 @@ async def test_creating_route( ) -> None: """Test creating a route after setting up the main config entry.""" mock_config_entry.add_to_hass(hass) - assert len(mock_config_entry.subentries) == 1 + assert len(mock_config_entry.subentries) == 2 result = await hass.config_entries.subentries.async_init( (mock_config_entry.entry_id, "route"), context={"source": SOURCE_USER} ) @@ -80,7 +80,7 @@ async def test_creating_route( CONF_NAME: "Home to Work", CONF_TIME: "08:30", } - assert len(mock_config_entry.subentries) == 2 + assert len(mock_config_entry.subentries) == 3 @pytest.mark.parametrize( @@ -136,7 +136,7 @@ async def test_fetching_stations_failed( ) -> None: """Test creating a route after setting up the main config entry.""" mock_config_entry.add_to_hass(hass) - assert len(mock_config_entry.subentries) == 1 + assert len(mock_config_entry.subentries) == 2 mock_nsapi.get_stations.side_effect = RequestsConnectionError("Unexpected error") result = await hass.config_entries.subentries.async_init( (mock_config_entry.entry_id, "route"), context={"source": SOURCE_USER} diff --git a/tests/components/nederlandse_spoorwegen/test_init.py b/tests/components/nederlandse_spoorwegen/test_init.py new file mode 100644 index 0000000000000..551cc4771d38a --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/test_init.py @@ -0,0 +1,30 @@ +"""Test the Nederlandse Spoorwegen init.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_device_registry_integration( + hass: HomeAssistant, + mock_nsapi, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device registry integration creates correct devices.""" + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + # Get all devices created for this config entry + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + # Snapshot the devices to ensure they have the correct structure + assert device_entries == snapshot diff --git a/tests/components/nederlandse_spoorwegen/test_sensor.py b/tests/components/nederlandse_spoorwegen/test_sensor.py index ab578248bccef..28839f633f1c9 100644 --- a/tests/components/nederlandse_spoorwegen/test_sensor.py +++ b/tests/components/nederlandse_spoorwegen/test_sensor.py @@ -3,16 +3,21 @@ from unittest.mock import AsyncMock import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError from syrupy.assertion import SnapshotAssertion from homeassistant.components.nederlandse_spoorwegen.const import ( CONF_FROM, CONF_ROUTES, + CONF_TIME, CONF_TO, CONF_VIA, DOMAIN, + INTEGRATION_TITLE, + SUBENTRY_TYPE_ROUTE, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigSubentryDataWithId from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_PLATFORM from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.entity_registry as er @@ -72,3 +77,83 @@ async def test_sensor( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensor_with_api_connection_error( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor behavior when API connection fails.""" + # Make API calls fail from the start + mock_nsapi.get_trips.side_effect = RequestsConnectionError("Connection failed") + + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + # Sensors should not be created at all if initial API call fails + sensor_states = hass.states.async_all("sensor") + assert len(sensor_states) == 0 + + +@pytest.mark.parametrize( + ("time_input", "route_name", "description"), + [ + (None, "Current time route", "No specific time - should use current time"), + ("08:30", "Morning commute", "Time only - should use today's date with time"), + ("08:30:45", "Early commute", "Time with seconds - should truncate seconds"), + ], +) +async def test_sensor_with_custom_time_parsing( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + time_input, + route_name, + description, +) -> None: + """Test sensor with different time parsing scenarios.""" + # Create a config entry with a route that has the specified time + config_entry = MockConfigEntry( + title=INTEGRATION_TITLE, + data={CONF_API_KEY: API_KEY}, + domain=DOMAIN, + subentries_data=[ + ConfigSubentryDataWithId( + data={ + CONF_NAME: route_name, + CONF_FROM: "Ams", + CONF_TO: "Rot", + CONF_VIA: "Ht", + CONF_TIME: time_input, + }, + subentry_type=SUBENTRY_TYPE_ROUTE, + title=f"{route_name} Route", + unique_id=None, + subentry_id=f"test_route_{time_input or 'none'}".replace(":", "_") + .replace("-", "_") + .replace(" ", "_"), + ), + ], + ) + + await setup_integration(hass, config_entry) + await hass.async_block_till_done() + + # Should create one sensor for the route with time parsing + sensor_states = hass.states.async_all("sensor") + assert len(sensor_states) == 1 + + # Verify sensor was created successfully with time parsing + state = sensor_states[0] + assert state is not None + assert state.state != "unavailable" + assert state.attributes.get("attribution") == "Data provided by NS" + assert state.attributes.get("device_class") == "timestamp" + assert state.attributes.get("icon") == "mdi:train" + + # The sensor should have a friendly name based on the route name + friendly_name = state.attributes.get("friendly_name", "").lower() + assert ( + route_name.lower() in friendly_name + or route_name.replace(" ", "_").lower() in state.entity_id + ) From 7a3630e6477d593061ef0ad92c348f17d05de009 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Tue, 14 Oct 2025 21:35:11 +0800 Subject: [PATCH 36/51] Add sensor description for switchbot cloud's device(plug) small changes (#148551) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/switchbot_cloud/sensor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index ff15b980d5e71..87f0db93030ee 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -38,6 +38,7 @@ SENSOR_TYPE_POWER = "power" SENSOR_TYPE_VOLTAGE = "voltage" SENSOR_TYPE_CURRENT = "electricCurrent" +SENSOR_TYPE_POWER_CONSUMPTION = "weight" SENSOR_TYPE_USED_ELECTRICITY = "usedElectricity" SENSOR_TYPE_LIGHTLEVEL = "lightLevel" @@ -120,6 +121,13 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ) +POWER_CONSUMPTION_DESCRIPTION = SensorEntityDescription( + key=SENSOR_TYPE_POWER_CONSUMPTION, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, +) + RELAY_SWITCH_2PM_POWER_DESCRIPTION = SensorEntityDescription( key=RELAY_SWITCH_2PM_SENSOR_TYPE_POWER, device_class=SensorDeviceClass.POWER, @@ -180,10 +188,12 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): "Plug Mini (US)": ( VOLTAGE_DESCRIPTION, CURRENT_DESCRIPTION_IN_MA, + POWER_CONSUMPTION_DESCRIPTION, ), "Plug Mini (JP)": ( VOLTAGE_DESCRIPTION, CURRENT_DESCRIPTION_IN_MA, + POWER_CONSUMPTION_DESCRIPTION, ), "Plug Mini (EU)": ( POWER_DESCRIPTION, From a3dec46d59bf3cb87d7a1b43a0fb9a431fc86a50 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 14 Oct 2025 06:58:14 -0700 Subject: [PATCH 37/51] Add derivative tests exhibiting unit issues (#153051) --- .../components/derivative/test_config_flow.py | 87 +++++++++++++++++++ tests/components/derivative/test_sensor.py | 62 +++++++++++++ 2 files changed, 149 insertions(+) diff --git a/tests/components/derivative/test_config_flow.py b/tests/components/derivative/test_config_flow.py index 5e2d9446cdc48..09e9fc9f07dfc 100644 --- a/tests/components/derivative/test_config_flow.py +++ b/tests/components/derivative/test_config_flow.py @@ -1,14 +1,18 @@ """Test the Derivative config flow.""" +from datetime import timedelta from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant import config_entries from homeassistant.components.derivative.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import selector +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, get_schema_suggested_value @@ -154,3 +158,86 @@ async def test_options( await hass.async_block_till_done() state = hass.states.get(f"{platform}.my_derivative") assert state.attributes["unit_of_measurement"] == "cat/h" + + +async def test_update_unit(hass: HomeAssistant) -> None: + """Test behavior of changing the unit_time option.""" + # Setup the config entry + source_id = "sensor.source" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": source_id, + "unit_time": "min", + "time_window": {"seconds": 0.0}, + }, + title="My derivative", + ) + derivative_id = "sensor.my_derivative" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(derivative_id) + assert state.state == STATE_UNAVAILABLE + assert state.attributes.get("unit_of_measurement") is None + + time = dt_util.utcnow() + with freeze_time(time) as freezer: + # First state update of the source. + # Derivative does not learn the unit yet. + hass.states.async_set(source_id, 5, {"unit_of_measurement": "dogs"}) + await hass.async_block_till_done() + state = hass.states.get(derivative_id) + assert state.state == "0.0" + assert state.attributes.get("unit_of_measurement") is None + + # Second state update of the source. + time += timedelta(minutes=1) + freezer.move_to(time) + hass.states.async_set(source_id, "7", {"unit_of_measurement": "dogs"}) + await hass.async_block_till_done() + state = hass.states.get(derivative_id) + assert state.state == "2.0" + assert state.attributes.get("unit_of_measurement") == "dogs/min" + + # Update the unit_time from minutes to seconds. + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "source": source_id, + "round": 1.0, + "unit_time": "s", + "time_window": {"seconds": 0.0}, + }, + ) + await hass.async_block_till_done() + + # Check the state after reconfigure. Neither unit or state has changed. + state = hass.states.get(derivative_id) + assert state.state == "2.0" + assert state.attributes.get("unit_of_measurement") == "dogs/min" + + # Third state update of the source. + time += timedelta(seconds=1) + freezer.move_to(time) + hass.states.async_set(source_id, "10", {"unit_of_measurement": "dogs"}) + await hass.async_block_till_done() + state = hass.states.get(derivative_id) + assert state.state == "3.0" + # While the state is correctly reporting a state of 3 dogs per second, it incorrectly keeps + # the unit as dogs/min + assert state.attributes.get("unit_of_measurement") == "dogs/min" + + # Fourth state update of the source. + time += timedelta(seconds=1) + freezer.move_to(time) + hass.states.async_set(source_id, "20", {"unit_of_measurement": "dogs"}) + await hass.async_block_till_done() + state = hass.states.get(derivative_id) + assert state.state == "10.0" + assert state.attributes.get("unit_of_measurement") == "dogs/min" diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 5a601ad26dd65..52b7ae725ea75 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -934,3 +934,65 @@ async def test_unavailable_boot( assert state is not None # Now that the source sensor has two valid datapoints, we can calculate derivative assert state.state == "5.00" + + +async def test_source_unit_change( + hass: HomeAssistant, +) -> None: + """Test how derivative responds when the source sensor changes unit.""" + source_id = "sensor.source" + config = { + "sensor": { + "platform": "derivative", + "name": "derivative", + "source": source_id, + "unit_time": "s", + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + entity_id = "sensor.derivative" + + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + assert state.attributes.get("unit_of_measurement") is None + + time = dt_util.utcnow() + with freeze_time(time) as freezer: + # First state update of the source. + # Derivative does not learn the UoM yet. + hass.states.async_set(source_id, "5", {"unit_of_measurement": "cats"}) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "0.000" + assert state.attributes.get("unit_of_measurement") is None + + # Second state update of the source. + time += timedelta(seconds=1) + freezer.move_to(time) + hass.states.async_set(source_id, "7", {"unit_of_measurement": "cats"}) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "2.000" + assert state.attributes.get("unit_of_measurement") == "cats/s" + + # Third state update of the source, source unit changes to dogs. + # Ignored by derivative which continues reporting cats. + time += timedelta(seconds=1) + freezer.move_to(time) + hass.states.async_set(source_id, "12", {"unit_of_measurement": "dogs"}) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "5.000" + assert state.attributes.get("unit_of_measurement") == "cats/s" + + # Fourth state update of the source, still dogs. + # Ignored by derivative which continues reporting cats. + time += timedelta(seconds=1) + freezer.move_to(time) + hass.states.async_set(source_id, "20", {"unit_of_measurement": "dogs"}) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "8.000" + assert state.attributes.get("unit_of_measurement") == "cats/s" From 2abc197dcdb61a8e079c6a22a9d28ad3cc3343bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 14 Oct 2025 15:16:00 +0100 Subject: [PATCH 38/51] Add extract_from_target websocket command (#150124) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/websocket_api/commands.py | 41 ++- .../components/websocket_api/test_commands.py | 263 +++++++++++++++++- 2 files changed, 302 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index a15d63b31e657..099e113cf935b 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -34,7 +34,12 @@ TemplateError, Unauthorized, ) -from homeassistant.helpers import config_validation as cv, entity, template +from homeassistant.helpers import ( + config_validation as cv, + entity, + target as target_helpers, + template, +) from homeassistant.helpers.condition import ( async_get_all_descriptions as async_get_all_condition_descriptions, async_subscribe_platform_events as async_subscribe_condition_platform_events, @@ -96,6 +101,7 @@ def async_register_commands( async_reg(hass, handle_call_service) async_reg(hass, handle_entity_source) async_reg(hass, handle_execute_script) + async_reg(hass, handle_extract_from_target) async_reg(hass, handle_fire_event) async_reg(hass, handle_get_config) async_reg(hass, handle_get_services) @@ -838,6 +844,39 @@ def handle_entity_source( connection.send_result(msg["id"], _serialize_entity_sources(entity_sources)) +@callback +@decorators.websocket_command( + { + vol.Required("type"): "extract_from_target", + vol.Required("target"): cv.TARGET_FIELDS, + vol.Optional("expand_group", default=False): bool, + } +) +def handle_extract_from_target( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle extract from target command.""" + + selector_data = target_helpers.TargetSelectorData(msg["target"]) + extracted = target_helpers.async_extract_referenced_entity_ids( + hass, selector_data, expand_group=msg["expand_group"] + ) + + extracted_dict = { + "referenced_entities": extracted.referenced.union( + extracted.indirectly_referenced + ), + "referenced_devices": extracted.referenced_devices, + "referenced_areas": extracted.referenced_areas, + "missing_devices": extracted.missing_devices, + "missing_areas": extracted.missing_areas, + "missing_floors": extracted.missing_floors, + "missing_labels": extracted.missing_labels, + } + + connection.send_result(msg["id"], extracted_dict) + + @decorators.websocket_command( { vol.Required("type"): "subscribe_trigger", diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 4a62ae99817f1..480f44c0ff1e0 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -32,7 +32,12 @@ from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS from homeassistant.core import Context, HomeAssistant, State, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + label_registry as lr, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_state_change_event from homeassistant.loader import Integration, async_get_integration @@ -108,6 +113,29 @@ def _apply_entities_changes(state_dict: dict, change_dict: dict) -> None: del state_dict[STATE_KEY_LONG_NAMES[key]][item] +def _assert_extract_from_target_command_result( + msg: dict[str, Any], + entities: set[str] | None = None, + devices: set[str] | None = None, + areas: set[str] | None = None, + missing_devices: set[str] | None = None, + missing_areas: set[str] | None = None, + missing_labels: set[str] | None = None, + missing_floors: set[str] | None = None, +) -> None: + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + result = msg["result"] + assert set(result["referenced_entities"]) == (entities or set()) + assert set(result["referenced_devices"]) == (devices or set()) + assert set(result["referenced_areas"]) == (areas or set()) + assert set(result["missing_devices"]) == (missing_devices or set()) + assert set(result["missing_areas"]) == (missing_areas or set()) + assert set(result["missing_floors"]) == (missing_floors or set()) + assert set(result["missing_labels"]) == (missing_labels or set()) + + async def test_fire_event( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: @@ -3223,3 +3251,236 @@ async def mock_setup(hass: HomeAssistant, _) -> bool: # The component has been loaded assert "test" in hass.config.components + + +async def test_extract_from_target( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + label_registry: lr.LabelRegistry, +) -> None: + """Test extract_from_target command with mixed target types including entities, devices, areas, and labels.""" + + async def call_command(target: dict[str, str]) -> Any: + await websocket_client.send_json_auto_id( + {"type": "extract_from_target", "target": target} + ) + return await websocket_client.receive_json() + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + device1 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device1")}, + ) + + device2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device2")}, + ) + + area_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device3")}, + ) + + label2_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device4")}, + ) + + kitchen_area = area_registry.async_create("Kitchen") + living_room_area = area_registry.async_create("Living Room") + label_area = area_registry.async_create("Bathroom") + label1 = label_registry.async_create("Test Label 1") + label2 = label_registry.async_create("Test Label 2") + + # Associate devices with areas and labels + device_registry.async_update_device(area_device.id, area_id=kitchen_area.id) + device_registry.async_update_device(label2_device.id, labels={label2.label_id}) + area_registry.async_update(label_area.id, labels={label1.label_id}) + + # Setup entities with targets + device1_entity1 = entity_registry.async_get_or_create( + "light", "test", "unique1", device_id=device1.id + ) + device1_entity2 = entity_registry.async_get_or_create( + "switch", "test", "unique2", device_id=device1.id + ) + device2_entity = entity_registry.async_get_or_create( + "sensor", "test", "unique3", device_id=device2.id + ) + area_device_entity = entity_registry.async_get_or_create( + "light", "test", "unique4", device_id=area_device.id + ) + area_entity = entity_registry.async_get_or_create("switch", "test", "unique5") + label_device_entity = entity_registry.async_get_or_create( + "light", "test", "unique6", device_id=label2_device.id + ) + label_entity = entity_registry.async_get_or_create("switch", "test", "unique7") + + # Associate entities with areas and labels + entity_registry.async_update_entity( + area_entity.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity( + label_entity.entity_id, labels={label1.label_id} + ) + + msg = await call_command({"entity_id": ["light.unknown_entity"]}) + _assert_extract_from_target_command_result(msg, entities={"light.unknown_entity"}) + + msg = await call_command({"device_id": [device1.id, device2.id]}) + _assert_extract_from_target_command_result( + msg, + entities={ + device1_entity1.entity_id, + device1_entity2.entity_id, + device2_entity.entity_id, + }, + devices={device1.id, device2.id}, + ) + + msg = await call_command({"area_id": [kitchen_area.id, living_room_area.id]}) + _assert_extract_from_target_command_result( + msg, + entities={area_device_entity.entity_id, area_entity.entity_id}, + areas={kitchen_area.id, living_room_area.id}, + devices={area_device.id}, + ) + + msg = await call_command({"label_id": [label1.label_id, label2.label_id]}) + _assert_extract_from_target_command_result( + msg, + entities={label_device_entity.entity_id, label_entity.entity_id}, + devices={label2_device.id}, + areas={label_area.id}, + ) + + # Test multiple mixed targets + msg = await call_command( + { + "entity_id": ["light.direct"], + "device_id": [device1.id], + "area_id": [kitchen_area.id], + "label_id": [label1.label_id], + }, + ) + _assert_extract_from_target_command_result( + msg, + entities={ + "light.direct", + device1_entity1.entity_id, + device1_entity2.entity_id, + area_device_entity.entity_id, + label_entity.entity_id, + }, + devices={device1.id, area_device.id}, + areas={kitchen_area.id, label_area.id}, + ) + + +async def test_extract_from_target_expand_group( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: + """Test extract_from_target command with expand_group parameter.""" + await async_setup_component( + hass, + "group", + { + "group": { + "test_group": { + "name": "Test Group", + "entities": ["light.kitchen", "light.living_room"], + } + } + }, + ) + + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.living_room", "off") + + # Test without expand_group (default False) + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": {"entity_id": ["group.test_group"]}, + } + ) + msg = await websocket_client.receive_json() + _assert_extract_from_target_command_result(msg, entities={"group.test_group"}) + + # Test with expand_group=True + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": {"entity_id": ["group.test_group"]}, + "expand_group": True, + } + ) + msg = await websocket_client.receive_json() + _assert_extract_from_target_command_result( + msg, + entities={"light.kitchen", "light.living_room"}, + ) + + +async def test_extract_from_target_missing_entities( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: + """Test extract_from_target command with missing device IDs, area IDs, etc.""" + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": { + "device_id": ["non_existent_device"], + "area_id": ["non_existent_area"], + "label_id": ["non_existent_label"], + }, + } + ) + + msg = await websocket_client.receive_json() + # Non-existent devices/areas are still referenced but reported as missing + _assert_extract_from_target_command_result( + msg, + devices={"non_existent_device"}, + areas={"non_existent_area"}, + missing_areas={"non_existent_area"}, + missing_devices={"non_existent_device"}, + missing_labels={"non_existent_label"}, + ) + + +async def test_extract_from_target_empty_target( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: + """Test extract_from_target command with empty target.""" + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": {}, + } + ) + + msg = await websocket_client.receive_json() + _assert_extract_from_target_command_result(msg) + + +async def test_extract_from_target_validation_error( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: + """Test extract_from_target command with invalid target data.""" + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": "invalid", # Should be a dict, not string + } + ) + msg = await websocket_client.receive_json() + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert "error" in msg From 3e20c506f4fda6d096a91d7df2e7b068be350ec2 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:16:48 +0200 Subject: [PATCH 39/51] Add gallons per hour as volume flow rate unit (#154246) Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com> --- homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 2 ++ tests/util/test_unit_conversion.py | 6 ++++++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/const.py b/homeassistant/const.py index 13f61eec6b188..d8869dfd6e9ce 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -665,6 +665,7 @@ class UnitOfVolumeFlowRate(StrEnum): LITERS_PER_HOUR = "L/h" LITERS_PER_MINUTE = "L/min" LITERS_PER_SECOND = "L/s" + GALLONS_PER_HOUR = "gal/h" GALLONS_PER_MINUTE = "gal/min" MILLILITERS_PER_SECOND = "mL/s" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 44ea5cbdd900f..c3deae749a9ed 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -826,6 +826,7 @@ class VolumeFlowRateConverter(BaseUnitConverter): UnitOfVolumeFlowRate.LITERS_PER_MINUTE: 1 / (_HRS_TO_MINUTES * _L_TO_CUBIC_METER), UnitOfVolumeFlowRate.LITERS_PER_SECOND: 1 / (_HRS_TO_SECS * _L_TO_CUBIC_METER), + UnitOfVolumeFlowRate.GALLONS_PER_HOUR: 1 / _GALLON_TO_CUBIC_METER, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 1 / (_HRS_TO_MINUTES * _GALLON_TO_CUBIC_METER), UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND: 1 @@ -839,6 +840,7 @@ class VolumeFlowRateConverter(BaseUnitConverter): UnitOfVolumeFlowRate.LITERS_PER_HOUR, UnitOfVolumeFlowRate.LITERS_PER_MINUTE, UnitOfVolumeFlowRate.LITERS_PER_SECOND, + UnitOfVolumeFlowRate.GALLONS_PER_HOUR, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND, } diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 91a9ed084796c..26a6b1805201b 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -977,6 +977,12 @@ 7.48051948, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, ), + ( + 1, + UnitOfVolumeFlowRate.LITERS_PER_HOUR, + 0.264172052, + UnitOfVolumeFlowRate.GALLONS_PER_HOUR, + ), ( 9, UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, From 080a7dcfa743531a84ec0956eb27574d07524c1d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 14 Oct 2025 16:18:16 +0200 Subject: [PATCH 40/51] Allow more device types for Vodafone Station (#153990) --- .../components/vodafone_station/__init__.py | 49 +++++++++- .../vodafone_station/config_flow.py | 37 +++++-- .../components/vodafone_station/const.py | 4 + .../vodafone_station/coordinator.py | 31 ++++-- .../components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vodafone_station/conftest.py | 97 +++++++++++++------ tests/components/vodafone_station/const.py | 6 ++ .../snapshots/test_diagnostics.ambr | 6 +- .../vodafone_station/test_config_flow.py | 83 ++++++++++------ .../components/vodafone_station/test_init.py | 41 +++++++- 12 files changed, 271 insertions(+), 89 deletions(-) diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 0433199b54e6e..ded8b2ec3b825 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -1,8 +1,12 @@ """Vodafone Station integration.""" -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from aiohttp import ClientSession, CookieJar +from aiovodafone.api import VodafoneStationCommonApi + +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant +from .const import _LOGGER, CONF_DEVICE_DETAILS, DEVICE_TYPE, DEVICE_URL from .coordinator import VodafoneConfigEntry, VodafoneStationRouter from .utils import async_client_session @@ -14,9 +18,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> session = await async_client_session(hass) coordinator = VodafoneStationRouter( hass, - entry.data[CONF_HOST], - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], entry, session, ) @@ -30,6 +31,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> return True +async def async_migrate_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool: + """Migrate old entry.""" + if entry.version == 1 and entry.minor_version == 1: + _LOGGER.debug( + "Migrating from version %s.%s", entry.version, entry.minor_version + ) + + jar = CookieJar(unsafe=True, quote_cookie=False) + session = ClientSession(cookie_jar=jar) + + try: + device_type, url = await VodafoneStationCommonApi.get_device_type( + entry.data[CONF_HOST], + session, + ) + finally: + await session.close() + + # Save device details to config entry + new_data = entry.data.copy() + new_data.update( + { + CONF_DEVICE_DETAILS: { + DEVICE_TYPE: device_type, + DEVICE_URL: str(url), + } + }, + ) + + hass.config_entries.async_update_entry( + entry, data=new_data, version=1, minor_version=2 + ) + + _LOGGER.info( + "Migration to version %s.%s successful", entry.version, entry.minor_version + ) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index 13e30d389261e..ab2335e766977 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -5,7 +5,8 @@ from collections.abc import Mapping from typing import Any -from aiovodafone import VodafoneStationSercommApi, exceptions as aiovodafone_exceptions +from aiovodafone import exceptions as aiovodafone_exceptions +from aiovodafone.api import VodafoneStationCommonApi, init_api_class import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -20,7 +21,15 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback -from .const import _LOGGER, DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN +from .const import ( + _LOGGER, + CONF_DEVICE_DETAILS, + DEFAULT_HOST, + DEFAULT_USERNAME, + DEVICE_TYPE, + DEVICE_URL, + DOMAIN, +) from .coordinator import VodafoneConfigEntry from .utils import async_client_session @@ -40,26 +49,37 @@ def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect.""" session = await async_client_session(hass) - api = VodafoneStationSercommApi( - data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD], session + + device_type, url = await VodafoneStationCommonApi.get_device_type( + data[CONF_HOST], + session, ) + api = init_api_class(url, device_type, data, session) + try: await api.login() finally: await api.logout() - return {"title": data[CONF_HOST]} + return { + "title": data[CONF_HOST], + CONF_DEVICE_DETAILS: { + DEVICE_TYPE: device_type, + DEVICE_URL: str(url), + }, + } class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Vodafone Station.""" VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback @@ -97,7 +117,10 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry( + title=info["title"], + data=user_input | {CONF_DEVICE_DETAILS: info[CONF_DEVICE_DETAILS]}, + ) return self.async_show_form( step_id="user", data_schema=user_form_schema(user_input), errors=errors diff --git a/homeassistant/components/vodafone_station/const.py b/homeassistant/components/vodafone_station/const.py index 99f953d50d5f9..0a74f423e2a06 100644 --- a/homeassistant/components/vodafone_station/const.py +++ b/homeassistant/components/vodafone_station/const.py @@ -7,6 +7,10 @@ DOMAIN = "vodafone_station" SCAN_INTERVAL = 30 +CONF_DEVICE_DETAILS = "device_details" +DEVICE_URL = "device_url" +DEVICE_TYPE = "device_type" + DEFAULT_DEVICE_NAME = "Unknown device" DEFAULT_HOST = "192.168.1.1" DEFAULT_USERNAME = "vodafone" diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 5a3330b16c621..3648dee779595 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -6,13 +6,16 @@ from typing import Any, cast from aiohttp import ClientSession -from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions +from aiovodafone import exceptions +from aiovodafone.api import VodafoneStationDevice, init_api_class +from yarl import URL from homeassistant.components.device_tracker import ( DEFAULT_CONSIDER_HOME, DOMAIN as DEVICE_TRACKER_DOMAIN, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er @@ -20,7 +23,14 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import _LOGGER, DOMAIN, SCAN_INTERVAL +from .const import ( + _LOGGER, + CONF_DEVICE_DETAILS, + DEVICE_TYPE, + DEVICE_URL, + DOMAIN, + SCAN_INTERVAL, +) from .helpers import cleanup_device_tracker CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds() @@ -53,16 +63,19 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): def __init__( self, hass: HomeAssistant, - host: str, - username: str, - password: str, config_entry: VodafoneConfigEntry, session: ClientSession, ) -> None: """Initialize the scanner.""" - self._host = host - self.api = VodafoneStationSercommApi(host, username, password, session) + data = config_entry.data + + self.api = init_api_class( + URL(data[CONF_DEVICE_DETAILS][DEVICE_URL]), + data[CONF_DEVICE_DETAILS][DEVICE_TYPE], + data, + session, + ) # Last resort as no MAC or S/N can be retrieved via API self._id = config_entry.unique_id @@ -70,7 +83,7 @@ def __init__( super().__init__( hass=hass, logger=_LOGGER, - name=f"{DOMAIN}-{host}-coordinator", + name=f"{DOMAIN}-{data[CONF_HOST]}-coordinator", update_interval=timedelta(seconds=SCAN_INTERVAL), config_entry=config_entry, ) @@ -117,7 +130,7 @@ def _calculate_update_time_and_consider_home( async def _async_update_data(self) -> UpdateCoordinatorDataType: """Update router data.""" - _LOGGER.debug("Polling Vodafone Station host: %s", self._host) + _LOGGER.debug("Polling Vodafone Station host: %s", self.api.base_url.host) try: await self.api.login() diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index a9ee2f49b4c65..001bfa4d5b715 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aiovodafone"], "quality_scale": "platinum", - "requirements": ["aiovodafone==1.2.1"] + "requirements": ["aiovodafone==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8512f01af2f75..3533b50727a55 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -429,7 +429,7 @@ aiousbwatcher==1.1.1 aiovlc==0.5.1 # homeassistant.components.vodafone_station -aiovodafone==1.2.1 +aiovodafone==2.0.1 # homeassistant.components.waqi aiowaqi==3.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c19fe24fa7f0b..acfe8a42d9486 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -411,7 +411,7 @@ aiousbwatcher==1.1.1 aiovlc==0.5.1 # homeassistant.components.vodafone_station -aiovodafone==1.2.1 +aiovodafone==2.0.1 # homeassistant.components.waqi aiowaqi==3.1.0 diff --git a/tests/components/vodafone_station/conftest.py b/tests/components/vodafone_station/conftest.py index 778d8fdaa41bd..4790ddbe0b8d1 100644 --- a/tests/components/vodafone_station/conftest.py +++ b/tests/components/vodafone_station/conftest.py @@ -2,13 +2,28 @@ from datetime import UTC, datetime -from aiovodafone import VodafoneStationDevice +from aiovodafone.api import VodafoneStationCommonApi, VodafoneStationDevice import pytest +from yarl import URL -from homeassistant.components.vodafone_station.const import DOMAIN +from homeassistant.components.vodafone_station.const import ( + CONF_DEVICE_DETAILS, + DEVICE_TYPE, + DEVICE_URL, + DOMAIN, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from .const import DEVICE_1_HOST, DEVICE_1_MAC, DEVICE_2_MAC +from .const import ( + DEVICE_1_HOST, + DEVICE_1_MAC, + DEVICE_2_MAC, + TEST_HOST, + TEST_PASSWORD, + TEST_TYPE, + TEST_URL, + TEST_USERNAME, +) from tests.common import ( AsyncMock, @@ -34,53 +49,71 @@ def mock_vodafone_station_router() -> Generator[AsyncMock]: """Mock a Vodafone Station router.""" with ( patch( - "homeassistant.components.vodafone_station.coordinator.VodafoneStationSercommApi", + "homeassistant.components.vodafone_station.coordinator.init_api_class", autospec=True, ) as mock_router, patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi", + "homeassistant.components.vodafone_station.config_flow.init_api_class", new=mock_router, ), + patch.object( + VodafoneStationCommonApi, + "get_device_type", + new=AsyncMock(return_value=(TEST_TYPE, URL(TEST_URL))), + ), ): router = mock_router.return_value - router.get_devices_data.return_value = { - DEVICE_1_MAC: VodafoneStationDevice( - connected=True, - connection_type="wifi", - ip_address="192.168.1.10", - name=DEVICE_1_HOST, - mac=DEVICE_1_MAC, - type="laptop", - wifi="2.4G", - ), - DEVICE_2_MAC: VodafoneStationDevice( - connected=False, - connection_type="lan", - ip_address="192.168.1.11", - name="LanDevice1", - mac=DEVICE_2_MAC, - type="desktop", - wifi="", - ), - } - router.get_sensor_data.return_value = load_json_object_fixture( - "get_sensor_data.json", DOMAIN + router.login = AsyncMock(return_value=True) + router.logout = AsyncMock(return_value=True) + router.get_devices_data = AsyncMock( + return_value={ + DEVICE_1_MAC: VodafoneStationDevice( + connected=True, + connection_type="wifi", + ip_address="192.168.1.10", + name=DEVICE_1_HOST, + mac=DEVICE_1_MAC, + type="laptop", + wifi="2.4G", + ), + DEVICE_2_MAC: VodafoneStationDevice( + connected=False, + connection_type="lan", + ip_address="192.168.1.11", + name="LanDevice1", + mac=DEVICE_2_MAC, + type="desktop", + wifi="", + ), + } + ) + router.get_sensor_data = AsyncMock( + return_value=load_json_object_fixture("get_sensor_data.json", DOMAIN) ) router.convert_uptime.return_value = datetime( 2024, 11, 19, 20, 19, 0, tzinfo=UTC ) - router.base_url = "https://fake_host" + router.base_url = URL(TEST_URL) + router.restart_connection = AsyncMock(return_value=True) + router.restart_router = AsyncMock(return_value=True) + yield router @pytest.fixture -def mock_config_entry() -> Generator[MockConfigEntry]: +def mock_config_entry() -> MockConfigEntry: """Mock a Vodafone Station config entry.""" return MockConfigEntry( domain=DOMAIN, data={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_DEVICE_DETAILS: { + DEVICE_TYPE: TEST_TYPE, + DEVICE_URL: TEST_URL, + }, }, + version=1, + minor_version=2, ) diff --git a/tests/components/vodafone_station/const.py b/tests/components/vodafone_station/const.py index cf6c274e5d594..744430e06f8fc 100644 --- a/tests/components/vodafone_station/const.py +++ b/tests/components/vodafone_station/const.py @@ -4,3 +4,9 @@ DEVICE_1_MAC = "xx:xx:xx:xx:xx:xx" DEVICE_2_HOST = "LanDevice1" DEVICE_2_MAC = "yy:yy:yy:yy:yy:yy" + +TEST_HOST = "fake_host" +TEST_PASSWORD = "fake_password" +TEST_TYPE = "Sercomm" +TEST_URL = f"https://{TEST_HOST}" +TEST_USERNAME = "fake_username" diff --git a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr index be2956e0aab71..6929d875dfd57 100644 --- a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr +++ b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr @@ -27,6 +27,10 @@ }), 'entry': dict({ 'data': dict({ + 'device_details': dict({ + 'device_type': 'Sercomm', + 'device_url': 'https://fake_host', + }), 'host': 'fake_host', 'password': '**REDACTED**', 'username': '**REDACTED**', @@ -35,7 +39,7 @@ 'discovery_keys': dict({ }), 'domain': 'vodafone_station', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 4653230f7ca94..9d9ed2fda85e6 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -11,12 +11,19 @@ import pytest from homeassistant.components.device_tracker import CONF_CONSIDER_HOME -from homeassistant.components.vodafone_station.const import DOMAIN +from homeassistant.components.vodafone_station.const import ( + CONF_DEVICE_DETAILS, + DEVICE_TYPE, + DEVICE_URL, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .const import TEST_HOST, TEST_PASSWORD, TEST_TYPE, TEST_URL, TEST_USERNAME + from tests.common import MockConfigEntry @@ -35,16 +42,20 @@ async def test_user( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_DEVICE_DETAILS: { + DEVICE_TYPE: TEST_TYPE, + DEVICE_URL: TEST_URL, + }, } assert not result["result"].unique_id @@ -81,9 +92,9 @@ async def test_exception_connection( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, }, ) @@ -96,18 +107,22 @@ async def test_exception_connection( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "fake_host" + assert result["title"] == TEST_HOST assert result["data"] == { - "host": "fake_host", - "username": "fake_username", - "password": "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_DEVICE_DETAILS: { + DEVICE_TYPE: TEST_TYPE, + DEVICE_URL: TEST_URL, + }, } @@ -127,9 +142,9 @@ async def test_duplicate_entry( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, }, ) assert result["type"] is FlowResultType.ABORT @@ -199,13 +214,13 @@ async def test_reauth_not_successful( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_PASSWORD: "fake_password", + CONF_PASSWORD: TEST_PASSWORD, }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" - assert mock_config_entry.data[CONF_PASSWORD] == "fake_password" + assert mock_config_entry.data[CONF_PASSWORD] == TEST_PASSWORD async def test_options_flow( @@ -244,7 +259,7 @@ async def test_reconfigure_successful( assert result["step_id"] == "reconfigure" # original entry - assert mock_config_entry.data["host"] == "fake_host" + assert mock_config_entry.data[CONF_HOST] == TEST_HOST new_host = "192.168.100.60" @@ -252,8 +267,8 @@ async def test_reconfigure_successful( result["flow_id"], user_input={ CONF_HOST: new_host, - CONF_PASSWORD: "fake_password", - CONF_USERNAME: "fake_username", + CONF_PASSWORD: TEST_PASSWORD, + CONF_USERNAME: TEST_USERNAME, }, ) @@ -261,7 +276,7 @@ async def test_reconfigure_successful( assert reconfigure_result["reason"] == "reconfigure_successful" # changed entry - assert mock_config_entry.data["host"] == new_host + assert mock_config_entry.data[CONF_HOST] == new_host @pytest.mark.parametrize( @@ -294,8 +309,8 @@ async def test_reconfigure_fails( result["flow_id"], user_input={ CONF_HOST: "192.168.100.60", - CONF_PASSWORD: "fake_password", - CONF_USERNAME: "fake_username", + CONF_PASSWORD: TEST_PASSWORD, + CONF_USERNAME: TEST_USERNAME, }, ) @@ -309,8 +324,8 @@ async def test_reconfigure_fails( result["flow_id"], user_input={ CONF_HOST: "192.168.100.61", - CONF_PASSWORD: "fake_password", - CONF_USERNAME: "fake_username", + CONF_PASSWORD: TEST_PASSWORD, + CONF_USERNAME: TEST_USERNAME, }, ) @@ -318,6 +333,10 @@ async def test_reconfigure_fails( assert reconfigure_result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { CONF_HOST: "192.168.100.61", - CONF_PASSWORD: "fake_password", - CONF_USERNAME: "fake_username", + CONF_PASSWORD: TEST_PASSWORD, + CONF_USERNAME: TEST_USERNAME, + CONF_DEVICE_DETAILS: { + DEVICE_TYPE: TEST_TYPE, + DEVICE_URL: TEST_URL, + }, } diff --git a/tests/components/vodafone_station/test_init.py b/tests/components/vodafone_station/test_init.py index 053f0a95fe46a..9813a25fccc6e 100644 --- a/tests/components/vodafone_station/test_init.py +++ b/tests/components/vodafone_station/test_init.py @@ -3,12 +3,19 @@ from unittest.mock import AsyncMock from homeassistant.components.device_tracker import CONF_CONSIDER_HOME -from homeassistant.components.vodafone_station.const import DOMAIN +from homeassistant.components.vodafone_station.const import ( + CONF_DEVICE_DETAILS, + DEVICE_TYPE, + DEVICE_URL, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import setup_integration +from .const import TEST_HOST, TEST_PASSWORD, TEST_TYPE, TEST_URL, TEST_USERNAME from tests.common import MockConfigEntry @@ -51,3 +58,35 @@ async def test_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) + + +async def test_migrate_entry( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful migration of entry data.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + title=TEST_HOST, + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + unique_id="vodafone", + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.minor_version == 2 + assert config_entry.data[CONF_DEVICE_DETAILS] == { + DEVICE_TYPE: TEST_TYPE, + DEVICE_URL: TEST_URL, + } From 11ee7d63be6f63651053787b064d23ea192a101a Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 14 Oct 2025 08:23:29 -0600 Subject: [PATCH 41/51] Remove vesync unused extra attributes, refine enums (#153171) --- homeassistant/components/vesync/fan.py | 20 ++++++++++++++----- .../components/vesync/snapshots/test_fan.ambr | 7 +++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 9f3a7bc9ba8af..b4e4932a08b17 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -156,17 +156,27 @@ def extra_state_attributes(self) -> dict[str, Any]: if hasattr(self.device.state, "active_time"): attr["active_time"] = self.device.state.active_time - if hasattr(self.device.state, "display_status"): + if ( + hasattr(self.device.state, "display_status") + and self.device.state.display_status is not None + ): attr["display_status"] = getattr( self.device.state.display_status, "value", None ) - if hasattr(self.device.state, "child_lock"): + if ( + hasattr(self.device.state, "child_lock") + and self.device.state.child_lock is not None + ): attr["child_lock"] = self.device.state.child_lock - if hasattr(self.device.state, "nightlight_status"): - attr["night_light"] = self.device.state.nightlight_status - + if ( + hasattr(self.device.state, "nightlight_status") + and self.device.state.nightlight_status is not None + ): + attr["night_light"] = getattr( + self.device.state.nightlight_status, "value", None + ) if hasattr(self.device.state, "mode"): attr["mode"] = self.device.state.mode diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index daacddb32675d..bd7243347cd72 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -82,7 +82,6 @@ 'display_status': 'on', 'friendly_name': 'Air Purifier 131s', 'mode': 'sleep', - 'night_light': None, 'percentage': None, 'percentage_step': 33.333333333333336, 'preset_mode': 'sleep', @@ -182,7 +181,7 @@ 'display_status': 'on', 'friendly_name': 'Air Purifier 200s', 'mode': 'manual', - 'night_light': , + 'night_light': 'off', 'percentage': 33, 'percentage_step': 33.333333333333336, 'preset_mode': None, @@ -282,7 +281,7 @@ 'display_status': 'on', 'friendly_name': 'Air Purifier 400s', 'mode': 'manual', - 'night_light': , + 'night_light': 'off', 'percentage': 25, 'percentage_step': 25.0, 'preset_mode': None, @@ -383,7 +382,7 @@ 'display_status': 'on', 'friendly_name': 'Air Purifier 600s', 'mode': 'manual', - 'night_light': , + 'night_light': 'off', 'percentage': 25, 'percentage_step': 25.0, 'preset_mode': None, From 655de3dfd2c65536a044730af017bd97615aef5d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:26:40 -0400 Subject: [PATCH 42/51] Use `async_schedule_reload` instead of `async_reload` for ZHA (#154397) --- homeassistant/components/zha/helpers.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index f5b44eb8fc4a0..f8f1bd7880579 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -536,7 +536,6 @@ def __init__( self._unsubs: list[Callable[[], None]] = [] self._unsubs.append(self.gateway.on_all_events(self._handle_event_protocol)) - self._reload_task: asyncio.Task | None = None config_entry.async_on_unload( self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, @@ -622,15 +621,7 @@ def handle_connection_lost(self, event: ConnectionLostEvent) -> None: """Handle a connection lost event.""" _LOGGER.debug("Connection to the radio was lost: %r", event) - - # Ensure we do not queue up multiple resets - if self._reload_task is not None: - _LOGGER.debug("Ignoring reset, one is already running") - return - - self._reload_task = self.hass.async_create_task( - self.hass.config_entries.async_reload(self.config_entry.entry_id), - ) + self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) @callback def handle_device_joined(self, event: DeviceJoinedEvent) -> None: From 6aff1287dd70015aecadcee1abe9225eb3f4d54a Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:29:50 +0200 Subject: [PATCH 43/51] Fix capitalization of RADIUS in Uptime Kuma (#154456) --- homeassistant/components/uptime_kuma/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index e4a21004cfe5e..fb9b7760f09ae 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -98,7 +98,7 @@ "postgres": "PostgreSQL", "mysql": "MySQL/MariaDB", "mongodb": "MongoDB", - "radius": "Radius", + "radius": "RADIUS", "redis": "Redis", "tailscale_ping": "Tailscale Ping", "snmp": "SNMP", From b494074ee050ee8558d162a09c4f13fc28366a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 14 Oct 2025 15:32:31 +0100 Subject: [PATCH 44/51] Fix device registry arg docstring (#154453) --- homeassistant/helpers/device_registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index ef9c6b26b9f5b..d2a7b62d09090 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1045,7 +1045,7 @@ def _async_update_device( # noqa: C901 """Private update device attributes. :param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id - :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_subentry_id + :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_entry_id """ old = self.devices[device_id] @@ -1346,7 +1346,7 @@ def async_update_device( """Update device attributes. :param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id - :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_subentry_id + :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_entry_id """ if suggested_area is not UNDEFINED: report_usage( From 97a0a4ea17583aa5239714d7a0eb6b3e4afd6891 Mon Sep 17 00:00:00 2001 From: Kelyan PEGEOT SELME <75201282+kelyaenn@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:33:43 +0200 Subject: [PATCH 45/51] Add tyre pressure to Renault integration (#154377) --- .../components/renault/renault_vehicle.py | 5 + homeassistant/components/renault/sensor.py | 42 ++ homeassistant/components/renault/strings.json | 12 + tests/components/renault/conftest.py | 9 + tests/components/renault/const.py | 2 + .../renault/fixtures/pressure.1.json | 16 + .../renault/snapshots/test_sensor.ambr | 448 ++++++++++++++++++ tests/components/renault/test_sensor.py | 4 +- 8 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 tests/components/renault/fixtures/pressure.1.json diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 89059e890f47c..6ae9693887e8e 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -260,4 +260,9 @@ async def set_charge_schedules( key="res_state", update_method=lambda x: x.get_res_state, ), + RenaultCoordinatorDescription( + endpoint="pressure", + key="pressure", + update_method=lambda x: x.get_tyre_pressure, + ), ) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 7c513c1b9def5..e3eefde1aa748 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -13,6 +13,7 @@ KamereonVehicleHvacStatusData, KamereonVehicleLocationData, KamereonVehicleResStateData, + KamereonVehicleTyrePressureData, ) from homeassistant.components.sensor import ( @@ -26,6 +27,7 @@ UnitOfEnergy, UnitOfLength, UnitOfPower, + UnitOfPressure, UnitOfTemperature, UnitOfTime, UnitOfVolume, @@ -337,4 +339,44 @@ def _get_utc_value(entity: RenaultSensor[T]) -> datetime: entity_registry_enabled_default=False, translation_key="res_state_code", ), + RenaultSensorEntityDescription( + key="front_left_pressure", + coordinator="pressure", + data_key="flPressure", + device_class=SensorDeviceClass.PRESSURE, + entity_class=RenaultSensor[KamereonVehicleTyrePressureData], + native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, + translation_key="front_left_pressure", + ), + RenaultSensorEntityDescription( + key="front_right_pressure", + coordinator="pressure", + data_key="frPressure", + device_class=SensorDeviceClass.PRESSURE, + entity_class=RenaultSensor[KamereonVehicleTyrePressureData], + native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, + translation_key="front_right_pressure", + ), + RenaultSensorEntityDescription( + key="rear_left_pressure", + coordinator="pressure", + data_key="rlPressure", + device_class=SensorDeviceClass.PRESSURE, + entity_class=RenaultSensor[KamereonVehicleTyrePressureData], + native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, + translation_key="rear_left_pressure", + ), + RenaultSensorEntityDescription( + key="rear_right_pressure", + coordinator="pressure", + data_key="rrPressure", + device_class=SensorDeviceClass.PRESSURE, + entity_class=RenaultSensor[KamereonVehicleTyrePressureData], + native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, + translation_key="rear_right_pressure", + ), ) diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index dabe2f77bac63..851a761fc8b53 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -166,6 +166,18 @@ }, "res_state_code": { "name": "Remote engine start code" + }, + "front_left_pressure": { + "name": "Front left tyre pressure" + }, + "front_right_pressure": { + "name": "Front right tyre pressure" + }, + "rear_left_pressure": { + "name": "Rear left tyre pressure" + }, + "rear_right_pressure": { + "name": "Rear right tyre pressure" } } }, diff --git a/tests/components/renault/conftest.py b/tests/components/renault/conftest.py index ad968358c784d..c8e0c83c42749 100644 --- a/tests/components/renault/conftest.py +++ b/tests/components/renault/conftest.py @@ -131,6 +131,11 @@ def _get_fixtures(vehicle_type: str) -> MappingProxyType: if "res_state" in mock_vehicle["endpoints"] else load_fixture("renault/no_data.json") ).get_attributes(schemas.KamereonVehicleResStateDataSchema), + "pressure": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['pressure']}") + if "pressure" in mock_vehicle["endpoints"] + else load_fixture("renault/no_data.json") + ).get_attributes(schemas.KamereonVehicleTyrePressureDataSchema), } @@ -157,6 +162,9 @@ def patch_get_vehicle_data() -> Generator[dict[str, AsyncMock]]: patch( "renault_api.renault_vehicle.RenaultVehicle.get_res_state" ) as get_res_state, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_tyre_pressure" + ) as get_tyre_pressure, ): yield { "battery_status": get_battery_status, @@ -166,6 +174,7 @@ def patch_get_vehicle_data() -> Generator[dict[str, AsyncMock]]: "location": get_location, "lock_status": get_lock_status, "res_state": get_res_state, + "pressure": get_tyre_pressure, } diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index 259d1b52f6368..fc2428607d4f4 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -31,6 +31,7 @@ "location": "location.json", "lock_status": "lock_status.1.json", "res_state": "res_state.1.json", + "pressure": "pressure.1.json", }, }, "captur_phev": { @@ -58,6 +59,7 @@ "cockpit": "cockpit_ev.json", "hvac_status": "hvac_status.3.json", "location": "location.json", + "pressure": "pressure.1.json", }, }, } diff --git a/tests/components/renault/fixtures/pressure.1.json b/tests/components/renault/fixtures/pressure.1.json new file mode 100644 index 0000000000000..b4c2d2768656b --- /dev/null +++ b/tests/components/renault/fixtures/pressure.1.json @@ -0,0 +1,16 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "flPressure": 2730, + "frPressure": 2790, + "rlPressure": 2340, + "rrPressure": 2460, + "flStatus": 0, + "frStatus": 0, + "rlStatus": 0, + "rrStatus": 0 + } + } +} diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index 3f7c0b637d858..e7b932104b7e4 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -3112,6 +3112,118 @@ 'state': '15', }) # --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_front_left_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_front_left_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front left tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'front_left_pressure', + 'unique_id': 'vf1twingoiiivin_front_left_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_front_left_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-TWINGO-III Front left tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_front_left_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2730', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_front_right_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_front_right_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front right tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'front_right_pressure', + 'unique_id': 'vf1twingoiiivin_front_right_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_front_right_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-TWINGO-III Front right tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_front_right_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2790', + }) +# --- # name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_hvac_soc_threshold-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3484,6 +3596,118 @@ 'state': 'unknown', }) # --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_rear_left_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_rear_left_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rear_left_pressure', + 'unique_id': 'vf1twingoiiivin_rear_left_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_rear_left_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-TWINGO-III Rear left tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_rear_left_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2340', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_rear_right_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_rear_right_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rear_right_pressure', + 'unique_id': 'vf1twingoiiivin_rear_right_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_rear_right_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-TWINGO-III Rear right tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_rear_right_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2460', + }) +# --- # name: test_sensors[zoe_40][sensor.reg_zoe_40_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4613,6 +4837,118 @@ 'state': 'unknown', }) # --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_front_left_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_front_left_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front left tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'front_left_pressure', + 'unique_id': 'vf1zoe50vin_front_left_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_front_left_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-ZOE-50 Front left tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_front_left_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2730', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_front_right_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_front_right_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front right tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'front_right_pressure', + 'unique_id': 'vf1zoe50vin_front_right_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_front_right_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-ZOE-50 Front right tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_front_right_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2790', + }) +# --- # name: test_sensors[zoe_50][sensor.reg_zoe_50_hvac_soc_threshold-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4985,3 +5321,115 @@ 'state': 'unplugged', }) # --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_rear_left_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_rear_left_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rear_left_pressure', + 'unique_id': 'vf1zoe50vin_rear_left_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_rear_left_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-ZOE-50 Rear left tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_rear_left_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2340', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_rear_right_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_rear_right_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rear_right_pressure', + 'unique_id': 'vf1zoe50vin_rear_right_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_rear_right_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-ZOE-50 Rear right tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_rear_right_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2460', + }) +# --- diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index fe2f63331e074..bfd48222fda16 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -197,7 +197,7 @@ async def test_sensor_throttling_after_init( @pytest.mark.parametrize( ("vehicle_type", "vehicle_count", "scan_interval"), [ - ("zoe_50", 1, 240), # 4 coordinators => 4 minutes interval + ("zoe_50", 1, 300), # 5 coordinators => 5 minutes interval ("captur_fuel", 1, 180), # 3 coordinators => 3 minutes interval ("multi", 2, 420), # 7 coordinators => 8 minutes interval ], @@ -236,7 +236,7 @@ async def test_dynamic_scan_interval( @pytest.mark.parametrize( ("vehicle_type", "vehicle_count", "scan_interval"), [ - ("zoe_50", 1, 180), # (6-1) coordinators => 3 minutes interval + ("zoe_50", 1, 240), # (7-1) coordinators => 4 minutes interval ("captur_fuel", 1, 180), # (4-1) coordinators => 3 minutes interval ("multi", 2, 360), # (8-2) coordinators => 6 minutes interval ], From 31857a03d6f6e7f2eac9e7d0e1802154a54f432a Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:35:48 +0200 Subject: [PATCH 46/51] Remove Asuwrt device tracker last_time_reachable extra attribute (#154219) --- homeassistant/components/asuswrt/device_tracker.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index ee6c3f96fc4aa..2781812cca7eb 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -10,8 +10,6 @@ from . import AsusWrtConfigEntry from .router import AsusWrtDevInfo, AsusWrtRouter -ATTR_LAST_TIME_REACHABLE = "last_time_reachable" - DEFAULT_DEVICE_NAME = "Unknown device" @@ -58,8 +56,6 @@ def add_entities( class AsusWrtDevice(ScannerEntity): """Representation of a AsusWrt device.""" - _unrecorded_attributes = frozenset({ATTR_LAST_TIME_REACHABLE}) - _attr_should_poll = False def __init__(self, router: AsusWrtRouter, device: AsusWrtDevInfo) -> None: @@ -97,11 +93,6 @@ def mac_address(self) -> str: def async_on_demand_update(self) -> None: """Update state.""" self._device = self._router.devices[self._device.mac] - self._attr_extra_state_attributes = {} - if self._device.last_activity: - self._attr_extra_state_attributes[ATTR_LAST_TIME_REACHABLE] = ( - self._device.last_activity.isoformat(timespec="seconds") - ) self.async_write_ha_state() async def async_added_to_hass(self) -> None: From 28405e2b049a8419837d487d39b839b5ba4588e2 Mon Sep 17 00:00:00 2001 From: MoonDevLT <107535193+MoonDevLT@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:40:48 +0200 Subject: [PATCH 47/51] Add model name to Lunatone devices (#154432) --- homeassistant/components/lunatone/__init__.py | 1 + homeassistant/components/lunatone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lunatone/__init__.py | 1 + tests/components/lunatone/conftest.py | 3 ++- tests/components/lunatone/test_init.py | 3 ++- 7 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lunatone/__init__.py b/homeassistant/components/lunatone/__init__.py index d507f91a4f3ab..8c74e25e2feec 100644 --- a/homeassistant/components/lunatone/__init__.py +++ b/homeassistant/components/lunatone/__init__.py @@ -45,6 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> hw_version=info_api.data.device.pcb, configuration_url=entry.data[CONF_URL], serial_number=str(info_api.serial_number), + model=info_api.product_name, model_id=( f"{info_api.data.device.article_number}{info_api.data.device.article_info}" ), diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json index 8db658869d545..9cc7f2579ffb8 100644 --- a/homeassistant/components/lunatone/manifest.json +++ b/homeassistant/components/lunatone/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["lunatone-rest-api-client==0.4.8"] + "requirements": ["lunatone-rest-api-client==0.5.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3533b50727a55..a2e2d89fa9b14 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1400,7 +1400,7 @@ loqedAPI==2.1.10 luftdaten==0.7.4 # homeassistant.components.lunatone -lunatone-rest-api-client==0.4.8 +lunatone-rest-api-client==0.5.3 # homeassistant.components.lupusec lupupy==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index acfe8a42d9486..fc2bbe9a04120 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1204,7 +1204,7 @@ loqedAPI==2.1.10 luftdaten==0.7.4 # homeassistant.components.lunatone -lunatone-rest-api-client==0.4.8 +lunatone-rest-api-client==0.5.3 # homeassistant.components.lupusec lupupy==0.3.2 diff --git a/tests/components/lunatone/__init__.py b/tests/components/lunatone/__init__.py index bc9e44d2e099a..b10d3d2df3365 100644 --- a/tests/components/lunatone/__init__.py +++ b/tests/components/lunatone/__init__.py @@ -17,6 +17,7 @@ from tests.common import MockConfigEntry BASE_URL: Final = "http://10.0.0.131" +PRODUCT_NAME: Final = "Test Product" SERIAL_NUMBER: Final = 12345 VERSION: Final = "v1.14.1/1.4.3" diff --git a/tests/components/lunatone/conftest.py b/tests/components/lunatone/conftest.py index 5f60d084788c1..21a80891dd911 100644 --- a/tests/components/lunatone/conftest.py +++ b/tests/components/lunatone/conftest.py @@ -9,7 +9,7 @@ from homeassistant.components.lunatone.const import DOMAIN from homeassistant.const import CONF_URL -from . import BASE_URL, DEVICES_DATA, INFO_DATA, SERIAL_NUMBER +from . import BASE_URL, DEVICES_DATA, INFO_DATA, PRODUCT_NAME, SERIAL_NUMBER from tests.common import MockConfigEntry @@ -68,6 +68,7 @@ def mock_lunatone_info() -> Generator[AsyncMock]: info.name = info.data.name info.version = info.data.version info.serial_number = info.data.device.serial + info.product_name = PRODUCT_NAME yield info diff --git a/tests/components/lunatone/test_init.py b/tests/components/lunatone/test_init.py index 0e063b25adbf9..d8813e332103e 100644 --- a/tests/components/lunatone/test_init.py +++ b/tests/components/lunatone/test_init.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import BASE_URL, VERSION, setup_integration +from . import BASE_URL, PRODUCT_NAME, VERSION, setup_integration from tests.common import MockConfigEntry @@ -33,6 +33,7 @@ async def test_load_unload_config_entry( assert device_entry.manufacturer == "Lunatone" assert device_entry.sw_version == VERSION assert device_entry.configuration_url == BASE_URL + assert device_entry.model == PRODUCT_NAME await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() From 8b6fb05ee4efa3f72cd9e15a7b43975d00b107f6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 14 Oct 2025 16:45:48 +0200 Subject: [PATCH 48/51] Add subentry support for MQTT siren device (#154220) --- homeassistant/components/mqtt/config_flow.py | 71 ++++++++++++++++++++ homeassistant/components/mqtt/const.py | 3 + homeassistant/components/mqtt/siren.py | 17 +++-- homeassistant/components/mqtt/strings.json | 16 +++++ tests/components/mqtt/common.py | 23 +++++++ tests/components/mqtt/test_config_flow.py | 36 ++++++++++ 6 files changed, 157 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 62105459b8913..7fb4455d546d6 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -150,6 +150,7 @@ CONF_ACTION_TOPIC, CONF_AVAILABILITY_TEMPLATE, CONF_AVAILABILITY_TOPIC, + CONF_AVAILABLE_TONES, CONF_BIRTH_MESSAGE, CONF_BLUE_TEMPLATE, CONF_BRIGHTNESS_COMMAND_TEMPLATE, @@ -307,6 +308,8 @@ CONF_STATE_VALUE_TEMPLATE, CONF_STEP, CONF_SUGGESTED_DISPLAY_PRECISION, + CONF_SUPPORT_DURATION, + CONF_SUPPORT_VOLUME_SET, CONF_SUPPORTED_COLOR_MODES, CONF_SUPPORTED_FEATURES, CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE, @@ -460,6 +463,7 @@ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SIREN, Platform.SWITCH, ] @@ -1163,6 +1167,7 @@ def validate_sensor_platform_config( Platform.NOTIFY.value: None, Platform.NUMBER.value: validate_number_platform_config, Platform.SELECT: None, + Platform.SIREN: None, Platform.SENSOR.value: validate_sensor_platform_config, Platform.SWITCH.value: None, } @@ -1419,6 +1424,7 @@ class PlatformField: default=None, ), }, + Platform.SIREN: {}, Platform.SWITCH.value: { CONF_DEVICE_CLASS: PlatformField( selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False @@ -3181,6 +3187,71 @@ class PlatformField: section="advanced_settings", ), }, + Platform.SIREN: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_PAYLOAD_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_OFF, + ), + CONF_PAYLOAD_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_ON, + ), + CONF_STATE_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + ), + CONF_STATE_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + ), + CONF_AVAILABLE_TONES: PlatformField( + selector=OPTIONS_SELECTOR, + required=False, + ), + CONF_SUPPORT_DURATION: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + ), + CONF_SUPPORT_VOLUME_SET: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + ), + CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + CONF_OPTIMISTIC: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + CONF_COMMAND_OFF_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="siren_advanced_settings", + ), + }, Platform.SWITCH.value: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index c77264b91d624..80fbc9059461f 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -30,6 +30,7 @@ CONF_AVAILABILITY_MODE = "availability_mode" CONF_AVAILABILITY_TEMPLATE = "availability_template" CONF_AVAILABILITY_TOPIC = "availability_topic" +CONF_AVAILABLE_TONES = "available_tones" CONF_BROKER = "broker" CONF_BIRTH_MESSAGE = "birth_message" CONF_CODE_ARM_REQUIRED = "code_arm_required" @@ -200,6 +201,8 @@ CONF_STATE_UNLOCKING = "state_unlocking" CONF_STEP = "step" CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" +CONF_SUPPORT_DURATION = "support_duration" +CONF_SUPPORT_VOLUME_SET = "support_volume_set" CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template" CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic" diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 48ab4676dea90..b4102b054a555 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -39,10 +39,18 @@ from . import subscription from .config import MQTT_RW_SCHEMA from .const import ( + CONF_AVAILABLE_TONES, + CONF_COMMAND_OFF_TEMPLATE, CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_STATE_OFF, + CONF_STATE_ON, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, + CONF_SUPPORT_DURATION, + CONF_SUPPORT_VOLUME_SET, + DEFAULT_PAYLOAD_OFF, + DEFAULT_PAYLOAD_ON, PAYLOAD_EMPTY_JSON, PAYLOAD_NONE, ) @@ -58,18 +66,9 @@ PARALLEL_UPDATES = 0 DEFAULT_NAME = "MQTT Siren" -DEFAULT_PAYLOAD_ON = "ON" -DEFAULT_PAYLOAD_OFF = "OFF" ENTITY_ID_FORMAT = siren.DOMAIN + ".{}" -CONF_AVAILABLE_TONES = "available_tones" -CONF_COMMAND_OFF_TEMPLATE = "command_off_template" -CONF_STATE_ON = "state_on" -CONF_STATE_OFF = "state_off" -CONF_SUPPORT_DURATION = "support_duration" -CONF_SUPPORT_VOLUME_SET = "support_volume_set" - STATE = "state" PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 438d64c48dfd7..a6bd64cb8734d 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -318,6 +318,7 @@ "title": "Configure MQTT device \"{mqtt_device}\"", "description": "Please configure MQTT specific details for {platform} entity \"{entity}\":", "data": { + "available_tones": "Available Tones", "blue_template": "Blue template", "brightness_template": "Brightness template", "code": "Alarm code", @@ -360,12 +361,15 @@ "state_topic": "State topic", "state_value_template": "State value template", "step": "Step", + "support_duration": "Duration support", + "support_volume_set": "Set volume support", "supported_color_modes": "Supported color modes", "url_template": "URL template", "url_topic": "URL topic", "value_template": "Value template" }, "data_description": { + "available_tones": "The siren supports tones. The `tone` variable will become available for use in the \"Command template\" setting. [Learn more.]({url}#available_tones)", "blue_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract blue color from the state payload value. Expected result of the template is an integer from 0-255 range.", "brightness_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract brightness from the state payload value. Expected result of the template is an integer from 0-255 range.", "code": "Specifies a code to enable or disable the alarm in the frontend. Note that this blocks sending MQTT message commands to the remote device if the code validation fails. [Learn more.]({url}#code)", @@ -407,6 +411,8 @@ "state_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract state from the state payload value.", "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)", "step": "Step value. Smallest value 0.001.", + "support_duration": "The siren supports setting a duration in second. The `duration` variable will become available for use in the \"Command template\" setting. [Learn more.]({url}#support_duration)", + "support_volume_set": "The siren supports setting a volume. The `tone` variable will become available for use in the \"Command template\" setting. [Learn more.]({url}#support_volume_set)", "supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)", "url_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract an URL from the received URL topic payload value. [Learn more.]({url}#url_template)", "url_topic": "The MQTT topic subscribed to receive messages containing the image URL. [Learn more.]({url}#url_topic)", @@ -910,6 +916,15 @@ "target_humidity_state_topic": "The MQTT topic to subscribe for changes of the target humidity. [Learn more.]({url}#humidity_state_topic)" } }, + "siren_advanced_settings": { + "name": "Advanced siren settings", + "data": { + "command_off_template": "Command \"off\" template" + }, + "data_description": { + "command_off_template": "The [template]({templating_url}#using-command-templates-with-mqtt) for \"off\" state changes. By default the \"[Command template]({url}#command_template)\" will be used. [Learn more.]({url}#command_off_template)" + } + }, "target_temperature_settings": { "name": "Target temperature settings", "data": { @@ -1338,6 +1353,7 @@ "number": "[%key:component::number::title%]", "select": "[%key:component::select::title%]", "sensor": "[%key:component::sensor::title%]", + "siren": "[%key:component::siren::title%]", "switch": "[%key:component::switch::title%]" } }, diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 0451ef25d148c..5b02453e44e0c 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -565,6 +565,25 @@ "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", }, } +MOCK_SUBENTRY_SIREN_COMPONENT = { + "3faf1318023c46c5aea26707eeb6f12e": { + "platform": "siren", + "name": "Siren", + "entity_category": None, + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{ value }}", + "command_off_template": "{{ value }}", + "value_template": "{{ value_json.value }}", + "payload_off": "OFF", + "payload_on": "ON", + "available_tones": ["Happy hour", "Cooling alarm"], + "support_volume_set": True, + "support_duration": True, + "entity_picture": "https://example.com/3faf1318023c46c5aea26707eeb6f12e", + "optimistic": True, + }, +} MOCK_SUBENTRY_SWITCH_COMPONENT = { "3faf1318016c46c5aea26707eeb6f12e": { "platform": "switch", @@ -698,6 +717,10 @@ "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET, } +MOCK_SIREN_SUBENTRY_DATA = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_SIREN_COMPONENT, +} MOCK_SWITCH_SUBENTRY_DATA = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SWITCH_COMPONENT, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 6185e2ffae18e..03d669c2d38c0 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -60,6 +60,7 @@ MOCK_SENSOR_SUBENTRY_DATA, MOCK_SENSOR_SUBENTRY_DATA_LAST_RESET_TEMPLATE, MOCK_SENSOR_SUBENTRY_DATA_STATE_CLASS, + MOCK_SIREN_SUBENTRY_DATA, MOCK_SWITCH_SUBENTRY_DATA, ) @@ -3655,6 +3656,41 @@ async def test_migrate_of_incompatible_config_entry( "Milk notifier Energy", id="sensor_total", ), + pytest.param( + MOCK_SIREN_SUBENTRY_DATA, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Siren"}, + {}, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "value_template": "{{ value_json.value }}", + "optimistic": True, + "available_tones": ["Happy hour", "Cooling alarm"], + "support_duration": True, + "support_volume_set": True, + "siren_advanced_settings": { + "command_off_template": "{{ value }}", + }, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + }, + {"state_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Siren", + id="siren", + ), pytest.param( MOCK_SWITCH_SUBENTRY_DATA, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, From b6337c07d6dd86394eb266d524f97073ecd4c2bb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:52:12 +0200 Subject: [PATCH 49/51] Update intellifire4py to 4.2.1 (#154454) --- homeassistant/components/intellifire/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json index e3ee663e8fecf..d258c59eb9cec 100644 --- a/homeassistant/components/intellifire/manifest.json +++ b/homeassistant/components/intellifire/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/intellifire", "iot_class": "local_polling", "loggers": ["intellifire4py"], - "requirements": ["intellifire4py==4.1.9"] + "requirements": ["intellifire4py==4.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a2e2d89fa9b14..0e893d95559ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1270,7 +1270,7 @@ inkbird-ble==1.1.0 insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire -intellifire4py==4.1.9 +intellifire4py==4.2.1 # homeassistant.components.iometer iometer==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc2bbe9a04120..b287dfe6ce7f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1107,7 +1107,7 @@ inkbird-ble==1.1.0 insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire -intellifire4py==4.1.9 +intellifire4py==4.2.1 # homeassistant.components.iometer iometer==0.2.0 From 1d6c6628f42e4a6c1b6e3b18a901a7f58ac6879c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Oct 2025 17:18:25 +0200 Subject: [PATCH 50/51] Migrate onewire to async library (#154439) --- homeassistant/components/onewire/__init__.py | 6 +-- .../components/onewire/config_flow.py | 10 ++-- homeassistant/components/onewire/entity.py | 20 +++---- .../components/onewire/manifest.json | 4 +- .../components/onewire/onewirehub.py | 54 ++++++++++--------- homeassistant/components/onewire/select.py | 4 +- homeassistant/components/onewire/sensor.py | 20 ++++--- homeassistant/components/onewire/switch.py | 8 +-- requirements_all.txt | 6 +-- requirements_test_all.txt | 6 +-- tests/components/onewire/__init__.py | 8 +-- tests/components/onewire/conftest.py | 16 +++--- tests/components/onewire/const.py | 14 ++--- tests/components/onewire/test_config_flow.py | 28 +++++----- tests/components/onewire/test_init.py | 11 ++-- tests/components/onewire/test_sensor.py | 10 ++-- 16 files changed, 117 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index 396539d93e3b6..72c520d5a7f88 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -2,7 +2,7 @@ import logging -from pyownet import protocol +from aio_ownet.exceptions import OWServerConnectionError, OWServerReturnError from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -28,8 +28,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> b try: await onewire_hub.initialize() except ( - protocol.ConnError, # Failed to connect to the server - protocol.OwnetError, # Connected to server, but failed to list the devices + OWServerConnectionError, # Failed to connect to the server + OWServerReturnError, # Connected to server, but failed to list the devices ) as exc: raise ConfigEntryNotReady from exc diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 0f2a2b6c51c32..f10692061ae9f 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -5,7 +5,8 @@ from copy import deepcopy from typing import Any -from pyownet import protocol +from aio_ownet.exceptions import OWServerConnectionError +from aio_ownet.proxy import OWServerStatelessProxy import voluptuous as vol from homeassistant.config_entries import ( @@ -45,11 +46,10 @@ async def validate_input( hass: HomeAssistant, data: dict[str, Any], errors: dict[str, str] ) -> None: """Validate the user input allows us to connect.""" + proxy = OWServerStatelessProxy(data[CONF_HOST], data[CONF_PORT]) try: - await hass.async_add_executor_job( - protocol.proxy, data[CONF_HOST], data[CONF_PORT] - ) - except protocol.ConnError: + await proxy.validate() + except OWServerConnectionError: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/onewire/entity.py b/homeassistant/components/onewire/entity.py index c66ec3bef15e5..9adc046acc1fe 100644 --- a/homeassistant/components/onewire/entity.py +++ b/homeassistant/components/onewire/entity.py @@ -6,7 +6,8 @@ import logging from typing import Any -from pyownet import protocol +from aio_ownet.exceptions import OWServerError +from aio_ownet.proxy import OWServerStatelessProxy from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription @@ -36,7 +37,7 @@ def __init__( device_id: str, device_info: DeviceInfo, device_file: str, - owproxy: protocol._Proxy, + owproxy: OWServerStatelessProxy, ) -> None: """Initialize the entity.""" self.entity_description = description @@ -55,20 +56,19 @@ def extra_state_attributes(self) -> dict[str, Any] | None: "device_file": self._device_file, } - def _read_value(self) -> str: + async def _read_value(self) -> str: """Read a value from the server.""" - read_bytes: bytes = self._owproxy.read(self._device_file) - return read_bytes.decode().lstrip() + return (await self._owproxy.read(self._device_file)).decode().lstrip() - def _write_value(self, value: bytes) -> None: + async def _write_value(self, value: bytes) -> None: """Write a value to the server.""" - self._owproxy.write(self._device_file, value) + await self._owproxy.write(self._device_file, value) - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data from the device.""" try: - self._value_raw = float(self._read_value()) - except protocol.Error as exc: + self._value_raw = float(await self._read_value()) + except OWServerError as exc: if self._last_update_success: _LOGGER.error("Error fetching %s data: %s", self.name, exc) self._last_update_success = False diff --git a/homeassistant/components/onewire/manifest.json b/homeassistant/components/onewire/manifest.json index 844c4c1afb993..80d3a6fdc3c93 100644 --- a/homeassistant/components/onewire/manifest.json +++ b/homeassistant/components/onewire/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/onewire", "integration_type": "hub", "iot_class": "local_polling", - "loggers": ["pyownet"], - "requirements": ["pyownet==0.10.0.post1"], + "loggers": ["aio_ownet"], + "requirements": ["aio-ownet==0.0.3"], "zeroconf": ["_owserver._tcp.local."] } diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index dc894a4242e1a..b03b2fa3c683a 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -7,7 +7,9 @@ import logging import os -from pyownet import protocol +from aio_ownet.definitions import OWServerCommonPath +from aio_ownet.exceptions import OWServerProtocolError, OWServerReturnError +from aio_ownet.proxy import OWServerStatelessProxy from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_VIA_DEVICE, CONF_HOST, CONF_PORT @@ -57,7 +59,7 @@ def _is_known_device(device_family: str, device_type: str | None) -> bool: class OneWireHub: """Hub to communicate with server.""" - owproxy: protocol._Proxy + owproxy: OWServerStatelessProxy devices: list[OWDeviceDescription] _version: str | None = None @@ -66,23 +68,25 @@ def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> Non self._hass = hass self._config_entry = config_entry - def _initialize(self) -> None: - """Connect to the server, and discover connected devices. - - Needs to be run in executor. - """ + async def _initialize(self) -> None: + """Connect to the server, and discover connected devices.""" host = self._config_entry.data[CONF_HOST] port = self._config_entry.data[CONF_PORT] _LOGGER.debug("Initializing connection to %s:%s", host, port) - self.owproxy = protocol.proxy(host, port) - with contextlib.suppress(protocol.OwnetError): + self.owproxy = OWServerStatelessProxy( + self._config_entry.data[CONF_HOST], self._config_entry.data[CONF_PORT] + ) + await self.owproxy.validate() + with contextlib.suppress(OWServerReturnError): # Version is not available on all servers - self._version = self.owproxy.read(protocol.PTH_VERSION).decode() - self.devices = _discover_devices(self.owproxy) + self._version = ( + await self.owproxy.read(OWServerCommonPath.VERSION) + ).decode() + self.devices = await _discover_devices(self.owproxy) async def initialize(self) -> None: """Initialize a config entry.""" - await self._hass.async_add_executor_job(self._initialize) + await self._initialize() self._populate_device_registry(self.devices) @callback @@ -106,9 +110,7 @@ def schedule_scan_for_new_devices(self) -> None: async def _scan_for_new_devices(self, _: datetime) -> None: """Scan the bus for new devices.""" - devices = await self._hass.async_add_executor_job( - _discover_devices, self.owproxy - ) + devices = await _discover_devices(self.owproxy) existing_device_ids = [device.id for device in self.devices] new_devices = [ device for device in devices if device.id not in existing_device_ids @@ -121,16 +123,16 @@ async def _scan_for_new_devices(self, _: datetime) -> None: ) -def _discover_devices( - owproxy: protocol._Proxy, path: str = "/", parent_id: str | None = None +async def _discover_devices( + owproxy: OWServerStatelessProxy, path: str = "/", parent_id: str | None = None ) -> list[OWDeviceDescription]: """Discover all server devices.""" devices: list[OWDeviceDescription] = [] - for device_path in owproxy.dir(path): + for device_path in await owproxy.dir(path): device_id = os.path.split(os.path.split(device_path)[0])[1] - device_family = owproxy.read(f"{device_path}family").decode() + device_family = (await owproxy.read(f"{device_path}family")).decode() _LOGGER.debug("read `%sfamily`: %s", device_path, device_family) - device_type = _get_device_type(owproxy, device_path) + device_type = await _get_device_type(owproxy, device_path) if not _is_known_device(device_family, device_type): _LOGGER.warning( "Ignoring unknown device family/type (%s/%s) found for device %s", @@ -159,22 +161,24 @@ def _discover_devices( devices.append(device) if device_branches := DEVICE_COUPLERS.get(device_family): for branch in device_branches: - devices += _discover_devices( + devices += await _discover_devices( owproxy, f"{device_path}{branch}", device_id ) return devices -def _get_device_type(owproxy: protocol._Proxy, device_path: str) -> str | None: +async def _get_device_type( + owproxy: OWServerStatelessProxy, device_path: str +) -> str | None: """Get device model.""" try: - device_type: str = owproxy.read(f"{device_path}type").decode() - except protocol.ProtocolError as exc: + device_type = (await owproxy.read(f"{device_path}type")).decode() + except OWServerProtocolError as exc: _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc) return None _LOGGER.debug("read `%stype`: %s", device_path, device_type) if device_type == "EDS": - device_type = owproxy.read(f"{device_path}device_type").decode() + device_type = (await owproxy.read(f"{device_path}device_type")).decode() _LOGGER.debug("read `%sdevice_type`: %s", device_path, device_type) return device_type diff --git a/homeassistant/components/onewire/select.py b/homeassistant/components/onewire/select.py index 7f4111243aa78..7a33471dbe5a4 100644 --- a/homeassistant/components/onewire/select.py +++ b/homeassistant/components/onewire/select.py @@ -105,6 +105,6 @@ def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return str(self._state) - def select_option(self, option: str) -> None: + async def async_select_option(self, option: str) -> None: """Change the selected option.""" - self._write_value(option.encode("ascii")) + await self._write_value(option.encode("ascii")) diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 7039dc098580b..ee0a3cbacbbfb 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -9,7 +9,7 @@ import os from typing import Any -from pyownet import protocol +from aio_ownet.exceptions import OWServerReturnError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -397,11 +397,7 @@ async def _add_entities( """Add 1-Wire entities for all devices.""" if not devices: return - # note: we have to go through the executor as SENSOR platform - # makes extra calls to the hub during device listing - entities = await hass.async_add_executor_job( - get_entities, hub, devices, config_entry.options - ) + entities = await get_entities(hub, devices, config_entry.options) async_add_entities(entities, True) hub = config_entry.runtime_data @@ -411,7 +407,7 @@ async def _add_entities( ) -def get_entities( +async def get_entities( onewire_hub: OneWireHub, devices: list[OWDeviceDescription], options: Mapping[str, Any], @@ -441,8 +437,10 @@ def get_entities( if description.key.startswith("moisture/"): s_id = description.key.split(".")[1] is_leaf = int( - onewire_hub.owproxy.read( - f"{device_path}moisture/is_leaf.{s_id}" + ( + await onewire_hub.owproxy.read( + f"{device_path}moisture/is_leaf.{s_id}" + ) ).decode() ) if is_leaf: @@ -463,8 +461,8 @@ def get_entities( if family == "12": # We need to check if there is TAI8570 plugged in try: - onewire_hub.owproxy.read(device_file) - except protocol.OwnetError as err: + await onewire_hub.owproxy.read(device_file) + except OWServerReturnError as err: _LOGGER.debug( "Ignoring unreachable sensor %s", device_file, diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index aeea0b8e98b76..23f85714136d8 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -220,10 +220,10 @@ def is_on(self) -> bool | None: return None return self._state == 1 - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - self._write_value(b"1") + await self._write_value(b"1") - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - self._write_value(b"0") + await self._write_value(b"0") diff --git a/requirements_all.txt b/requirements_all.txt index 0e893d95559ba..bd526904c8e23 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -175,6 +175,9 @@ aio-geojson-usgs-earthquakes==0.3 # homeassistant.components.gdacs aio-georss-gdacs==0.10 +# homeassistant.components.onewire +aio-ownet==0.0.3 + # homeassistant.components.acaia aioacaia==0.1.17 @@ -2268,9 +2271,6 @@ pyotp==2.9.0 # homeassistant.components.overkiz pyoverkiz==1.19.0 -# homeassistant.components.onewire -pyownet==0.10.0.post1 - # homeassistant.components.palazzetti pypalazzetti==0.1.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b287dfe6ce7f2..bba0afb793b5d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -163,6 +163,9 @@ aio-geojson-usgs-earthquakes==0.3 # homeassistant.components.gdacs aio-georss-gdacs==0.10 +# homeassistant.components.onewire +aio-ownet==0.0.3 + # homeassistant.components.acaia aioacaia==0.1.17 @@ -1898,9 +1901,6 @@ pyotp==2.9.0 # homeassistant.components.overkiz pyoverkiz==1.19.0 -# homeassistant.components.onewire -pyownet==0.10.0.post1 - # homeassistant.components.palazzetti pypalazzetti==0.1.19 diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index 595b660b72200..cc58f57e74b12 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import MagicMock -from pyownet.protocol import ProtocolError +from aio_ownet.exceptions import OWServerProtocolError from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES @@ -23,7 +23,7 @@ def setup_owproxy_mock_devices(owproxy: MagicMock, device_ids: list[str]) -> Non for device_id in device_ids: _setup_owproxy_mock_device(dir_side_effect, read_side_effect, device_id) - def _dir(path: str) -> Any: + async def _dir(path: str) -> Any: if (side_effect := dir_side_effect.get(path)) is None: raise NotImplementedError(f"Unexpected _dir call: {path}") result = side_effect.pop(0) @@ -33,11 +33,11 @@ def _dir(path: str) -> Any: raise result return result - def _read(path: str) -> Any: + async def _read(path: str) -> Any: if (side_effect := read_side_effect.get(path)) is None: raise NotImplementedError(f"Unexpected _read call: {path}") if len(side_effect) == 0: - raise ProtocolError(f"Missing injected value for: {path}") + raise OWServerProtocolError(f"Missing injected value for: {path}") result = side_effect.pop(0) if isinstance(result, Exception) or ( isinstance(result, type) and issubclass(result, Exception) diff --git a/tests/components/onewire/conftest.py b/tests/components/onewire/conftest.py index 9d4303eaa1c66..35d319d580a06 100644 --- a/tests/components/onewire/conftest.py +++ b/tests/components/onewire/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from pyownet.protocol import ConnError +from aio_ownet.exceptions import OWServerConnectionError import pytest from homeassistant.components.onewire.const import DOMAIN @@ -56,15 +56,15 @@ def get_config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture(name="owproxy") def get_owproxy() -> Generator[MagicMock]: """Mock owproxy.""" - with patch("homeassistant.components.onewire.onewirehub.protocol.proxy") as owproxy: + with patch( + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy", + autospec=True, + ) as owproxy: yield owproxy @pytest.fixture(name="owproxy_with_connerror") -def get_owproxy_with_connerror() -> Generator[MagicMock]: +def get_owproxy_with_connerror(owproxy: MagicMock) -> MagicMock: """Mock owproxy.""" - with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", - side_effect=ConnError, - ) as owproxy: - yield owproxy + owproxy.return_value.validate.side_effect = OWServerConnectionError + return owproxy diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 32804bca28ec1..c113bd592ede8 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -1,6 +1,6 @@ """Constants for 1-Wire integration.""" -from pyownet.protocol import ProtocolError +from aio_ownet.exceptions import OWServerProtocolError ATTR_INJECT_READS = "inject_reads" @@ -49,7 +49,7 @@ }, "16.111111111111": { # Test case for issue #115984, where the device type cannot be read - ATTR_INJECT_READS: {"/type": [ProtocolError()]}, + ATTR_INJECT_READS: {"/type": [OWServerProtocolError()]}, }, "1F.111111111111": { ATTR_INJECT_READS: {"/type": [b"DS2409"]}, @@ -82,7 +82,7 @@ "22.111111111111": { ATTR_INJECT_READS: { "/type": [b"DS1822"], - "/temperature": [ProtocolError], + "/temperature": [OWServerProtocolError], }, }, "26.111111111111": { @@ -93,7 +93,7 @@ "/HIH3600/humidity": [b" 73.7563"], "/HIH4000/humidity": [b" 74.7563"], "/HIH5030/humidity": [b" 75.7563"], - "/HTM1735/humidity": [ProtocolError], + "/HTM1735/humidity": [OWServerProtocolError], "/B1-R1-A/pressure": [b" 969.265"], "/S3-R1-A/illuminance": [b" 65.8839"], "/VAD": [b" 2.97"], @@ -129,7 +129,7 @@ "/PIO.0": [b" 1"], "/PIO.1": [b" 0"], "/PIO.2": [b" 1"], - "/PIO.3": [ProtocolError], + "/PIO.3": [OWServerProtocolError], "/PIO.4": [b" 1"], "/PIO.5": [b" 0"], "/PIO.6": [b" 1"], @@ -145,7 +145,7 @@ "/sensed.0": [b" 1"], "/sensed.1": [b" 0"], "/sensed.2": [b" 0"], - "/sensed.3": [ProtocolError], + "/sensed.3": [OWServerProtocolError], "/sensed.4": [b" 0"], "/sensed.5": [b" 0"], "/sensed.6": [b" 0"], @@ -190,7 +190,7 @@ "/HIH3600/humidity": [b" 73.7563"], "/HIH4000/humidity": [b" 74.7563"], "/HIH5030/humidity": [b" 75.7563"], - "/HTM1735/humidity": [ProtocolError], + "/HTM1735/humidity": [OWServerProtocolError], "/B1-R1-A/pressure": [b" 969.265"], "/S3-R1-A/illuminance": [b" 65.8839"], "/VAD": [b" 2.97"], diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index 65bdaafc1311d..68edaef51f5f9 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -3,7 +3,7 @@ from ipaddress import ip_address from unittest.mock import AsyncMock, patch -from pyownet import protocol +from aio_ownet.exceptions import OWServerConnectionError import pytest from homeassistant.components.onewire.const import ( @@ -65,7 +65,7 @@ async def test_user_flow(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -86,8 +86,8 @@ async def test_user_flow_recovery(hass: HomeAssistant) -> None: # Invalid server with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", - side_effect=protocol.ConnError, + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", + side_effect=OWServerConnectionError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -100,7 +100,7 @@ async def test_user_flow_recovery(hass: HomeAssistant) -> None: # Valid server with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -148,8 +148,8 @@ async def test_reconfigure_flow( # Invalid server with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", - side_effect=protocol.ConnError, + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", + side_effect=OWServerConnectionError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -162,7 +162,7 @@ async def test_reconfigure_flow( # Valid server with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -222,8 +222,8 @@ async def test_hassio_flow(hass: HomeAssistant) -> None: # Cannot connect to server => retry with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", - side_effect=protocol.ConnError, + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", + side_effect=OWServerConnectionError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -236,7 +236,7 @@ async def test_hassio_flow(hass: HomeAssistant) -> None: # Connect OK with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -274,8 +274,8 @@ async def test_zeroconf_flow(hass: HomeAssistant) -> None: # Cannot connect to server => retry with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", - side_effect=protocol.ConnError, + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", + side_effect=OWServerConnectionError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -288,7 +288,7 @@ async def test_zeroconf_flow(hass: HomeAssistant) -> None: # Connect OK with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index ace7afb56451e..2716c579036d7 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -2,8 +2,8 @@ from unittest.mock import MagicMock, patch +from aio_ownet.exceptions import OWServerReturnError from freezegun.api import FrozenDateTimeFactory -from pyownet import protocol import pytest from syrupy.assertion import SnapshotAssertion @@ -38,7 +38,8 @@ async def test_listing_failure( hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock ) -> None: """Test listing failure raises ConfigEntryNotReady.""" - owproxy.return_value.dir.side_effect = protocol.OwnetError() + owproxy.return_value.read.side_effect = OWServerReturnError(-1) + owproxy.return_value.dir.side_effect = OWServerReturnError(-1) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -47,9 +48,11 @@ async def test_listing_failure( assert config_entry.state is ConfigEntryState.SETUP_RETRY -@pytest.mark.usefixtures("owproxy") -async def test_unload_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_unload_entry( + hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock +) -> None: """Test being able to unload an entry.""" + setup_owproxy_mock_devices(owproxy, []) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index f1ef2dfa11bdb..db63196ef3dec 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -5,8 +5,8 @@ import logging from unittest.mock import MagicMock, _patch_dict, patch +from aio_ownet.exceptions import OWServerReturnError from freezegun.api import FrozenDateTimeFactory -from pyownet.protocol import OwnetError import pytest from syrupy.assertion import SnapshotAssertion @@ -85,8 +85,12 @@ async def test_tai8570_sensors( """ mock_devices = deepcopy(MOCK_OWPROXY_DEVICES) mock_device = mock_devices[device_id] - mock_device[ATTR_INJECT_READS]["/TAI8570/temperature"] = [OwnetError] - mock_device[ATTR_INJECT_READS]["/TAI8570/pressure"] = [OwnetError] + mock_device[ATTR_INJECT_READS]["/TAI8570/temperature"] = [ + OWServerReturnError(2, "legacy", "/12.111111111111/TAI8570/temperature") + ] + mock_device[ATTR_INJECT_READS]["/TAI8570/pressure"] = [ + OWServerReturnError(2, "legacy", "/12.111111111111/TAI8570/pressure") + ] with _patch_dict(MOCK_OWPROXY_DEVICES, mock_devices): setup_owproxy_mock_devices(owproxy, [device_id]) From 681eb6b59449e6b84926ccfa3ad93ecae764d73c Mon Sep 17 00:00:00 2001 From: Sebastian Schneider Date: Tue, 14 Oct 2025 17:20:47 +0200 Subject: [PATCH 51/51] Add LED control for supported UniFi network devices (#152649) --- homeassistant/components/unifi/const.py | 1 + homeassistant/components/unifi/light.py | 172 ++++++++++ homeassistant/components/unifi/strings.json | 5 + .../unifi/snapshots/test_light.ambr | 72 ++++ tests/components/unifi/test_hub.py | 1 + tests/components/unifi/test_light.py | 323 ++++++++++++++++++ 6 files changed, 574 insertions(+) create mode 100644 homeassistant/components/unifi/light.py create mode 100644 tests/components/unifi/snapshots/test_light.ambr create mode 100644 tests/components/unifi/test_light.py diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index bbd03b070a477..decbc8bb52314 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -13,6 +13,7 @@ Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.IMAGE, + Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/unifi/light.py b/homeassistant/components/unifi/light.py new file mode 100644 index 0000000000000..9327dcc160e07 --- /dev/null +++ b/homeassistant/components/unifi/light.py @@ -0,0 +1,172 @@ +"""Light platform for UniFi Network integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast + +from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent +from aiounifi.interfaces.devices import Devices +from aiounifi.models.api import ApiItem +from aiounifi.models.device import Device, DeviceSetLedStatus + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_RGB_COLOR, + ColorMode, + LightEntity, + LightEntityDescription, + LightEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import rgb_hex_to_rgb_list + +from . import UnifiConfigEntry +from .entity import ( + UnifiEntity, + UnifiEntityDescription, + async_device_available_fn, + async_device_device_info_fn, +) + +if TYPE_CHECKING: + from .hub import UnifiHub + + +@callback +def async_device_led_supported_fn(hub: UnifiHub, obj_id: str) -> bool: + """Check if device supports LED control.""" + device: Device = hub.api.devices[obj_id] + return device.supports_led_ring + + +@callback +def async_device_led_is_on_fn(hub: UnifiHub, device: Device) -> bool: + """Check if device LED is on.""" + return device.led_override == "on" + + +async def async_device_led_control_fn( + hub: UnifiHub, obj_id: str, turn_on: bool, **kwargs: Any +) -> None: + """Control device LED.""" + device = hub.api.devices[obj_id] + + status = "on" if turn_on else "off" + + brightness = ( + int((kwargs[ATTR_BRIGHTNESS] / 255) * 100) + if ATTR_BRIGHTNESS in kwargs + else device.led_override_color_brightness + ) + + color = ( + f"#{kwargs[ATTR_RGB_COLOR][0]:02x}{kwargs[ATTR_RGB_COLOR][1]:02x}{kwargs[ATTR_RGB_COLOR][2]:02x}" + if ATTR_RGB_COLOR in kwargs + else device.led_override_color + ) + + await hub.api.request( + DeviceSetLedStatus.create( + device=device, + status=status, + brightness=brightness, + color=color, + ) + ) + + +@dataclass(frozen=True, kw_only=True) +class UnifiLightEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( + LightEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT] +): + """Class describing UniFi light entity.""" + + control_fn: Callable[[UnifiHub, str, bool], Coroutine[Any, Any, None]] + is_on_fn: Callable[[UnifiHub, ApiItemT], bool] + + +ENTITY_DESCRIPTIONS: tuple[UnifiLightEntityDescription, ...] = ( + UnifiLightEntityDescription[Devices, Device]( + key="LED control", + translation_key="led_control", + allowed_fn=lambda hub, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + control_fn=async_device_led_control_fn, + device_info_fn=async_device_device_info_fn, + is_on_fn=async_device_led_is_on_fn, + name_fn=lambda device: "LED", + object_fn=lambda api, obj_id: api.devices[obj_id], + supported_fn=async_device_led_supported_fn, + unique_id_fn=lambda hub, obj_id: f"led-{obj_id}", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: UnifiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up lights for UniFi Network integration.""" + config_entry.runtime_data.entity_loader.register_platform( + async_add_entities, + UnifiLightEntity, + ENTITY_DESCRIPTIONS, + requires_admin=True, + ) + + +class UnifiLightEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( + UnifiEntity[HandlerT, ApiItemT], LightEntity +): + """Base representation of a UniFi light.""" + + entity_description: UnifiLightEntityDescription[HandlerT, ApiItemT] + _attr_supported_features = LightEntityFeature(0) + _attr_color_mode = ColorMode.RGB + _attr_supported_color_modes = {ColorMode.RGB} + + @callback + def async_initiate_state(self) -> None: + """Initiate entity state.""" + self.async_update_state(ItemEvent.ADDED, self._obj_id) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on light.""" + await self.entity_description.control_fn(self.hub, self._obj_id, True, **kwargs) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off light.""" + await self.entity_description.control_fn( + self.hub, self._obj_id, False, **kwargs + ) + + @callback + def async_update_state(self, event: ItemEvent, obj_id: str) -> None: + """Update entity state.""" + description = self.entity_description + device_obj = description.object_fn(self.api, self._obj_id) + + device = cast(Device, device_obj) + + self._attr_is_on = description.is_on_fn(self.hub, device_obj) + + brightness = device.led_override_color_brightness + self._attr_brightness = ( + int((int(brightness) / 100) * 255) if brightness is not None else None + ) + + hex_color = ( + device.led_override_color.lstrip("#") + if self._attr_is_on and device.led_override_color + else None + ) + if hex_color and len(hex_color) == 6: + rgb_list = rgb_hex_to_rgb_list(hex_color) + self._attr_rgb_color = (rgb_list[0], rgb_list[1], rgb_list[2]) + else: + self._attr_rgb_color = None diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 5b88055e62a2e..3e357ab645fe9 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -34,6 +34,11 @@ } }, "entity": { + "light": { + "led_control": { + "name": "LED" + } + }, "sensor": { "device_state": { "state": { diff --git a/tests/components/unifi/snapshots/test_light.ambr b/tests/components/unifi/snapshots/test_light.ambr new file mode 100644 index 0000000000000..fc9d972f9be3e --- /dev/null +++ b/tests/components/unifi/snapshots/test_light.ambr @@ -0,0 +1,72 @@ +# serializer version: 1 +# name: test_light_platform_snapshot[device_payload0][light.device_with_led_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.device_with_led_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED', + 'platform': 'unifi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'led_control', + 'unique_id': 'led-10:00:00:00:01:01', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_platform_snapshot[device_payload0][light.device_with_led_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 204, + 'color_mode': , + 'friendly_name': 'Device with LED LED', + 'hs_color': tuple( + 240.0, + 100.0, + ), + 'rgb_color': tuple( + 0, + 0, + 255, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.136, + 0.04, + ), + }), + 'context': , + 'entity_id': 'light.device_with_led_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index 897eab2ae12b0..78f9d484619c1 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -41,6 +41,7 @@ async def test_hub_setup( Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.IMAGE, + Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/tests/components/unifi/test_light.py b/tests/components/unifi/test_light.py new file mode 100644 index 0000000000000..6ee40c9a91d03 --- /dev/null +++ b/tests/components/unifi/test_light.py @@ -0,0 +1,323 @@ +"""UniFi Network light platform tests.""" + +from copy import deepcopy +from unittest.mock import patch + +from aiounifi.models.message import MessageKey +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.unifi.const import CONF_SITE_ID +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + ConfigEntryFactoryType, + WebsocketMessageMock, + WebsocketStateManager, +) + +from tests.common import MockConfigEntry, snapshot_platform +from tests.test_util.aiohttp import AiohttpClientMocker + +DEVICE_WITH_LED = { + "board_rev": 3, + "device_id": "mock-id", + "ip": "10.0.0.1", + "last_seen": 1562600145, + "mac": "10:00:00:00:01:01", + "model": "U6-Lite", + "name": "Device with LED", + "next_interval": 20, + "state": 1, + "type": "uap", + "version": "4.0.42.10433", + "led_override": "on", + "led_override_color": "#0000ff", + "led_override_color_brightness": 80, + "hw_caps": 2, +} + +DEVICE_WITHOUT_LED = { + "board_rev": 2, + "device_id": "mock-id-2", + "ip": "10.0.0.2", + "last_seen": 1562600145, + "mac": "10:00:00:00:01:02", + "model": "US-8-60W", + "name": "Device without LED", + "next_interval": 20, + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + "hw_caps": 0, +} + +DEVICE_LED_OFF = { + "board_rev": 3, + "device_id": "mock-id-3", + "ip": "10.0.0.3", + "last_seen": 1562600145, + "mac": "10:00:00:00:01:03", + "model": "U6-Pro", + "name": "Device LED Off", + "next_interval": 20, + "state": 1, + "type": "uap", + "version": "4.0.42.10433", + "led_override": "off", + "led_override_color": "#ffffff", + "led_override_color_brightness": 0, + "hw_caps": 2, +} + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED, DEVICE_WITHOUT_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_lights( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test lights.""" + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_ON + assert light_entity.attributes["brightness"] == 204 + assert light_entity.attributes["rgb_color"] == (0, 0, 255) + + assert hass.states.get("light.device_without_led_led") is None + + +@pytest.mark.parametrize("device_payload", [[DEVICE_LED_OFF]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_off_state( + hass: HomeAssistant, +) -> None: + """Test light off state.""" + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + + light_entity = hass.states.get("light.device_led_off_led") + assert light_entity is not None + assert light_entity.state == STATE_OFF + assert light_entity.attributes.get("brightness") is None + assert light_entity.attributes.get("rgb_color") is None + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_turn_on_off( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test turn on and off.""" + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.device_with_led_led"}, + blocking=True, + ) + + assert aioclient_mock.call_count == 1 + call_data = aioclient_mock.mock_calls[0][2] + assert call_data["led_override"] == "off" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.device_with_led_led"}, + blocking=True, + ) + + assert aioclient_mock.call_count == 2 + call_data = aioclient_mock.mock_calls[1][2] + assert call_data["led_override"] == "on" + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_set_brightness( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test set brightness.""" + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.device_with_led_led", + ATTR_BRIGHTNESS: 127, + }, + blocking=True, + ) + + assert aioclient_mock.call_count == 1 + call_data = aioclient_mock.mock_calls[0][2] + assert call_data["led_override"] == "on" + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_set_rgb_color( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test set RGB color.""" + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.device_with_led_led", + ATTR_RGB_COLOR: (255, 0, 0), + }, + blocking=True, + ) + + assert aioclient_mock.call_count == 1 + call_data = aioclient_mock.mock_calls[0][2] + assert call_data["led_override"] == "on" + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_set_brightness_and_color( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test set brightness and color.""" + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.device_with_led_led", + ATTR_RGB_COLOR: (0, 255, 0), + ATTR_BRIGHTNESS: 191, + }, + blocking=True, + ) + + assert aioclient_mock.call_count == 1 + call_data = aioclient_mock.mock_calls[0][2] + assert call_data["led_override"] == "on" + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_state_update_via_websocket( + hass: HomeAssistant, + mock_websocket_message: WebsocketMessageMock, +) -> None: + """Test state update via websocket.""" + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_ON + assert light_entity.attributes["rgb_color"] == (0, 0, 255) + updated_device = deepcopy(DEVICE_WITH_LED) + updated_device["led_override"] = "off" + updated_device["led_override_color"] = "#ff0000" + updated_device["led_override_color_brightness"] = 100 + + mock_websocket_message(message=MessageKey.DEVICE, data=[updated_device]) + await hass.async_block_till_done() + + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_OFF + assert light_entity.attributes.get("rgb_color") is None + assert light_entity.attributes.get("brightness") is None + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_device_offline( + hass: HomeAssistant, + mock_websocket_message: WebsocketMessageMock, +) -> None: + """Test device offline.""" + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + assert hass.states.get("light.device_with_led_led") is not None + + offline_device = deepcopy(DEVICE_WITH_LED) + offline_device["state"] = 0 + mock_websocket_message(message=MessageKey.DEVICE, data=[offline_device]) + await hass.async_block_till_done() + + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_ON + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_device_unavailable( + hass: HomeAssistant, + mock_websocket_state: WebsocketStateManager, +) -> None: + """Test device unavailable.""" + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_ON + + updated_device = deepcopy(DEVICE_WITH_LED) + updated_device["state"] = 0 + + await mock_websocket_state.disconnect() + await hass.async_block_till_done() + + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +async def test_light_platform_snapshot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry_factory: ConfigEntryFactoryType, + snapshot: SnapshotAssertion, +) -> None: + """Test platform snapshot.""" + with patch("homeassistant.components.unifi.PLATFORMS", [Platform.LIGHT]): + config_entry = await config_entry_factory() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)