From 619fdea5dfeeccd08eff78681913721748623140 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 7 May 2025 18:50:45 +0200 Subject: [PATCH 01/16] Fix Z-Wave restore nvm command to wait for driver ready (#144413) --- homeassistant/components/zwave_js/api.py | 15 ++ .../components/zwave_js/config_flow.py | 2 +- homeassistant/components/zwave_js/const.py | 4 + tests/components/zwave_js/test_api.py | 152 +++++++++++++----- 4 files changed, 129 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index aa2219031d2..f4397737234 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -88,6 +88,7 @@ from .const import ( DATA_CLIENT, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, + RESTORE_NVM_DRIVER_READY_TIMEOUT, USER_AGENT, ) from .helpers import ( @@ -3063,14 +3064,28 @@ async def websocket_restore_nvm( ) ) + @callback + def set_driver_ready(event: dict) -> None: + "Set the driver ready event." + wait_driver_ready.set() + + wait_driver_ready = asyncio.Event() + # Set up subscription for progress events connection.subscriptions[msg["id"]] = async_cleanup msg[DATA_UNSUBSCRIBE] = unsubs = [ controller.on("nvm convert progress", forward_progress), controller.on("nvm restore progress", forward_progress), + driver.once("driver ready", set_driver_ready), ] await controller.async_restore_nvm_base64(msg["data"]) + + with suppress(TimeoutError): + async with asyncio.timeout(RESTORE_NVM_DRIVER_READY_TIMEOUT): + await wait_driver_ready.wait() + await hass.config_entries.async_reload(entry.entry_id) + connection.send_message( websocket_api.event_message( msg[ID], diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 84717047fdd..407af9e902b 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -67,6 +67,7 @@ from .const import ( CONF_USE_ADDON, DATA_CLIENT, DOMAIN, + RESTORE_NVM_DRIVER_READY_TIMEOUT, ) _LOGGER = logging.getLogger(__name__) @@ -78,7 +79,6 @@ ADDON_SETUP_TIMEOUT = 5 ADDON_SETUP_TIMEOUT_ROUNDS = 40 CONF_EMULATE_HARDWARE = "emulate_hardware" CONF_LOG_LEVEL = "log_level" -RESTORE_NVM_DRIVER_READY_TIMEOUT = 60 SERVER_VERSION_TIMEOUT = 10 ADDON_LOG_LEVELS = { diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 16cf6f748bb..5792fca42a2 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -201,3 +201,7 @@ COVER_TILT_PROPERTY_KEYS: set[str | int | None] = { WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE, WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION, } + +# Other constants + +RESTORE_NVM_DRIVER_READY_TIMEOUT = 60 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 2e3d8fd290a..c6ce3d9ac1b 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5518,10 +5518,98 @@ async def test_restore_nvm( # Set up mocks for the controller events controller = client.driver.controller - # Test restore success - with patch.object( - controller, "async_restore_nvm_base64", return_value=None - ) as mock_restore: + async def async_send_command_driver_ready( + message: dict[str, Any], + require_schema: int | None = None, + ) -> dict: + """Send a command and get a response.""" + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + return {} + + client.async_send_command.side_effect = async_send_command_driver_ready + + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": integration.entry_id, + "data": "dGVzdA==", # base64 encoded "test" + } + ) + + # Verify the finished event first + msg = await ws_client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["event"] == "finished" + + # Verify subscription success + msg = await ws_client.receive_json() + assert msg["type"] == "result" + assert msg["success"] is True + + # Simulate progress events + event = Event( + "nvm restore progress", + { + "source": "controller", + "event": "nvm restore progress", + "bytesWritten": 25, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm restore progress" + assert msg["event"]["bytesWritten"] == 25 + assert msg["event"]["total"] == 100 + + event = Event( + "nvm restore progress", + { + "source": "controller", + "event": "nvm restore progress", + "bytesWritten": 50, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm restore progress" + assert msg["event"]["bytesWritten"] == 50 + assert msg["event"]["total"] == 100 + + await hass.async_block_till_done() + + # Verify the restore was called + # The first call is the relevant one for nvm restore. + assert client.async_send_command.call_count == 3 + assert client.async_send_command.call_args_list[0] == call( + { + "command": "controller.restore_nvm", + "nvmData": "dGVzdA==", + }, + require_schema=14, + ) + + client.async_send_command.reset_mock() + + # Test sending command with driver not ready and timeout. + + async def async_send_command_no_driver_ready( + message: dict[str, Any], + require_schema: int | None = None, + ) -> dict: + """Send a command and get a response.""" + return {} + + client.async_send_command.side_effect = async_send_command_no_driver_ready + + with patch( + "homeassistant.components.zwave_js.api.RESTORE_NVM_DRIVER_READY_TIMEOUT", + new=0, + ): # Send the subscription request await ws_client.send_json_auto_id( { @@ -5533,6 +5621,7 @@ async def test_restore_nvm( # Verify the finished event first msg = await ws_client.receive_json() + assert msg["type"] == "event" assert msg["event"]["event"] == "finished" @@ -5541,48 +5630,25 @@ async def test_restore_nvm( assert msg["type"] == "result" assert msg["success"] is True - # Simulate progress events - event = Event( - "nvm restore progress", - { - "source": "controller", - "event": "nvm restore progress", - "bytesWritten": 25, - "total": 100, - }, - ) - controller.receive_event(event) - msg = await ws_client.receive_json() - assert msg["event"]["event"] == "nvm restore progress" - assert msg["event"]["bytesWritten"] == 25 - assert msg["event"]["total"] == 100 - - event = Event( - "nvm restore progress", - { - "source": "controller", - "event": "nvm restore progress", - "bytesWritten": 50, - "total": 100, - }, - ) - controller.receive_event(event) - msg = await ws_client.receive_json() - assert msg["event"]["event"] == "nvm restore progress" - assert msg["event"]["bytesWritten"] == 50 - assert msg["event"]["total"] == 100 - - # Wait for the restore to complete await hass.async_block_till_done() - # Verify the restore was called - assert mock_restore.called + # Verify the restore was called + # The first call is the relevant one for nvm restore. + assert client.async_send_command.call_count == 3 + assert client.async_send_command.call_args_list[0] == call( + { + "command": "controller.restore_nvm", + "nvmData": "dGVzdA==", + }, + require_schema=14, + ) + + client.async_send_command.reset_mock() # Test restore failure - with patch.object( - controller, - "async_restore_nvm_base64", - side_effect=FailedCommand("failed_command", "Restore failed"), + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_restore_nvm_base64", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): # Send the subscription request await ws_client.send_json_auto_id( @@ -5596,7 +5662,7 @@ async def test_restore_nvm( # Verify error response msg = await ws_client.receive_json() assert not msg["success"] - assert msg["error"]["code"] == "Restore failed" + assert msg["error"]["code"] == "zwave_error" # Test entry_id not found await ws_client.send_json_auto_id( From 6eb2d1aa7c6758ae2d38cbe0b33eaccb355abd3c Mon Sep 17 00:00:00 2001 From: Tamer Wahba Date: Thu, 8 May 2025 18:41:14 -0400 Subject: [PATCH 02/16] fix homekit air purifier temperature sensor to convert unit (#144435) --- .../components/homekit/type_air_purifiers.py | 22 ++++++++++++++++--- .../homekit/test_type_air_purifiers.py | 18 +++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/type_air_purifiers.py b/homeassistant/components/homekit/type_air_purifiers.py index 25d305a0aa9..feb75f4a856 100644 --- a/homeassistant/components/homekit/type_air_purifiers.py +++ b/homeassistant/components/homekit/type_air_purifiers.py @@ -8,7 +8,13 @@ from pyhap.const import CATEGORY_AIR_PURIFIER from pyhap.service import Service from pyhap.util import callback as pyhap_callback -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfTemperature, +) from homeassistant.core import ( Event, EventStateChangedData, @@ -43,7 +49,12 @@ from .const import ( THRESHOLD_FILTER_CHANGE_NEEDED, ) from .type_fans import ATTR_PRESET_MODE, CHAR_ROTATION_SPEED, Fan -from .util import cleanup_name_for_homekit, convert_to_float, density_to_air_quality +from .util import ( + cleanup_name_for_homekit, + convert_to_float, + density_to_air_quality, + temperature_to_homekit, +) _LOGGER = logging.getLogger(__name__) @@ -345,8 +356,13 @@ class AirPurifier(Fan): ): return + unit = new_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature.CELSIUS + ) + current_temperature = temperature_to_homekit(current_temperature, unit) + _LOGGER.debug( - "%s: Linked temperature sensor %s changed to %d", + "%s: Linked temperature sensor %s changed to %d °C", self.entity_id, self.linked_temperature_sensor, current_temperature, diff --git a/tests/components/homekit/test_type_air_purifiers.py b/tests/components/homekit/test_type_air_purifiers.py index 90b0e0047de..acc7838652d 100644 --- a/tests/components/homekit/test_type_air_purifiers.py +++ b/tests/components/homekit/test_type_air_purifiers.py @@ -34,9 +34,11 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + UnitOfTemperature, ) from homeassistant.core import Event, HomeAssistant @@ -437,6 +439,22 @@ async def test_expose_linked_sensors( assert acc.char_air_quality.value == 1 assert len(broker.mock_calls) == 0 + # Updated temperature with different unit should reflect in HomeKit + broker = MagicMock() + acc.char_current_temperature.broker = broker + hass.states.async_set( + temperature_entity_id, + 60, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_temperature.value == 15.6 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + # Updated temperature should reflect in HomeKit broker = MagicMock() acc.char_current_temperature.broker = broker From 054c7a0adcfb5f7ccaeaa4487402e0bb1878db3e Mon Sep 17 00:00:00 2001 From: DukeChocula <79062549+DukeChocula@users.noreply.github.com> Date: Fri, 9 May 2025 04:49:26 -0500 Subject: [PATCH 03/16] Add LAP-V102S-AUSR to VeSync (#144437) Update const.py Added LAP-V102S-AUSR to Vital 100S --- homeassistant/components/vesync/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 4e39fe40f2d..ff55bcf2e37 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -97,6 +97,7 @@ SKU_TO_BASE_DEVICE = { "LAP-V102S-AASR": "Vital100S", # Alt ID Model Vital100S "LAP-V102S-WEU": "Vital100S", # Alt ID Model Vital100S "LAP-V102S-WUK": "Vital100S", # Alt ID Model Vital100S + "LAP-V102S-AUSR": "Vital100S", # Alt ID Model Vital100S "EverestAir": "EverestAir", "LAP-EL551S-AUS": "EverestAir", # Alt ID Model EverestAir "LAP-EL551S-AEUR": "EverestAir", # Alt ID Model EverestAir From a793503c8a25470addf5163ce56c6864c0fcd304 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 8 May 2025 10:13:42 +0200 Subject: [PATCH 04/16] Bump pylamarzocco to 2.0.1 (#144454) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 572f70bc455..fb6a3660c66 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.0"] + "requirements": ["pylamarzocco==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index da5bdddd5c2..ff231fb44ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2093,7 +2093,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.0 +pylamarzocco==2.0.1 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7f6f484a70..b0d5fffe420 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1708,7 +1708,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.0 +pylamarzocco==2.0.1 # homeassistant.components.lastfm pylast==5.1.0 From e5c56629e25d32db2032f5b22e60813cf4ba9938 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 8 May 2025 11:59:20 +0200 Subject: [PATCH 05/16] Fix Z-Wave reset accumulated values button entity category (#144459) --- homeassistant/components/zwave_js/discovery.py | 2 +- tests/components/zwave_js/test_discovery.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 5c79c668afc..b46735e4040 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -1204,7 +1204,7 @@ DISCOVERY_SCHEMAS = [ property={RESET_METER_PROPERTY}, type={ValueType.BOOLEAN}, ), - entity_category=EntityCategory.DIAGNOSTIC, + entity_category=EntityCategory.CONFIG, ), ZWaveDiscoverySchema( platform=Platform.BINARY_SENSOR, diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 0be0cca78c8..7ef5f0e480f 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -431,10 +431,11 @@ async def test_rediscovery( async def test_aeotec_smart_switch_7( hass: HomeAssistant, + entity_registry: er.EntityRegistry, aeotec_smart_switch_7: Node, integration: MockConfigEntry, ) -> None: - """Test that Smart Switch 7 has a light and a switch entity.""" + """Test Smart Switch 7 discovery.""" state = hass.states.get("light.smart_switch_7") assert state assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ @@ -443,3 +444,9 @@ async def test_aeotec_smart_switch_7( state = hass.states.get("switch.smart_switch_7") assert state + + state = hass.states.get("button.smart_switch_7_reset_accumulated_values") + assert state + entity_entry = entity_registry.async_get(state.entity_id) + assert entity_entry + assert entity_entry.entity_category is EntityCategory.CONFIG From 23244fb79fcb67904d55cfebfe3873495d2b4842 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 8 May 2025 14:28:56 +0200 Subject: [PATCH 06/16] Fix point import error (#144462) * fix import error * fix failing tests * Apply suggestions from code review --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/point/coordinator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/point/coordinator.py b/homeassistant/components/point/coordinator.py index c0cb4e27646..93bd74955ea 100644 --- a/homeassistant/components/point/coordinator.py +++ b/homeassistant/components/point/coordinator.py @@ -6,7 +6,6 @@ import logging from typing import Any from pypoint import PointSession -from tempora.utc import fromtimestamp from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -62,7 +61,9 @@ class PointDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]] or device.device_id not in self.device_updates or self.device_updates[device.device_id] < last_updated ): - self.device_updates[device.device_id] = last_updated or fromtimestamp(0) + self.device_updates[device.device_id] = ( + last_updated or datetime.fromtimestamp(0) + ) self.data[device.device_id] = { k: await device.sensor(k) for k in ("temperature", "humidity", "sound_pressure") From a8beec26916166e760e500decb1fabf4deaf811c Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 9 May 2025 10:39:00 +0200 Subject: [PATCH 07/16] Ignore Fronius Gen24 firmware 1.35.4-1 SSL verification issue (#144463) --- homeassistant/components/fronius/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index 4ba893df85c..8a3d1ebf04c 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -45,7 +45,15 @@ type FroniusConfigEntry = ConfigEntry[FroniusSolarNet] async def async_setup_entry(hass: HomeAssistant, entry: FroniusConfigEntry) -> bool: """Set up fronius from a config entry.""" host = entry.data[CONF_HOST] - fronius = Fronius(async_get_clientsession(hass), host) + fronius = Fronius( + async_get_clientsession( + hass, + # Fronius Gen24 firmware 1.35.4-1 redirects to HTTPS with self-signed + # certificate. See https://github.com/home-assistant/core/issues/138881 + verify_ssl=False, + ), + host, + ) solar_net = FroniusSolarNet(hass, entry, fronius) await solar_net.init_devices() From 30f7e9b441a39656ad8cbeb612b8379718dd0c69 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 8 May 2025 22:30:35 +0200 Subject: [PATCH 08/16] Don't encrypt or decrypt unknown files in backup archives (#144495) --- homeassistant/components/backup/http.py | 16 ++- homeassistant/components/backup/util.py | 92 +++++++++++++----- .../backup/fixtures/test_backups/c0cb53bd.tar | Bin 10240 -> 10240 bytes .../test_backups/c0cb53bd.tar.decrypted | Bin 10240 -> 10240 bytes tests/components/backup/test_http.py | 4 +- tests/components/backup/test_util.py | 64 ++++++++---- 6 files changed, 129 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 8f241e6363d..11d8199bdc5 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -22,7 +22,7 @@ from . import util from .agent import BackupAgent from .const import DATA_MANAGER from .manager import BackupManager -from .models import BackupNotFound +from .models import AgentBackup, BackupNotFound @callback @@ -85,7 +85,15 @@ class DownloadBackupView(HomeAssistantView): request, headers, backup_id, agent_id, agent, manager ) return await self._send_backup_with_password( - hass, request, headers, backup_id, agent_id, password, agent, manager + hass, + backup, + request, + headers, + backup_id, + agent_id, + password, + agent, + manager, ) except BackupNotFound: return Response(status=HTTPStatus.NOT_FOUND) @@ -116,6 +124,7 @@ class DownloadBackupView(HomeAssistantView): async def _send_backup_with_password( self, hass: HomeAssistant, + backup: AgentBackup, request: Request, headers: dict[istr, str], backup_id: str, @@ -144,7 +153,8 @@ class DownloadBackupView(HomeAssistantView): stream = util.AsyncIteratorWriter(hass) worker = threading.Thread( - target=util.decrypt_backup, args=[reader, stream, password, on_done, 0, []] + target=util.decrypt_backup, + args=[backup, reader, stream, password, on_done, 0, []], ) try: worker.start() diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index bd77880738e..8112faf4459 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -295,13 +295,26 @@ def validate_password_stream( raise BackupEmpty +def _get_expected_archives(backup: AgentBackup) -> set[str]: + """Get the expected archives in the backup.""" + expected_archives = set() + if backup.homeassistant_included: + expected_archives.add("homeassistant") + for addon in backup.addons: + expected_archives.add(addon.slug) + for folder in backup.folders: + expected_archives.add(folder.value) + return expected_archives + + def decrypt_backup( + backup: AgentBackup, input_stream: IO[bytes], output_stream: IO[bytes], password: str | None, on_done: Callable[[Exception | None], None], minimum_size: int, - nonces: list[bytes], + nonces: NonceGenerator, ) -> None: """Decrypt a backup.""" error: Exception | None = None @@ -315,7 +328,7 @@ def decrypt_backup( fileobj=output_stream, mode="w|", bufsize=BUF_SIZE ) as output_tar, ): - _decrypt_backup(input_tar, output_tar, password) + _decrypt_backup(backup, input_tar, output_tar, password) except (DecryptError, SecureTarError, tarfile.TarError) as err: LOGGER.warning("Error decrypting backup: %s", err) error = err @@ -333,15 +346,18 @@ def decrypt_backup( def _decrypt_backup( + backup: AgentBackup, input_tar: tarfile.TarFile, output_tar: tarfile.TarFile, password: str | None, ) -> None: """Decrypt a backup.""" + expected_archives = _get_expected_archives(backup) for obj in input_tar: # We compare with PurePath to avoid issues with different path separators, # for example when backup.json is added as "./backup.json" - if PurePath(obj.name) == PurePath("backup.json"): + object_path = PurePath(obj.name) + if object_path == PurePath("backup.json"): # Rewrite the backup.json file to indicate that the backup is decrypted if not (reader := input_tar.extractfile(obj)): raise DecryptError @@ -352,7 +368,13 @@ def _decrypt_backup( metadata_obj.size = len(updated_metadata_b) output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b)) continue - if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): + prefix, _, suffix = object_path.name.partition(".") + if suffix not in ("tar", "tgz", "tar.gz"): + LOGGER.debug("Unknown file %s will not be decrypted", obj.name) + output_tar.addfile(obj, input_tar.extractfile(obj)) + continue + if prefix not in expected_archives: + LOGGER.debug("Unknown inner tar file %s will not be decrypted", obj.name) output_tar.addfile(obj, input_tar.extractfile(obj)) continue istf = SecureTarFile( @@ -371,12 +393,13 @@ def _decrypt_backup( def encrypt_backup( + backup: AgentBackup, input_stream: IO[bytes], output_stream: IO[bytes], password: str | None, on_done: Callable[[Exception | None], None], minimum_size: int, - nonces: list[bytes], + nonces: NonceGenerator, ) -> None: """Encrypt a backup.""" error: Exception | None = None @@ -390,7 +413,7 @@ def encrypt_backup( fileobj=output_stream, mode="w|", bufsize=BUF_SIZE ) as output_tar, ): - _encrypt_backup(input_tar, output_tar, password, nonces) + _encrypt_backup(backup, input_tar, output_tar, password, nonces) except (EncryptError, SecureTarError, tarfile.TarError) as err: LOGGER.warning("Error encrypting backup: %s", err) error = err @@ -408,17 +431,20 @@ def encrypt_backup( def _encrypt_backup( + backup: AgentBackup, input_tar: tarfile.TarFile, output_tar: tarfile.TarFile, password: str | None, - nonces: list[bytes], + nonces: NonceGenerator, ) -> None: """Encrypt a backup.""" inner_tar_idx = 0 + expected_archives = _get_expected_archives(backup) for obj in input_tar: # We compare with PurePath to avoid issues with different path separators, # for example when backup.json is added as "./backup.json" - if PurePath(obj.name) == PurePath("backup.json"): + object_path = PurePath(obj.name) + if object_path == PurePath("backup.json"): # Rewrite the backup.json file to indicate that the backup is encrypted if not (reader := input_tar.extractfile(obj)): raise EncryptError @@ -429,16 +455,21 @@ def _encrypt_backup( metadata_obj.size = len(updated_metadata_b) output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b)) continue - if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): + prefix, _, suffix = object_path.name.partition(".") + if suffix not in ("tar", "tgz", "tar.gz"): + LOGGER.debug("Unknown file %s will not be encrypted", obj.name) output_tar.addfile(obj, input_tar.extractfile(obj)) continue + if prefix not in expected_archives: + LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name) + continue istf = SecureTarFile( None, # Not used gzip=False, key=password_to_key(password) if password is not None else None, mode="r", fileobj=input_tar.extractfile(obj), - nonce=nonces[inner_tar_idx], + nonce=nonces.get(inner_tar_idx), ) inner_tar_idx += 1 with istf.encrypt(obj) as encrypted: @@ -456,17 +487,33 @@ class _CipherWorkerStatus: writer: AsyncIteratorWriter +class NonceGenerator: + """Generate nonces for encryption.""" + + def __init__(self) -> None: + """Initialize the generator.""" + self._nonces: dict[int, bytes] = {} + + def get(self, index: int) -> bytes: + """Get a nonce for the given index.""" + if index not in self._nonces: + # Generate a new nonce for the given index + self._nonces[index] = os.urandom(16) + return self._nonces[index] + + class _CipherBackupStreamer: """Encrypt or decrypt a backup.""" _cipher_func: Callable[ [ + AgentBackup, IO[bytes], IO[bytes], str | None, Callable[[Exception | None], None], int, - list[bytes], + NonceGenerator, ], None, ] @@ -484,7 +531,7 @@ class _CipherBackupStreamer: self._hass = hass self._open_stream = open_stream self._password = password - self._nonces: list[bytes] = [] + self._nonces = NonceGenerator() def size(self) -> int: """Return the maximum size of the decrypted or encrypted backup.""" @@ -508,7 +555,15 @@ class _CipherBackupStreamer: writer = AsyncIteratorWriter(self._hass) worker = threading.Thread( target=self._cipher_func, - args=[reader, writer, self._password, on_done, self.size(), self._nonces], + args=[ + self._backup, + reader, + writer, + self._password, + on_done, + self.size(), + self._nonces, + ], ) worker_status = _CipherWorkerStatus( done=asyncio.Event(), reader=reader, thread=worker, writer=writer @@ -538,17 +593,6 @@ class DecryptedBackupStreamer(_CipherBackupStreamer): class EncryptedBackupStreamer(_CipherBackupStreamer): """Encrypt a backup.""" - def __init__( - self, - hass: HomeAssistant, - backup: AgentBackup, - open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], - password: str | None, - ) -> None: - """Initialize.""" - super().__init__(hass, backup, open_stream, password) - self._nonces = [os.urandom(16) for _ in range(self._num_tar_files())] - _cipher_func = staticmethod(encrypt_backup) def backup(self) -> AgentBackup: diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar index f3b2845d5eb19b9708ae6d4fd68f7d220fb39c45..29e61d5e4c11683b126dcc08b0736dca19eda9f4 100644 GIT binary patch delta 283 zcmZn&Xb70lB57`F!eD>^3w}8B;bhGKMkL>nJECrljQO6)RaOL{}>n z=ai-cSxU+IMX82LK*_ws+*FW&Gf+SQEK-(QRGgWg2NE>UGXSY6&a48d0rF~f6j04D z!Y~6Y0yjepn<25a69q*Uv9O3_7`dq6bzW0!ePX2WMFIr l_X#I6JBJ}m!NQ2iDFUV}hHzOyX7w}8B;bhGKMiR)&iL;7O}8Q5@4Ck%Mm_N rfpc<-fC;MsLKP=7`(_pi8K%t&LQhy3jUlQyfT~PcjNwK}{^tV#9Tpno diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted index c97533fc1afb35fafb7be57651fc72e4069f44b3..386ea021247a5b5f9658a1e2009e956f73dcca9d 100644 GIT binary patch delta 282 zcmZn&Xb70lB57`F%3y#13w}8B;bhGKMqN>nJECrljQO6)RaOL{}>n z=ai-cSxU+IMX82LK*_ws+*FW&Gf+SQEK-(QRGgWg2NE>UGXSY6&a48d0rF~f6j04D z!Y~6Y0yjepn<25a69t7Av9O3_7`dq6bzW0!ePX2WMFIz k_X#I6JBJ}m!NQ2iDFUV}MsQg{X7w}8B;bhGKMoT)&iN!7qPHR5@4Ck%Mm_N qfpc<-fC;MsLKP=7`(_r2Kg^p%SXda1A&NMFicDEd;3i4_=K}x=rW(lr diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index 92bf454095e..b3845b1209a 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -177,7 +177,7 @@ async def _test_downloading_encrypted_backup( enc_metadata = json.loads(outer_tar.extractfile("./backup.json").read()) assert enc_metadata["protected"] is True with ( - outer_tar.extractfile("core.tar.gz") as inner_tar_file, + outer_tar.extractfile("homeassistant.tar.gz") as inner_tar_file, pytest.raises(tarfile.ReadError, match="file could not be opened"), ): # pylint: disable-next=consider-using-with @@ -209,7 +209,7 @@ async def _test_downloading_encrypted_backup( dec_metadata = json.loads(outer_tar.extractfile("./backup.json").read()) assert dec_metadata == enc_metadata | {"protected": False} with ( - outer_tar.extractfile("core.tar.gz") as inner_tar_file, + outer_tar.extractfile("homeassistant.tar.gz") as inner_tar_file, tarfile.open(fileobj=inner_tar_file, mode="r") as inner_tar, ): assert inner_tar.getnames() == [ diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 97e94eafb73..a999672e7f6 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -174,7 +174,10 @@ async def test_decrypted_backup_streamer(hass: HomeAssistant) -> None: ) encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -218,7 +221,10 @@ async def test_decrypted_backup_streamer_interrupt_stuck_reader( """Test the decrypted backup streamer.""" encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -253,7 +259,10 @@ async def test_decrypted_backup_streamer_interrupt_stuck_writer( """Test the decrypted backup streamer.""" encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -283,7 +292,10 @@ async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) -> """Test the decrypted backup streamer with wrong password.""" encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -320,7 +332,10 @@ async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None: ) encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -353,15 +368,16 @@ async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None: bytes.fromhex("00000000000000000000000000000000"), ) encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") - assert encryptor.backup() == dataclasses.replace( - backup, protected=True, size=backup.size + len(expected_padding) - ) - encrypted_stream = await encryptor.open_stream() - encrypted_output = b"" - async for chunk in encrypted_stream: - encrypted_output += chunk - await encryptor.wait() + assert encryptor.backup() == dataclasses.replace( + backup, protected=True, size=backup.size + len(expected_padding) + ) + + encrypted_stream = await encryptor.open_stream() + encrypted_output = b"" + async for chunk in encrypted_stream: + encrypted_output += chunk + await encryptor.wait() # Expect the output to match the stored encrypted backup file, with additional # padding. @@ -377,7 +393,10 @@ async def test_encrypted_backup_streamer_interrupt_stuck_reader( "test_backups/c0cb53bd.tar.decrypted", DOMAIN ) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -414,7 +433,10 @@ async def test_encrypted_backup_streamer_interrupt_stuck_writer( "test_backups/c0cb53bd.tar.decrypted", DOMAIN ) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -447,7 +469,10 @@ async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> No ) encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -490,7 +515,7 @@ async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> No await encryptor1.wait() await encryptor2.wait() - # Output from the two streames should differ but have the same length. + # Output from the two streams should differ but have the same length. assert encrypted_output1 != encrypted_output3 assert len(encrypted_output1) == len(encrypted_output3) @@ -508,7 +533,10 @@ async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: "test_backups/c0cb53bd.tar.decrypted", DOMAIN ) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, From fd6fb7e3bcc56b37db39a0c32ed0e35fe50c0044 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 May 2025 14:55:03 -0500 Subject: [PATCH 09/16] Bump forecast-solar to 4.2.0 (#144502) --- homeassistant/components/forecast_solar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index 769bda56adc..66796a44dc4 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/forecast_solar", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["forecast-solar==4.1.0"] + "requirements": ["forecast-solar==4.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ff231fb44ef..6f4dce5f130 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -958,7 +958,7 @@ fnv-hash-fast==1.5.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast-solar==4.1.0 +forecast-solar==4.2.0 # homeassistant.components.fortios fortiosapi==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0d5fffe420..cfd59c5ee2d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -818,7 +818,7 @@ fnv-hash-fast==1.5.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast-solar==4.1.0 +forecast-solar==4.2.0 # homeassistant.components.freebox freebox-api==1.2.2 From 47acceea082cac8fd74b93ed436755899d1f0e94 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 8 May 2025 21:54:49 +0200 Subject: [PATCH 10/16] Fix removing of smarthome templates on startup of AVM Fritz!SmartHome integration (#144506) --- .../components/fritzbox/coordinator.py | 2 +- tests/components/fritzbox/test_coordinator.py | 25 ++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index adc63dd2c2e..8a37ebf63e4 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -92,7 +92,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat available_main_ains = [ ain - for ain, dev in data.devices.items() + for ain, dev in data.devices.items() | data.templates.items() if dev.device_and_unit_id[1] is None ] device_reg = dr.async_get(self.hass) diff --git a/tests/components/fritzbox/test_coordinator.py b/tests/components/fritzbox/test_coordinator.py index f4f4da90181..4c329daa640 100644 --- a/tests/components/fritzbox/test_coordinator.py +++ b/tests/components/fritzbox/test_coordinator.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow -from . import FritzDeviceCoverMock, FritzDeviceSwitchMock +from . import FritzDeviceCoverMock, FritzDeviceSwitchMock, FritzEntityBaseMock from .const import MOCK_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed @@ -84,6 +84,8 @@ async def test_coordinator_automatic_registry_cleanup( entity_registry: er.EntityRegistry, ) -> None: """Test automatic registry cleanup.""" + + # init with 2 devices and 1 template fritz().get_devices.return_value = [ FritzDeviceSwitchMock( ain="fake ain switch", @@ -96,6 +98,13 @@ async def test_coordinator_automatic_registry_cleanup( name="fake_cover", ), ] + fritz().get_templates.return_value = [ + FritzEntityBaseMock( + ain="fake ain template", + device_and_unit_id=("fake ain template", None), + name="fake_template", + ) + ] entry = MockConfigEntry( domain=FB_DOMAIN, data=MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], @@ -105,9 +114,10 @@ async def test_coordinator_automatic_registry_cleanup( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) - assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 19 - assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 2 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 20 + assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 3 + # remove one device, keep the template fritz().get_devices.return_value = [ FritzDeviceSwitchMock( ain="fake ain switch", @@ -119,5 +129,14 @@ async def test_coordinator_automatic_registry_cleanup( async_fire_time_changed(hass, utcnow() + timedelta(seconds=35)) await hass.async_block_till_done(wait_background_tasks=True) + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 13 + assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 2 + + # remove the template, keep the device + fritz().get_templates.return_value = [] + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=35)) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 12 assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 1 From cb475bf153488717ef58ba08546aae30a63c8189 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 May 2025 15:43:45 -0500 Subject: [PATCH 11/16] Bump aiodns to 3.4.0 (#144511) --- homeassistant/components/dnsip/config_flow.py | 2 +- homeassistant/components/dnsip/manifest.json | 2 +- homeassistant/components/dnsip/sensor.py | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index 9e98178e718..e7b60d5bd6f 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -68,7 +68,7 @@ async def async_validate_hostname( result = False with contextlib.suppress(DNSError): result = bool( - await aiodns.DNSResolver( + await aiodns.DNSResolver( # type: ignore[call-overload] nameservers=[resolver], udp_port=port, tcp_port=port ).query(hostname, qtype) ) diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index 35802adb7f3..e004b386e02 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dnsip", "iot_class": "cloud_polling", - "requirements": ["aiodns==3.3.0"] + "requirements": ["aiodns==3.4.0"] } diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index 6708baefe8c..6cdb67dd80d 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -106,7 +106,7 @@ class WanIpSensor(SensorEntity): async def async_update(self) -> None: """Get the current DNS IP address for hostname.""" try: - response = await self.resolver.query(self.hostname, self.querytype) + response = await self.resolver.query(self.hostname, self.querytype) # type: ignore[call-overload] except DNSError as err: _LOGGER.warning("Exception while resolving host: %s", err) response = None diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 48b21942f4d..d26a2abc59b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodhcpwatcher==1.1.1 aiodiscover==2.6.1 -aiodns==3.3.0 +aiodns==3.4.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 50cc169cf10..2299673ea61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] requires-python = ">=3.13.2" dependencies = [ - "aiodns==3.3.0", + "aiodns==3.4.0", # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 diff --git a/requirements.txt b/requirements.txt index 26ff191025f..e87c1750336 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiodns==3.3.0 +aiodns==3.4.0 aiohasupervisor==0.3.1 aiohttp==3.11.18 aiohttp_cors==0.7.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6f4dce5f130..c75d8ed43ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -223,7 +223,7 @@ aiodhcpwatcher==1.1.1 aiodiscover==2.6.1 # homeassistant.components.dnsip -aiodns==3.3.0 +aiodns==3.4.0 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cfd59c5ee2d..70c2427a11c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -211,7 +211,7 @@ aiodhcpwatcher==1.1.1 aiodiscover==2.6.1 # homeassistant.components.dnsip -aiodns==3.3.0 +aiodns==3.4.0 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 From 4ad387c967ba2069103cd43abc3e83a1cc11b838 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 9 May 2025 10:37:48 +0200 Subject: [PATCH 12/16] Fix statistics coordinator subscription for lamarzocco (#144541) --- homeassistant/components/lamarzocco/sensor.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 5dc0eb3dbef..6e3eee50f41 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -132,17 +132,18 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities.""" - coordinator = entry.runtime_data.config_coordinator + config_coordinator = entry.runtime_data.config_coordinator + statistic_coordinators = entry.runtime_data.statistics_coordinator entities = [ - LaMarzoccoSensorEntity(coordinator, description) + LaMarzoccoSensorEntity(config_coordinator, description) for description in ENTITIES - if description.supported_fn(coordinator) + if description.supported_fn(config_coordinator) ] entities.extend( - LaMarzoccoStatisticSensorEntity(coordinator, description) + LaMarzoccoStatisticSensorEntity(statistic_coordinators, description) for description in STATISTIC_ENTITIES - if description.supported_fn(coordinator) + if description.supported_fn(statistic_coordinators) ) async_add_entities(entities) From 196d923ac6009ef4f23e9cb474555e93bd7aa2ab Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 9 May 2025 13:09:05 +0200 Subject: [PATCH 13/16] Update frontend to 20250509.0 (#144549) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 84062384bf5..9471f863a72 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250507.0"] + "requirements": ["home-assistant-frontend==20250509.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d26a2abc59b..bd3ec0bb03f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.48.2 hass-nabucasa==0.96.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250507.0 +home-assistant-frontend==20250509.0 home-assistant-intents==2025.5.7 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c75d8ed43ec..7973c10cbe3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1161,7 +1161,7 @@ hole==0.8.0 holidays==0.70 # homeassistant.components.frontend -home-assistant-frontend==20250507.0 +home-assistant-frontend==20250509.0 # homeassistant.components.conversation home-assistant-intents==2025.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 70c2427a11c..0e41fc37f4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -991,7 +991,7 @@ hole==0.8.0 holidays==0.70 # homeassistant.components.frontend -home-assistant-frontend==20250507.0 +home-assistant-frontend==20250509.0 # homeassistant.components.conversation home-assistant-intents==2025.5.7 From 181eca6c8200c20d99eb1ff977950b24cda65383 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 9 May 2025 14:47:38 +0200 Subject: [PATCH 14/16] Reolink clean device registry mac (#144554) --- homeassistant/components/reolink/__init__.py | 10 +++- tests/components/reolink/test_init.py | 52 +++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index f7d13c1d90f..433af396d63 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -23,7 +23,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -380,6 +380,14 @@ def migrate_entity_ids( if ch is None or is_chime: continue # Do not consider the NVR itself or chimes + # Check for wrongfully added MAC of the NVR/Hub to the camera + # Can be removed in HA 2025.12 + host_connnection = (CONNECTION_NETWORK_MAC, host.api.mac_address) + if host_connnection in device.connections: + new_connections = device.connections.copy() + new_connections.remove(host_connnection) + device_reg.async_update_device(device.id, new_connections=new_connections) + ch_device_ids[device.id] = ch if host.api.supported(ch, "UID") and device_uid[1] != host.api.camera_uid(ch): if host.api.supported(None, "UID"): diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 5915bd06608..6b57c1c253f 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -39,7 +39,7 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) -from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.setup import async_setup_component from .conftest import ( @@ -51,6 +51,7 @@ from .conftest import ( TEST_HOST, TEST_HOST_MODEL, TEST_MAC, + TEST_MAC_CAM, TEST_NVR_NAME, TEST_PORT, TEST_PRIVACY, @@ -614,6 +615,55 @@ async def test_migrate_with_already_existing_entity( assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) +async def test_cleanup_mac_connection( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test cleanup of the MAC of a IPC which was set to the MAC of the host.""" + reolink_connect.channels = [0] + reolink_connect.baichuan.mac_address.return_value = None + entity_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio" + dev_id = f"{TEST_UID}_{TEST_UID_CAM}" + domain = Platform.SWITCH + + dev_entry = device_registry.async_get_or_create( + identifiers={(DOMAIN, dev_id)}, + connections={(CONNECTION_NETWORK_MAC, TEST_MAC)}, + config_entry_id=config_entry.entry_id, + disabled_by=None, + ) + + entity_registry.async_get_or_create( + domain=domain, + platform=DOMAIN, + unique_id=entity_id, + config_entry=config_entry, + suggested_object_id=entity_id, + disabled_by=None, + device_id=dev_entry.id, + ) + + assert entity_registry.async_get_entity_id(domain, DOMAIN, entity_id) + device = device_registry.async_get_device(identifiers={(DOMAIN, dev_id)}) + assert device + assert device.connections == {(CONNECTION_NETWORK_MAC, TEST_MAC)} + + # setup CH 0 and host entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [domain]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get_entity_id(domain, DOMAIN, entity_id) + device = device_registry.async_get_device(identifiers={(DOMAIN, dev_id)}) + assert device + assert device.connections == set() + + reolink_connect.baichuan.mac_address.return_value = TEST_MAC_CAM + + async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry ) -> None: From f392e0c1c712d1a1dd55331c691b0c062bea5e9a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 9 May 2025 14:50:00 +0200 Subject: [PATCH 15/16] Prevent errors during cleaning of connections/identifiers in device registry (#144558) --- homeassistant/helpers/device_registry.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 79d6774c407..a80e74e7eb2 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -575,9 +575,11 @@ class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( """Unindex an entry.""" old_entry = self.data[key] for connection in old_entry.connections: - del self._connections[connection] + if connection in self._connections: + del self._connections[connection] for identifier in old_entry.identifiers: - del self._identifiers[identifier] + if identifier in self._identifiers: + del self._identifiers[identifier] def get_entry( self, From 13aba6201e5027478f95bcda54d4d045572ec5a7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 9 May 2025 13:29:29 +0000 Subject: [PATCH 16/16] Bump version to 2025.5.1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 11abbd33b41..65f8e2bae64 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 5 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 2299673ea61..fa960f6f815 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.5.0" +version = "2025.5.1" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3."