From 5aa8bbc509a0164c2cf649958a7103703435baa4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 21 Aug 2026 19:33:52 +0200 Subject: [PATCH 01/38] Remove clearing of via_device self references from integrations (#178464) --- homeassistant/components/hive/__init__.py | 7 +------ homeassistant/components/hydrawise/__init__.py | 3 --- homeassistant/components/lutron_caseta/__init__.py | 10 +--------- homeassistant/components/squeezebox/media_player.py | 3 +-- 4 files changed, 3 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index f9c4cdd88c7e7..d50ead9bfaa53 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -50,7 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool connections.add((dr.CONNECTION_NETWORK_MAC, mac)) device_registry = dr.async_get(hass) - hub_device = device_registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, hub_data["device_id"])}, connections=connections, @@ -59,11 +59,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool sw_version=hub_data["deviceData"]["version"], manufacturer=hub_data["deviceData"]["manufacturer"], ) - if hub_device.via_device_id is not None: - # Older versions linked the hub's own diagnostic sensor to the hub itself; - # clear the stale self-reference since async_get_or_create leaves - # via_device_id untouched when it's not passed. - device_registry.async_update_device(hub_device.id, via_device_id=None) await hass.config_entries.async_forward_entry_setups( entry, diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 1fced69ba7043..c5b74f2f2e292 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -70,9 +70,6 @@ def _async_register_controller_devices(controllers: Iterable[Controller]) -> Non manufacturer=MANUFACTURER, model=controller.hardware.model.description, name=controller.name, - # Explicitly clear any via_device_id: older versions linked the - # controller device to itself via its rain sensor entity. - via_device_id=None, ) # Register the controllers known at setup before the platforms construct diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index fb996fe600148..23f9b809f4931 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -243,15 +243,7 @@ def _async_register_bridge_device( if area != UNASSIGNED_AREA: device_args["suggested_area"] = area - device = device_registry.async_get_or_create( - **device_args, config_entry_id=config_entry_id - ) - if device.via_device_id is not None: - # Existing installations may still have the bridge device linked to - # itself via via_device_id, from when it was (incorrectly) registered - # as its own via device. Clear it explicitly since async_get_or_create - # above leaves via_device_id untouched when it's not passed. - device_registry.async_update_device(device.id, via_device_id=None) + device_registry.async_get_or_create(**device_args, config_entry_id=config_entry_id) @callback diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 5ad10f7a1ee9f..ffa12268d54a5 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -153,8 +153,7 @@ async def _player_discovered( ) model_id = SERVER_MODEL_ID + "/" + model_id if model_id else SERVER_MODEL_ID # The player shares the server's device (same MAC), so it resolves to - # the server device itself; don't link it to itself. None also clears - # the link for devices from before this was fixed. + # the server device itself; don't link it to itself. via_device_id = None device = device_registry.async_get_or_create( From a497edec458986efc3f086a43132b01f2a61f264 Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Fri, 21 Aug 2026 18:57:20 +0100 Subject: [PATCH 02/38] Add number platform to Lyngdorf (#179448) --- homeassistant/components/lyngdorf/const.py | 1 + homeassistant/components/lyngdorf/icons.json | 20 + homeassistant/components/lyngdorf/number.py | 180 ++++++++ .../components/lyngdorf/strings.json | 23 + tests/components/lyngdorf/conftest.py | 13 + .../lyngdorf/snapshots/test_diagnostics.ambr | 14 +- .../lyngdorf/snapshots/test_number.ambr | 422 ++++++++++++++++++ tests/components/lyngdorf/test_number.py | 175 ++++++++ 8 files changed, 841 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/lyngdorf/number.py create mode 100644 tests/components/lyngdorf/snapshots/test_number.ambr create mode 100644 tests/components/lyngdorf/test_number.py diff --git a/homeassistant/components/lyngdorf/const.py b/homeassistant/components/lyngdorf/const.py index 47a5cd018126a..753f8a4560db3 100644 --- a/homeassistant/components/lyngdorf/const.py +++ b/homeassistant/components/lyngdorf/const.py @@ -7,6 +7,7 @@ PLATFORMS: list[Platform] = [ Platform.MEDIA_PLAYER, + Platform.NUMBER, Platform.SENSOR, ] CONF_SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/lyngdorf/icons.json b/homeassistant/components/lyngdorf/icons.json index 4120b60053eb4..5e6ba372381b1 100644 --- a/homeassistant/components/lyngdorf/icons.json +++ b/homeassistant/components/lyngdorf/icons.json @@ -1,5 +1,25 @@ { "entity": { + "number": { + "trim_bass": { + "default": "mdi:music-clef-bass" + }, + "trim_centre": { + "default": "mdi:speaker" + }, + "trim_height": { + "default": "mdi:arrow-expand-up" + }, + "trim_lfe": { + "default": "mdi:sine-wave" + }, + "trim_surround": { + "default": "mdi:surround-sound" + }, + "trim_treble": { + "default": "mdi:music-clef-treble" + } + }, "sensor": { "audio_information": { "default": "mdi:surround-sound" diff --git a/homeassistant/components/lyngdorf/number.py b/homeassistant/components/lyngdorf/number.py new file mode 100644 index 0000000000000..cdf186a032e7d --- /dev/null +++ b/homeassistant/components/lyngdorf/number.py @@ -0,0 +1,180 @@ +"""Number platform for Lyngdorf integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, override + +from lyngdorf.device import Receiver +from lyngdorf.models.base import NumericRange + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfSoundPressure, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import LyngdorfEntity +from .models import LyngdorfConfigEntry + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class LyngdorfNumberEntityDescription(NumberEntityDescription): + """Describe a Lyngdorf number entity.""" + + value_fn: Callable[[Receiver], float | None] + set_value_fn: Callable[[Receiver, float], None] + range_fn: Callable[[Receiver], NumericRange | None] + + +NUMBER_ENTITIES: tuple[LyngdorfNumberEntityDescription, ...] = ( + LyngdorfNumberEntityDescription( + key="lipsync", + translation_key="lipsync", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.lipsync, + # The device takes lip sync as whole milliseconds. + set_value_fn=lambda r, v: r.set_lipsync(round(v)), + range_fn=lambda r: r.lipsync_range, + ), + LyngdorfNumberEntityDescription( + key="trim_bass", + translation_key="trim_bass", + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.trim_bass, + set_value_fn=lambda r, v: r.set_trim_bass(v), + range_fn=lambda r: r.trim_bass_range, + ), + LyngdorfNumberEntityDescription( + key="trim_treble", + translation_key="trim_treble", + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.trim_treble, + set_value_fn=lambda r, v: r.set_trim_treble(v), + range_fn=lambda r: r.trim_treble_range, + ), + LyngdorfNumberEntityDescription( + key="trim_centre", + translation_key="trim_centre", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.trim_centre, + set_value_fn=lambda r, v: r.set_trim_centre(v), + range_fn=lambda r: r.trim_centre_range, + ), + LyngdorfNumberEntityDescription( + key="trim_height", + translation_key="trim_height", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.trim_height, + set_value_fn=lambda r, v: r.set_trim_height(v), + range_fn=lambda r: r.trim_height_range, + ), + LyngdorfNumberEntityDescription( + key="trim_lfe", + translation_key="trim_lfe", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.trim_lfe, + set_value_fn=lambda r, v: r.set_trim_lfe(v), + range_fn=lambda r: r.trim_lfe_range, + ), + LyngdorfNumberEntityDescription( + key="trim_surround", + translation_key="trim_surround", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + entity_category=EntityCategory.CONFIG, + value_fn=lambda r: r.trim_surround, + set_value_fn=lambda r, v: r.set_trim_surround(v), + range_fn=lambda r: r.trim_surround_range, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LyngdorfConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Lyngdorf number entities from a config entry.""" + runtime_data = config_entry.runtime_data + receiver = runtime_data.receiver + + # A None range means the model has no such control at all. + async_add_entities( + LyngdorfNumber(receiver, config_entry, runtime_data.device_info, description) + for description in NUMBER_ENTITIES + if description.range_fn(receiver) is not None + ) + + +class LyngdorfNumber(LyngdorfEntity, NumberEntity): + """Lyngdorf number entity.""" + + entity_description: LyngdorfNumberEntityDescription + + def __init__( + self, + receiver: Receiver, + config_entry: LyngdorfConfigEntry, + device_info: DeviceInfo, + description: LyngdorfNumberEntityDescription, + ) -> None: + """Initialize the number entity.""" + super().__init__(receiver, device_info) + if TYPE_CHECKING: + assert config_entry.unique_id + self.entity_description = description + self._attr_unique_id = f"{config_entry.unique_id}_{description.key}" + + @property + def _range(self) -> NumericRange: + """Return the device's range for this setting.""" + device_range = self.entity_description.range_fn(self._receiver) + # Entities are only created for controls the model actually has. + if TYPE_CHECKING: + assert device_range is not None + return device_range + + @override + @property + def native_min_value(self) -> float: + """Return the minimum value the device accepts.""" + return self._range.min + + @override + @property + def native_max_value(self) -> float: + """Return the maximum value the device accepts.""" + return self._range.max + + @override + @property + def native_step(self) -> float: + """Return the step the device resolves.""" + return self._range.step + + @override + @property + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self._receiver) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + self.entity_description.set_value_fn(self._receiver, value) diff --git a/homeassistant/components/lyngdorf/strings.json b/homeassistant/components/lyngdorf/strings.json index 4b3baf44bc6a3..6d314636f59d3 100644 --- a/homeassistant/components/lyngdorf/strings.json +++ b/homeassistant/components/lyngdorf/strings.json @@ -53,6 +53,29 @@ "name": "Main zone" } }, + "number": { + "lipsync": { + "name": "Lip sync" + }, + "trim_bass": { + "name": "Trim bass" + }, + "trim_centre": { + "name": "Trim centre" + }, + "trim_height": { + "name": "Trim height" + }, + "trim_lfe": { + "name": "Trim LFE" + }, + "trim_surround": { + "name": "Trim surround" + }, + "trim_treble": { + "name": "Trim treble" + } + }, "sensor": { "audio_information": { "name": "Audio information" diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py index 498c4d56d60f7..0b9fcd0aca4df 100644 --- a/tests/components/lyngdorf/conftest.py +++ b/tests/components/lyngdorf/conftest.py @@ -111,6 +111,19 @@ def mock_receiver() -> Generator[MagicMock]: receiver.can_shuffle = False receiver.available_repeat_modes = frozenset() + receiver.lipsync = 50 + receiver.lipsync_range = NumericRange(0, 500, 1) + receiver.trim_bass = 3.0 + receiver.trim_treble = 0.0 + receiver.trim_centre = 0.0 + receiver.trim_height = 4.0 + receiver.trim_lfe = 3.0 + receiver.trim_surround = 0.0 + receiver.trim_bass_range = NumericRange(-12.0, 12.0, 0.1) + receiver.trim_treble_range = NumericRange(-12.0, 12.0, 0.1) + for _trim in ("centre", "height", "lfe", "surround"): + setattr(receiver, f"trim_{_trim}_range", NumericRange(-10.0, 10.0, 0.1)) + receiver.zone_b_power_on = False receiver.zone_b_volume = -40.0 receiver.zone_b_mute_enabled = False diff --git a/tests/components/lyngdorf/snapshots/test_diagnostics.ambr b/tests/components/lyngdorf/snapshots/test_diagnostics.ambr index 729cffe090879..3750c32a457f2 100644 --- a/tests/components/lyngdorf/snapshots/test_diagnostics.ambr +++ b/tests/components/lyngdorf/snapshots/test_diagnostics.ambr @@ -84,7 +84,7 @@ 'available_voicings': list([ ]), 'connected': True, - 'lipsync': None, + 'lipsync': 50, 'max_volume': 0.0, 'model': 'MP_60', 'mute_enabled': False, @@ -93,12 +93,12 @@ 'sound_mode': None, 'source': None, 'streaming_source': 'AirPlay', - 'trim_bass': None, - 'trim_centre': None, - 'trim_height': None, - 'trim_lfe': None, - 'trim_surround': None, - 'trim_treble': None, + 'trim_bass': 3.0, + 'trim_centre': 0.0, + 'trim_height': 4.0, + 'trim_lfe': 3.0, + 'trim_surround': 0.0, + 'trim_treble': 0.0, 'video_information': '4K HDR', 'video_input': 'hdmi', 'voicing': None, diff --git a/tests/components/lyngdorf/snapshots/test_number.ambr b/tests/components/lyngdorf/snapshots/test_number.ambr new file mode 100644 index 0000000000000..cb9740837e01e --- /dev/null +++ b/tests/components/lyngdorf/snapshots/test_number.ambr @@ -0,0 +1,422 @@ +# serializer version: 1 +# name: test_entities[number.mock_lyngdorf_lip_sync-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 500, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_lip_sync', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lip sync', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lip sync', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lipsync', + 'unique_id': '0050c27c76b2_lipsync', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_lip_sync-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Mock Lyngdorf Lip sync', + : 500, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_lip_sync', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_bass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 12.0, + : -12.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_trim_bass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trim bass', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trim bass', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trim_bass', + 'unique_id': '0050c27c76b2_trim_bass', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_bass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Trim bass', + : 12.0, + : -12.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_trim_bass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_centre-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10.0, + : -10.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_trim_centre', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trim centre', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trim centre', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trim_centre', + 'unique_id': '0050c27c76b2_trim_centre', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_centre-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Trim centre', + : 10.0, + : -10.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_trim_centre', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10.0, + : -10.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_trim_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trim height', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trim height', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trim_height', + 'unique_id': '0050c27c76b2_trim_height', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Trim height', + : 10.0, + : -10.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_trim_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_lfe-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10.0, + : -10.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_trim_lfe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trim LFE', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trim LFE', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trim_lfe', + 'unique_id': '0050c27c76b2_trim_lfe', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_lfe-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Trim LFE', + : 10.0, + : -10.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_trim_lfe', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_surround-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10.0, + : -10.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_trim_surround', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trim surround', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trim surround', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trim_surround', + 'unique_id': '0050c27c76b2_trim_surround', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_surround-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Trim surround', + : 10.0, + : -10.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_trim_surround', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_treble-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 12.0, + : -12.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_lyngdorf_trim_treble', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trim treble', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trim treble', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trim_treble', + 'unique_id': '0050c27c76b2_trim_treble', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.mock_lyngdorf_trim_treble-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Trim treble', + : 12.0, + : -12.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.mock_lyngdorf_trim_treble', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/lyngdorf/test_number.py b/tests/components/lyngdorf/test_number.py new file mode 100644 index 0000000000000..303c3f6a1f980 --- /dev/null +++ b/tests/components/lyngdorf/test_number.py @@ -0,0 +1,175 @@ +"""Tests for the Lyngdorf number platform.""" + +from unittest.mock import MagicMock, patch + +from lyngdorf.const import LyngdorfModel +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import notify_receiver_update + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Only load the number platform.""" + return [Platform.NUMBER] + + +LIPSYNC_ENTITY_ID = "number.mock_lyngdorf_lip_sync" +TRIM_BASS_ENTITY_ID = "number.mock_lyngdorf_trim_bass" +TRIM_TREBLE_ENTITY_ID = "number.mock_lyngdorf_trim_treble" +TRIM_CENTRE_ENTITY_ID = "number.mock_lyngdorf_trim_centre" +TRIM_HEIGHT_ENTITY_ID = "number.mock_lyngdorf_trim_height" +TRIM_LFE_ENTITY_ID = "number.mock_lyngdorf_trim_lfe" +TRIM_SURROUND_ENTITY_ID = "number.mock_lyngdorf_trim_surround" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the number entities.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +async def test_set_lipsync( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test setting the lipsync value.""" + mock_receiver.lipsync = 0 + + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: LIPSYNC_ENTITY_ID, + ATTR_VALUE: 75, + }, + blocking=True, + ) + + mock_receiver.set_lipsync.assert_called_once_with(75) + + +@pytest.mark.parametrize( + ("entity_id", "attribute", "method"), + [ + pytest.param(TRIM_BASS_ENTITY_ID, "trim_bass", "set_trim_bass", id="bass"), + pytest.param( + TRIM_TREBLE_ENTITY_ID, "trim_treble", "set_trim_treble", id="treble" + ), + pytest.param( + TRIM_CENTRE_ENTITY_ID, "trim_centre", "set_trim_centre", id="centre" + ), + pytest.param( + TRIM_HEIGHT_ENTITY_ID, "trim_height", "set_trim_height", id="height" + ), + pytest.param(TRIM_LFE_ENTITY_ID, "trim_lfe", "set_trim_lfe", id="lfe"), + pytest.param( + TRIM_SURROUND_ENTITY_ID, "trim_surround", "set_trim_surround", id="surround" + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_set_trim( + hass: HomeAssistant, + mock_receiver: MagicMock, + entity_id: str, + attribute: str, + method: str, +) -> None: + """Test setting each trim value.""" + setattr(mock_receiver, attribute, 0.0) + + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: -6.0, + }, + blocking=True, + ) + + getattr(mock_receiver, method).assert_called_once_with(-6.0) + + +async def test_number_none_values( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test a number shows unknown when the device reports nothing.""" + mock_receiver.lipsync = None + mock_receiver.trim_bass = None + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + assert hass.states.get(LIPSYNC_ENTITY_ID).state == STATE_UNKNOWN + assert hass.states.get(TRIM_BASS_ENTITY_ID).state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_receiver") +async def test_entities_absent_for_controls_the_model_lacks( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test no entity is created where the model has no such control.""" + mock_receiver.lipsync_range = None + mock_receiver.trim_surround_range = None + mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.lyngdorf.lookup_receiver_model", + return_value=LyngdorfModel.MP_60, + ), + patch("homeassistant.components.lyngdorf.PLATFORMS", [Platform.NUMBER]), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(LIPSYNC_ENTITY_ID) is None + assert hass.states.get(TRIM_SURROUND_ENTITY_ID) is None + assert hass.states.get(TRIM_BASS_ENTITY_ID) is not None + + +@pytest.mark.usefixtures("init_integration") +async def test_channel_trims_disabled_by_default( + entity_registry: er.EntityRegistry, +) -> None: + """Test only the commonly used trims are enabled by default.""" + for entity_id in (LIPSYNC_ENTITY_ID, TRIM_BASS_ENTITY_ID, TRIM_TREBLE_ENTITY_ID): + assert entity_registry.async_get(entity_id).disabled_by is None + + for entity_id in ( + TRIM_CENTRE_ENTITY_ID, + TRIM_HEIGHT_ENTITY_ID, + TRIM_LFE_ENTITY_ID, + TRIM_SURROUND_ENTITY_ID, + ): + entry = entity_registry.async_get(entity_id) + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION From 1e7d85397c74dd55b582aea3a05198bef987acc9 Mon Sep 17 00:00:00 2001 From: Chris <1105672+firstof9@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:02:08 -0700 Subject: [PATCH 03/38] Add OpenEVSE switch platform (#179660) --- homeassistant/components/openevse/__init__.py | 1 + homeassistant/components/openevse/button.py | 2 +- homeassistant/components/openevse/helpers.py | 3 +- homeassistant/components/openevse/number.py | 2 +- .../components/openevse/strings.json | 11 + homeassistant/components/openevse/switch.py | 132 +++++++++++ .../openevse/snapshots/test_switch.ambr | 151 ++++++++++++ tests/components/openevse/test_switch.py | 222 ++++++++++++++++++ 8 files changed, 521 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/openevse/switch.py create mode 100644 tests/components/openevse/snapshots/test_switch.ambr create mode 100644 tests/components/openevse/test_switch.py diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py index 67c6fcd578068..7597394ac799c 100644 --- a/homeassistant/components/openevse/__init__.py +++ b/homeassistant/components/openevse/__init__.py @@ -16,6 +16,7 @@ Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/openevse/button.py b/homeassistant/components/openevse/button.py index 098f9f3983340..738195ce87629 100644 --- a/homeassistant/components/openevse/button.py +++ b/homeassistant/components/openevse/button.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Any, override -from openevsehttp.__main__ import OpenEVSE +from openevsehttp import OpenEVSE from homeassistant.components.button import ( ButtonDeviceClass, diff --git a/homeassistant/components/openevse/helpers.py b/homeassistant/components/openevse/helpers.py index b15cdccab93e5..32989aa9c5117 100644 --- a/homeassistant/components/openevse/helpers.py +++ b/homeassistant/components/openevse/helpers.py @@ -2,6 +2,7 @@ from collections.abc import Iterator from contextlib import contextmanager +from typing import Any from aiohttp import ContentTypeError, ServerTimeoutError from openevsehttp.exceptions import ( @@ -20,7 +21,7 @@ @contextmanager -def openevse_exception_handler(value: float) -> Iterator[None]: +def openevse_exception_handler(value: Any = None) -> Iterator[None]: """Context manager to handle and translate OpenEVSE exceptions.""" try: yield diff --git a/homeassistant/components/openevse/number.py b/homeassistant/components/openevse/number.py index 5cb186409767b..47da5f5db267c 100644 --- a/homeassistant/components/openevse/number.py +++ b/homeassistant/components/openevse/number.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Any, override -from openevsehttp.__main__ import OpenEVSE +from openevsehttp import OpenEVSE from homeassistant.components.number import ( NumberDeviceClass, diff --git a/homeassistant/components/openevse/strings.json b/homeassistant/components/openevse/strings.json index fdc8d77606cce..c2810b37097ac 100644 --- a/homeassistant/components/openevse/strings.json +++ b/homeassistant/components/openevse/strings.json @@ -207,6 +207,17 @@ "vehicle_soc": { "name": "Vehicle state of charge" } + }, + "switch": { + "current_shaper": { + "name": "Current shaper" + }, + "manual_override": { + "name": "Manual override" + }, + "solar_pv_divert": { + "name": "Solar PV divert" + } } }, "exceptions": { diff --git a/homeassistant/components/openevse/switch.py b/homeassistant/components/openevse/switch.py new file mode 100644 index 0000000000000..a488465f96a7a --- /dev/null +++ b/homeassistant/components/openevse/switch.py @@ -0,0 +1,132 @@ +"""Support for OpenEVSE switch entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, override + +from openevsehttp import OpenEVSE + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import ATTR_CONNECTIONS, ATTR_SERIAL_NUMBER +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator +from .helpers import openevse_exception_handler + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OpenEVSESwitchDescription(SwitchEntityDescription): + """Describes an OpenEVSE switch entity.""" + + is_on_fn: Callable[[OpenEVSE], bool | None] + turn_on_fn: Callable[[OpenEVSE], Awaitable[Any]] + turn_off_fn: Callable[[OpenEVSE], Awaitable[Any]] + + +SWITCH_TYPES: tuple[OpenEVSESwitchDescription, ...] = ( + OpenEVSESwitchDescription( + key="solar_pv_divert", + translation_key="solar_pv_divert", + is_on_fn=lambda ev: ( + ev.divertmode == "eco" if ev.divertmode is not None else None + ), + turn_on_fn=lambda ev: ev.set_divert_mode("eco"), + turn_off_fn=lambda ev: ev.set_divert_mode( + "fast" + ), # "fast" disables solar divert + ), + OpenEVSESwitchDescription( + key="current_shaper", + translation_key="current_shaper", + is_on_fn=lambda ev: ev.shaper_active, + turn_on_fn=lambda ev: ev.set_shaper(True), + turn_off_fn=lambda ev: ev.set_shaper(False), + ), + OpenEVSESwitchDescription( + key="manual_override", + translation_key="manual_override", + is_on_fn=lambda ev: ev.manual_override, + turn_on_fn=lambda ev: ev.toggle_override(), + turn_off_fn=lambda ev: ev.toggle_override(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenEVSEConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OpenEVSE switches based on config entry.""" + coordinator = entry.runtime_data + async_add_entities( + OpenEVSESwitch( + coordinator, + description, + entry.unique_id or entry.entry_id, + entry.unique_id, + ) + for description in SWITCH_TYPES + ) + + +class OpenEVSESwitch(CoordinatorEntity[OpenEVSEDataUpdateCoordinator], SwitchEntity): + """Implementation of an OpenEVSE switch.""" + + _attr_has_entity_name = True + entity_description: OpenEVSESwitchDescription + + def __init__( + self, + coordinator: OpenEVSEDataUpdateCoordinator, + description: OpenEVSESwitchDescription, + identifier: str, + unique_id: str | None, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{identifier}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer="OpenEVSE", + ) + if unique_id: + self._attr_device_info[ATTR_CONNECTIONS] = { + (CONNECTION_NETWORK_MAC, unique_id) + } + self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id + + @property + @override + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and self.entity_description.is_on_fn(self.coordinator.charger) is not None + ) + + @property + @override + def is_on(self) -> bool | None: + """Return True if the switch is on.""" + return self.entity_description.is_on_fn(self.coordinator.charger) + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + with openevse_exception_handler(): + await self.entity_description.turn_on_fn(self.coordinator.charger) + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + with openevse_exception_handler(): + await self.entity_description.turn_off_fn(self.coordinator.charger) diff --git a/tests/components/openevse/snapshots/test_switch.ambr b/tests/components/openevse/snapshots/test_switch.ambr new file mode 100644 index 0000000000000..f71771ff4f1be --- /dev/null +++ b/tests/components/openevse/snapshots/test_switch.ambr @@ -0,0 +1,151 @@ +# serializer version: 1 +# name: test_entities[switch.openevse_mock_config_current_shaper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.openevse_mock_config_current_shaper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current shaper', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current shaper', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_shaper', + 'unique_id': 'deadbeeffeed-current_shaper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.openevse_mock_config_current_shaper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'openevse_mock_config Current shaper', + }), + 'context': , + 'entity_id': 'switch.openevse_mock_config_current_shaper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.openevse_mock_config_manual_override-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.openevse_mock_config_manual_override', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Manual override', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Manual override', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'manual_override', + 'unique_id': 'deadbeeffeed-manual_override', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.openevse_mock_config_manual_override-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'openevse_mock_config Manual override', + }), + 'context': , + 'entity_id': 'switch.openevse_mock_config_manual_override', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.openevse_mock_config_solar_pv_divert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.openevse_mock_config_solar_pv_divert', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar PV divert', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Solar PV divert', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_pv_divert', + 'unique_id': 'deadbeeffeed-solar_pv_divert', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.openevse_mock_config_solar_pv_divert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'openevse_mock_config Solar PV divert', + }), + 'context': , + 'entity_id': 'switch.openevse_mock_config_solar_pv_divert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/openevse/test_switch.py b/tests/components/openevse/test_switch.py new file mode 100644 index 0000000000000..a17c5e407c3c7 --- /dev/null +++ b/tests/components/openevse/test_switch.py @@ -0,0 +1,222 @@ +"""Tests for the OpenEVSE switch platform.""" + +from unittest.mock import MagicMock, patch + +from aiohttp import ContentTypeError, ServerTimeoutError +from openevsehttp.exceptions import ( + AuthenticationError, + ParseJSONError, + UnsupportedFeature, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.openevse.const import DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + HomeAssistantError, + ServiceValidationError, +) +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test the switch entities.""" + with patch("homeassistant.components.openevse.PLATFORMS", [Platform.SWITCH]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "service", "method_name", "args"), + [ + pytest.param( + "switch.openevse_mock_config_solar_pv_divert", + SERVICE_TURN_ON, + "set_divert_mode", + ("eco",), + id="solar_pv_divert_on", + ), + pytest.param( + "switch.openevse_mock_config_solar_pv_divert", + SERVICE_TURN_OFF, + "set_divert_mode", + ("fast",), + id="solar_pv_divert_off", + ), + pytest.param( + "switch.openevse_mock_config_current_shaper", + SERVICE_TURN_ON, + "set_shaper", + (True,), + id="current_shaper_on", + ), + pytest.param( + "switch.openevse_mock_config_current_shaper", + SERVICE_TURN_OFF, + "set_shaper", + (False,), + id="current_shaper_off", + ), + pytest.param( + "switch.openevse_mock_config_manual_override", + SERVICE_TURN_ON, + "toggle_override", + (), + id="manual_override_on", + ), + pytest.param( + "switch.openevse_mock_config_manual_override", + SERVICE_TURN_OFF, + "toggle_override", + (), + id="manual_override_off", + ), + ], +) +async def test_switch_turn_on_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, + entity_id: str, + service: str, + method_name: str, + args: tuple[object, ...], +) -> None: + """Test turning on and off the switch entities.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + getattr(mock_charger, method_name).assert_called_once_with(*args) + + +@pytest.mark.parametrize( + ("raised", "expected", "translation_key", "translation_placeholders"), + [ + pytest.param( + ValueError("invalid mode"), + ServiceValidationError, + "invalid_value", + {"value": "None"}, + id="value_error", + ), + pytest.param( + AuthenticationError("bad creds"), + ConfigEntryAuthFailed, + "authentication_error", + None, + id="auth_error", + ), + pytest.param( + TimeoutError("timed out"), + HomeAssistantError, + "communication_error", + None, + id="timeout_error", + ), + pytest.param( + ServerTimeoutError("timed out"), + HomeAssistantError, + "communication_error", + None, + id="server_timeout_error", + ), + pytest.param( + ParseJSONError("bad json"), + HomeAssistantError, + "communication_error", + None, + id="parse_json_error", + ), + pytest.param( + UnsupportedFeature("old firmware"), + HomeAssistantError, + "unsupported_feature", + None, + id="unsupported_feature", + ), + pytest.param( + ContentTypeError(MagicMock(), (), message="bad content"), + HomeAssistantError, + "communication_error", + None, + id="content_type_error", + ), + ], +) +async def test_switch_raises( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, + raised: Exception, + expected: type[Exception], + translation_key: str, + translation_placeholders: dict[str, str] | None, +) -> None: + """Test that errors from the charger are translated to HA exceptions.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_charger.set_shaper.side_effect = raised + + with pytest.raises(expected) as exc_info: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.openevse_mock_config_current_shaper", + }, + blocking=True, + ) + + assert exc_info.value.translation_key == translation_key + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_placeholders == translation_placeholders + + +async def test_switch_availability( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test switch entity availability when is_on_fn returns None.""" + mock_charger.divertmode = None + mock_charger.shaper_active = True + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("switch.openevse_mock_config_solar_pv_divert") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get("switch.openevse_mock_config_current_shaper") + assert state is not None + assert state.state == STATE_ON From fde9c535ac34a4b84907ed8ceb12a64300c6996c Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 21 Aug 2026 20:19:01 +0200 Subject: [PATCH 04/38] Add activation and skip reason to gardena (#169919) Co-authored-by: Copilot --- .../components/gardena_bluetooth/sensor.py | 32 +++- .../components/gardena_bluetooth/strings.json | 24 +++ .../snapshots/test_sensor.ambr | 160 ++++++++++++++++++ .../gardena_bluetooth/test_sensor.py | 8 +- 4 files changed, 222 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 6916e4b691471..4281c81b96379 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -214,11 +214,41 @@ def context(self) -> set[str]: char=EventHistory.error, get=lambda x: ( x.error_code.name.lower() - if x and isinstance(x.error_code, EventHistory.error.enum) + if x is not None and isinstance(x.error_code, EventHistory.error.enum) else None ), options=[member.name.lower() for member in EventHistory.error.enum], ), + GardenaBluetoothSensorEntityDescription( + key="aqua_contour_activation_reason", + translation_key="activation_reason", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + char=AquaContourWatering.activation_reason, + get=lambda x: ( + x.name.lower() + if isinstance(x, AquaContourWatering.activation_reason.enum) + else None + ), + options=[ + member.name.lower() for member in AquaContourWatering.activation_reason.enum + ], + ), + GardenaBluetoothSensorEntityDescription( + key="aqua_contour_skipped_reason", + translation_key="skipped_reason", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + char=AquaContourWatering.skipped_reason, + get=lambda x: ( + x.name.lower() + if isinstance(x, AquaContourWatering.skipped_reason.enum) + else None + ), + options=[ + member.name.lower() for member in AquaContourWatering.skipped_reason.enum + ], + ), GardenaBluetoothSensorEntityDescription( key="aqua_contour_error_timestamp", translation_key="error_timestamp", diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index ed52daef4cc1b..62202a8258bc3 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -162,6 +162,30 @@ "sensor_type": { "name": "Sensor type" }, + "skipped_reason": { + "name": "Skipped reason", + "state": { + "battery_empty": "Battery empty", + "charging_cable_plugged": "Charging cable plugged", + "contour_data_invalid": "Contour data invalid", + "contour_not_active": "Contour not active", + "contour_not_enabled_for_position": "Contour not enabled for position", + "humidity_sensor": "Humidity sensor", + "irrigation_control_changed": "Irrigation control changed", + "manual_mode": "Manual mode", + "no_water": "No water", + "none": "Inactive", + "operational_mode_changed": "Operational mode changed", + "other_schedule_with_same_start_time": "Other schedule with same start time", + "position_changed": "Position changed", + "rain_pause": "Rain pause", + "rain_sensor": "Rain sensor", + "rotation_sensor_error": "Rotation sensor error", + "sprinkler_motor_error": "Sprinkler motor error", + "valve_motor_error": "Valve motor error", + "watering_already_active": "Watering already active" + } + }, "spray_current_distance": { "name": "Current distance" }, diff --git a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr index 43cc42f665bc9..c98a9e250104c 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr @@ -31,6 +31,72 @@ 'state': '45', }) # --- +# name: test_sensors[aqua_contour][sensor.mock_title_activation_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'none', + 'manual', + 'schedule', + 'external', + 'setup', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_activation_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Activation reason', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Activation reason', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'activation_reason', + 'unique_id': '00000000-0000-0000-0000-000000000003-aqua_contour_activation_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqua_contour][sensor.mock_title_activation_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Mock Title Activation reason', + : list([ + 'none', + 'manual', + 'schedule', + 'external', + 'setup', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_activation_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'schedule', + }) +# --- # name: test_sensors[aqua_contour][sensor.mock_title_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -433,6 +499,100 @@ 'state': '111', }) # --- +# name: test_sensors[aqua_contour][sensor.mock_title_skipped_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'none', + 'rain_pause', + 'humidity_sensor', + 'rain_sensor', + 'watering_already_active', + 'battery_empty', + 'other_schedule_with_same_start_time', + 'contour_not_active', + 'contour_not_enabled_for_position', + 'contour_data_invalid', + 'position_changed', + 'charging_cable_plugged', + 'manual_mode', + 'no_water', + 'valve_motor_error', + 'sprinkler_motor_error', + 'rotation_sensor_error', + 'operational_mode_changed', + 'irrigation_control_changed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_skipped_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Skipped reason', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Skipped reason', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'skipped_reason', + 'unique_id': '00000000-0000-0000-0000-000000000003-aqua_contour_skipped_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqua_contour][sensor.mock_title_skipped_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Mock Title Skipped reason', + : list([ + 'none', + 'rain_pause', + 'humidity_sensor', + 'rain_sensor', + 'watering_already_active', + 'battery_empty', + 'other_schedule_with_same_start_time', + 'contour_not_active', + 'contour_not_enabled_for_position', + 'contour_data_invalid', + 'position_changed', + 'charging_cable_plugged', + 'manual_mode', + 'no_water', + 'valve_motor_error', + 'sprinkler_motor_error', + 'rotation_sensor_error', + 'operational_mode_changed', + 'irrigation_control_changed', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_skipped_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'rain_sensor', + }) +# --- # name: test_sensors[aqua_contour][sensor.mock_title_watering_finished-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/gardena_bluetooth/test_sensor.py b/tests/components/gardena_bluetooth/test_sensor.py index 6cb2f625b7202..4000a5f798ad3 100644 --- a/tests/components/gardena_bluetooth/test_sensor.py +++ b/tests/components/gardena_bluetooth/test_sensor.py @@ -15,7 +15,7 @@ Spray, Valve, ) -from gardena_bluetooth.parse import ActivationReason, ErrorData +from gardena_bluetooth.parse import ActivationReason, ErrorData, SkipReason from habluetooth import BluetoothServiceInfo import pytest from syrupy.assertion import SnapshotAssertion @@ -114,6 +114,12 @@ async def test_setup( AquaContourWatering.remaining_watering_time.unique_id: ( AquaContourWatering.remaining_watering_time.encode(100) ), + AquaContourWatering.activation_reason.uuid: AquaContourWatering.activation_reason.encode( + ActivationReason.SCHEDULE + ), + AquaContourWatering.skipped_reason.uuid: AquaContourWatering.skipped_reason.encode( + SkipReason.RAIN_SENSOR + ), }, id="aqua_contour", ), From b7016f73792e1369989b8a8484e0339f5d1422e1 Mon Sep 17 00:00:00 2001 From: soldier2008 <217476753+soldier2008@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:06:14 -0300 Subject: [PATCH 05/38] Recompute the local calendar event instead of caching it (#178763) --- .../components/local_calendar/calendar.py | 30 +++++++--- .../local_calendar/test_calendar.py | 60 ++++++++++++++++++- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 76610a9842376..cd52e903c0160 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -10,6 +10,7 @@ from ical.event import Event from ical.exceptions import CalendarParseError from ical.store import EventStore, EventStoreError +from ical.timeline import Timeline, materialize_timeline from ical.types import Range, Recur import voluptuous as vol @@ -34,6 +35,12 @@ PRODID = "-//homeassistant.io//local_calendar 1.0//EN" +# Materialize a bounded timeline of upcoming events on every update so the +# state can be recomputed synchronously, without walking recurrence rules in +# the event loop. Mirrors what remote_calendar does. +MAX_LOOKAHEAD_EVENTS = 20 +MAX_LOOKAHEAD_TIME = timedelta(days=365) + async def async_setup_entry( hass: HomeAssistant, @@ -74,7 +81,7 @@ def __init__( self._store = store self._calendar = calendar self._calendar_lock = asyncio.Lock() - self._event: CalendarEvent | None = None + self._timeline: Timeline | None = None self._attr_name = name self._attr_unique_id = unique_id @@ -82,7 +89,12 @@ def __init__( @override def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - return self._event + if self._timeline is None: + return None + events = self._timeline.active_after(dt_util.now()) + if event := next(events, None): + return _get_calendar_event(event) + return None @override async def async_get_events( @@ -102,14 +114,16 @@ def events_in_range() -> list[CalendarEvent]: async def async_update(self) -> None: """Update entity state with the next upcoming event.""" - def next_event() -> CalendarEvent | None: + def _get_timeline() -> Timeline: now = dt_util.now() - events = self._calendar.timeline_tz(now.tzinfo).active_after(now) - if event := next(events, None): - return _get_calendar_event(event) - return None + return materialize_timeline( + self._calendar.timeline_tz(now.tzinfo), + start=now, + stop=now + MAX_LOOKAHEAD_TIME, + max_number_of_events=MAX_LOOKAHEAD_EVENTS, + ) - self._event = await self.hass.async_add_executor_job(next_event) + self._timeline = await self.hass.async_add_executor_job(_get_timeline) async def _async_store(self) -> None: """Persist the calendar to disk.""" diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index cc2a1385a4fbd..0cf1bc138cd6f 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -1,13 +1,18 @@ """Tests for calendar platform of local calendar.""" import datetime +from datetime import timedelta import textwrap +from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.local_calendar.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .conftest import ( @@ -18,7 +23,7 @@ event_fields, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_empty_calendar( @@ -1158,3 +1163,56 @@ async def test_invalid_event_duration( "end": {"dateTime": "1997-07-14T11:30:00-06:00"}, } ] + + +ADJACENT_EVENTS_ICS = """BEGIN:VCALENDAR +PRODID:-//homeassistant.io//local_calendar 1.0//EN +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20260729T014500 +DTEND:20260729T020000 +SUMMARY:First +UID:first +END:VEVENT +BEGIN:VEVENT +DTSTART:20260729T020000 +DTEND:20260729T021500 +SUMMARY:Second +UID:second +END:VEVENT +END:VCALENDAR +""" + + +@pytest.mark.parametrize("ics_content", [ADJACENT_EVENTS_ICS]) +async def test_adjacent_events_stay_on( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, +) -> None: + """Test the state stays on when one event ends as the next one begins. + + The scan interval is widened so the platform poll cannot reach the boundary + first: what is under test is the alarm scheduled for the end of the current + event, which has to be able to pick up the next one on its own. + """ + freezer.move_to("2026-07-29 07:50:20+00:00") # 01:50:20 in America/Regina + + config_entry.add_to_hass(hass) + with patch("homeassistant.components.calendar.SCAN_INTERVAL", timedelta(hours=1)): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY) + assert state.state == STATE_ON + assert state.attributes["message"] == "First" + + # 02:00:00 in America/Regina, the moment the first event ends and the + # second begins. + freezer.move_to("2026-07-29 08:00:00+00:00") + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY) + assert state.state == STATE_ON + assert state.attributes["message"] == "Second" From 83ddd411b777bd10e711eebd5cb6129a587d1aa6 Mon Sep 17 00:00:00 2001 From: Harvey <42912136+flip-dots@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:06:59 +0100 Subject: [PATCH 06/38] Use latest HueBLE (#179721) --- homeassistant/components/hue_ble/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hue_ble/manifest.json b/homeassistant/components/hue_ble/manifest.json index fffc31c3e93f9..801f291476307 100644 --- a/homeassistant/components/hue_ble/manifest.json +++ b/homeassistant/components/hue_ble/manifest.json @@ -16,5 +16,5 @@ "iot_class": "local_push", "loggers": ["bleak", "HueBLE"], "quality_scale": "bronze", - "requirements": ["HueBLE==2.2.2"] + "requirements": ["HueBLE==2.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9d0f4095398b4..f47ee131c3a58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -22,7 +22,7 @@ HAP-python==5.0.0 HATasmota==0.10.1 # homeassistant.components.hue_ble -HueBLE==2.2.2 +HueBLE==2.2.3 # homeassistant.components.mastodon Mastodon.py==2.2.1 From 5f6ab83e0df09c75d1918de3f2e0424ddb951943 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:08:52 -0400 Subject: [PATCH 07/38] Bump nice-go to 1.0.3 (#179747) --- homeassistant/components/nice_go/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json index dbf22e25274c1..79b64a47302a6 100644 --- a/homeassistant/components/nice_go/manifest.json +++ b/homeassistant/components/nice_go/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["nice_go"], - "requirements": ["nice-go==1.0.2"] + "requirements": ["nice-go==1.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index f47ee131c3a58..4b880cc1e6fd4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1697,7 +1697,7 @@ nhc==0.8.0 nibe==2.24.0 # homeassistant.components.nice_go -nice-go==1.0.2 +nice-go==1.0.3 # homeassistant.components.nilu niluclient==0.1.2 From 11768c73e69cfb0dac38b7792aa72a466e5f5023 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:10:33 +0200 Subject: [PATCH 08/38] Update numpy to 2.5.2 (#178633) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/stream/core.py | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- script/gen_requirements_all.py | 2 +- script/licenses.py | 2 ++ 9 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 4de2a39ec3254..5b256b28690c8 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", "quality_scale": "legacy", - "requirements": ["numpy==2.3.2"] + "requirements": ["numpy==2.5.2"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 48a89f5a96a4a..b4977a5de2cc2 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==2.3.2", "pyiqvia==2022.04.0"] + "requirements": ["numpy==2.5.2", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 3a3d9f9a75cf7..1203d8b66563c 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -484,7 +484,7 @@ def create_codec_context(self, codec_context: VideoCodecContext) -> None: @staticmethod def transform_image(image: np.ndarray, orientation: int) -> np.ndarray: """Transform image to a given orientation.""" - return TRANSFORM_IMAGE_FUNCTION[orientation](image) + return TRANSFORM_IMAGE_FUNCTION[orientation](image) # type: ignore[no-any-return] def _generate_image(self, width: int | None, height: int | None) -> None: """Generate the keyframe image. diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index b9cc560699d41..664e6097691ae 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.8.3", "av==17.0.1", "numpy==2.3.2"] + "requirements": ["PyTurboJPEG==1.8.3", "av==17.0.1", "numpy==2.5.2"] } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 39ed17a3fbaf1..05a248e1527d8 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -8,5 +8,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==2.3.2"] + "requirements": ["numpy==2.5.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d5de9f1085a6c..18fc4369dc44b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -131,7 +131,7 @@ httpcore==1.0.9 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.3.2 +numpy==2.5.2 pandas==2.3.3 # Constrain multidict to avoid typing issues diff --git a/requirements_all.txt b/requirements_all.txt index 4b880cc1e6fd4..49af0f6f9dadc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1730,7 +1730,7 @@ numato-gpio==0.13.0 # homeassistant.components.iqvia # homeassistant.components.stream # homeassistant.components.trend -numpy==2.3.2 +numpy==2.5.2 # homeassistant.components.nyt_games nyt_games==0.5.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 5304b2be7fbe7..e532f8f1e2fd8 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -114,7 +114,7 @@ hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.3.2 +numpy==2.5.2 pandas==2.3.3 # Constrain multidict to avoid typing issues diff --git a/script/licenses.py b/script/licenses.py index 6603b643e01ce..e21e44adf8a6c 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -192,6 +192,8 @@ def from_dict(cls, data: PackageMetadata) -> PackageDefinition: "ld2410-ble", # https://github.com/930913/ld2410-ble/pull/7 "maxcube-api", # https://github.com/uebelack/python-maxcube-api/pull/48 "neurio", # https://github.com/jordanh/neurio-python/pull/13 + # numpy: BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0 + "numpy", # CC0-1.0 is not OSI approved "nsw-fuel-api-client", # https://github.com/nickw444/nsw-fuel-api-client/pull/14 "pigpio", # https://github.com/joan2937/pigpio/pull/608 "pymitv", # MIT From 4763a198268bf797dca59e73ae441a8bb31cc010 Mon Sep 17 00:00:00 2001 From: Manan Bansal <66985466+manan-tech@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:41:16 +0530 Subject: [PATCH 09/38] Fix derivative sensor staying unavailable after total_increasing reset (#173899) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/derivative/sensor.py | 9 +++- tests/components/derivative/test_sensor.py | 52 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index eb33dad6cced9..3fd227d1156f7 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -489,7 +489,8 @@ def calc_derivative( old_timestamp: datetime, ) -> None: """Handle the sensor state changes.""" - if not _is_decimal_state(old_value): + recovered_from_invalid = not _is_decimal_state(old_value) + if recovered_from_invalid: if self._last_valid_state_time: old_value = self._last_valid_state_time[0] old_timestamp = self._last_valid_state_time[1] @@ -550,6 +551,12 @@ def calc_derivative( "%s: Dropping sample as source total_increasing sensor decreased", self.entity_id, ) + if recovered_from_invalid: + # Reset while recovering from an invalid source: re-baseline + # and report zero so the entity doesn't stay stuck unavailable. + self._state_list = [] + self._last_valid_state_time = (new_state.state, new_timestamp) + self._write_native_value(Decimal(0)) return # add latest derivative to the window list diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index f147139553631..5c54176ce7788 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -894,6 +894,58 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None: assert actual_values == expected_values +@pytest.mark.parametrize("bad_state", [STATE_UNAVAILABLE, STATE_UNKNOWN]) +@pytest.mark.parametrize( + ("extra_config", "active_value", "recovered_value"), + [ + pytest.param({}, "5.00", "1.00", id="no_time_window"), + pytest.param( + {"time_window": {"seconds": 60}}, "0.83", "0.17", id="time_window" + ), + ], +) +async def test_total_increasing_reset_while_unavailable( + hass: HomeAssistant, + bad_state: str, + extra_config: dict[str, Any], + active_value: str, + recovered_value: str, +) -> None: + """Test derivative recovers when a total_increasing source resets while unavailable. + + Regression test for a total_increasing source (e.g. a daily energy sensor) + that briefly goes unavailable/unknown around midnight and returns with its + value reset to 0. The derivative must report a zero rate of change on the + reset sample and must not stay stuck in the unavailable/unknown state until + the next state change is received, regardless of the configured time window. + The first normal sample after the reset must produce a sensible positive + rate again, proving the source value was re-baselined to the post-reset + value rather than the stale pre-reset one. + """ + times = [0, 10, 20, 30, 40] + values = [0, 50, bad_state, 0, 10] + expected_states = ["0.00", active_value, bad_state, "0.00", recovered_value] + + _config, entity_id = await _setup_sensor( + hass, {"unit_time": UnitOfTime.SECONDS} | extra_config + ) + + base_time = dt_util.utcnow() + with freeze_time(base_time) as freezer: + for time, value, expected in zip(times, values, expected_states, strict=True): + freezer.move_to(base_time + timedelta(seconds=time)) + hass.states.async_set( + entity_id, + value, + {ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING}, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + assert state is not None + assert state.state == expected + + async def test_device_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 2cc0f1a96df98ff9b992d0104537f9a551074a52 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:11:46 +0800 Subject: [PATCH 10/38] Switchbot Cloud:Add a night light control to the fan (#177514) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .../components/switchbot_cloud/__init__.py | 3 + .../components/switchbot_cloud/const.py | 25 ++- .../components/switchbot_cloud/icons.json | 5 + .../components/switchbot_cloud/select.py | 106 +++++++++++++ .../components/switchbot_cloud/strings.json | 12 +- tests/components/switchbot_cloud/__init__.py | 1 - .../fixtures/sensor_status.json | 13 ++ .../components/switchbot_cloud/test_select.py | 144 ++++++++++++++++++ 8 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/switchbot_cloud/select.py create mode 100644 tests/components/switchbot_cloud/test_select.py diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 30d5d7baec234..1a697a2ed9a6b 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -43,6 +43,7 @@ Platform.IMAGE, Platform.LIGHT, Platform.LOCK, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.VACUUM, @@ -64,6 +65,7 @@ class SwitchbotDevices: switches: list[tuple[Device | Remote, SwitchBotCoordinator]] = field( default_factory=list ) + selects: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) sensors: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) @@ -261,6 +263,7 @@ async def make_new_device_data( Platform.IMAGE: devices_data.images, Platform.LIGHT: devices_data.lights, Platform.LOCK: devices_data.locks, + Platform.SELECT: devices_data.selects, Platform.SENSOR: devices_data.sensors, Platform.SWITCH: devices_data.switches, Platform.VACUUM: devices_data.vacuums, diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 57e86b25e8d21..57a519aef327b 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -36,6 +36,25 @@ 100: 103, # High humidity mode } +NIGHT_LIGHT_ON = "on" +NIGHT_LIGHT_OFF = "off" +NIGHT_LIGHT_BRIGHT = "bright" +NIGHT_LIGHT_SOFT = "soft" + +STANDING_FAN_NIGHT_LIGHT_PARAMETERS_MAP = { + NIGHT_LIGHT_ON: "on", + NIGHT_LIGHT_OFF: "off", + NIGHT_LIGHT_BRIGHT: "1", + NIGHT_LIGHT_SOFT: "2", +} + +BATTERY_CIRCULATOR_FAN_2_PRO_NIGHT_LIGHT_PARAMETERS_MAP = { + NIGHT_LIGHT_ON: "on", + NIGHT_LIGHT_OFF: "off", + NIGHT_LIGHT_BRIGHT: "0", + NIGHT_LIGHT_SOFT: "1", +} + @dataclass(frozen=True) class SwitchbotCloudDeviceConfig: @@ -128,13 +147,13 @@ class SwitchbotCloudDeviceConfig: ), "Circulator Fan": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.FAN,)), "Standing Fan": SwitchbotCloudDeviceConfig( - True, entity_config=(Platform.SENSOR, Platform.FAN) + True, entity_config=(Platform.SENSOR, Platform.FAN, Platform.SELECT) ), "Battery Circulator Fan": SwitchbotCloudDeviceConfig( - True, entity_config=(Platform.SENSOR, Platform.FAN) + True, entity_config=(Platform.SENSOR, Platform.FAN, Platform.SELECT) ), "Battery Circulator Fan 2 Pro": SwitchbotCloudDeviceConfig( - True, entity_config=(Platform.SENSOR, Platform.FAN) + True, entity_config=(Platform.SENSOR, Platform.FAN, Platform.SELECT) ), "Water Detector": SwitchbotCloudDeviceConfig( True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR) diff --git a/homeassistant/components/switchbot_cloud/icons.json b/homeassistant/components/switchbot_cloud/icons.json index cfd29f54123bf..859d0ad118437 100644 --- a/homeassistant/components/switchbot_cloud/icons.json +++ b/homeassistant/components/switchbot_cloud/icons.json @@ -53,6 +53,11 @@ } } }, + "select": { + "night_light_control": { + "default": "mdi:lightbulb-night" + } + }, "sensor": { "light_level": { "default": "mdi:brightness-7", diff --git a/homeassistant/components/switchbot_cloud/select.py b/homeassistant/components/switchbot_cloud/select.py new file mode 100644 index 0000000000000..6ab3e70babff6 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/select.py @@ -0,0 +1,106 @@ +"""SwitchBotCloudSelect entity.""" + +from typing import TYPE_CHECKING, override + +from switchbot_api import BatteryCirculatorFanCommands, Device, Remote, SwitchBotAPI + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator +from .const import ( + BATTERY_CIRCULATOR_FAN_2_PRO_NIGHT_LIGHT_PARAMETERS_MAP, + NIGHT_LIGHT_BRIGHT, + NIGHT_LIGHT_ON, + NIGHT_LIGHT_SOFT, + STANDING_FAN_NIGHT_LIGHT_PARAMETERS_MAP, +) +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: SwitchbotCloudConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data = config.runtime_data + async_add_entities( + _async_make_entity(data.api, device, coordinator) + for device, coordinator in data.devices.selects + ) + + +class SwitchBotCloudStandingFanNightLight(SwitchBotCloudEntity, SelectEntity): + """SwitchBotCloud Standing Fan Night Light.""" + + _night_light_parameters_map: dict[str, str] = ( + STANDING_FAN_NIGHT_LIGHT_PARAMETERS_MAP + ) + _attr_entity_category = EntityCategory.CONFIG + _attr_current_option: str | None = None + + _attr_translation_key = "night_light_control" + _attr_options = list(_night_light_parameters_map) + + @override + async def async_select_option(self, option: str) -> None: + """Select the night light mode.""" + if option == NIGHT_LIGHT_ON: + para = self._night_light_parameters_map.get( + NIGHT_LIGHT_BRIGHT + ) or self._night_light_parameters_map.get(NIGHT_LIGHT_SOFT) + if TYPE_CHECKING: + assert para is not None + await self.send_api_command( + BatteryCirculatorFanCommands.SET_NIGHT_LIGHT_MODE, + parameters=para, + ) + else: + await self.send_api_command( + BatteryCirculatorFanCommands.SET_NIGHT_LIGHT_MODE, + parameters=self._night_light_parameters_map[option], + ) + self._attr_current_option = option + self.async_write_ha_state() + + @override + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if self.coordinator.data is None: + return + night_status = self.coordinator.data.get("nightStatus") + for key, value in self._night_light_parameters_map.items(): + if value == night_status: + self._attr_current_option = key + return + self._attr_current_option = None + + +class SwitchBotCloudBatteryCirculatorFan2ProNightLight( + SwitchBotCloudStandingFanNightLight +): + """SwitchBotCloud Battery Circulator Fan 2 Pro Night Light.""" + + _night_light_parameters_map: dict[str, str] = ( + BATTERY_CIRCULATOR_FAN_2_PRO_NIGHT_LIGHT_PARAMETERS_MAP + ) + + +@callback +def _async_make_entity( + api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator +) -> ( + SwitchBotCloudStandingFanNightLight + | SwitchBotCloudBatteryCirculatorFan2ProNightLight +): + """Make a SwitchBotCloudSelect entity.""" + if device.device_type in ["Standing Fan", "Battery Circulator Fan"]: + return SwitchBotCloudStandingFanNightLight(api, device, coordinator) + if device.device_type == "Battery Circulator Fan 2 Pro": + return SwitchBotCloudBatteryCirculatorFan2ProNightLight( + api, device, coordinator + ) + raise NotImplementedError diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json index a75a0f008cac8..8f500ec9cbcd0 100644 --- a/homeassistant/components/switchbot_cloud/strings.json +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -72,7 +72,17 @@ "name": "Display" } }, - + "select": { + "night_light_control": { + "name": "Night light", + "state": { + "bright": "Bright", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "soft": "Soft" + } + } + }, "sensor": { "light_level": { "name": "Light level" diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py index dae9263d7addf..bebd31077c72e 100644 --- a/tests/components/switchbot_cloud/__init__.py +++ b/tests/components/switchbot_cloud/__init__.py @@ -65,7 +65,6 @@ async def configure_integration(hass: HomeAssistant) -> MockConfigEntry: hubDeviceId="test-hub-id", ) - METER_INFO = Device( version="V1.0", deviceId="meter-id-1", diff --git a/tests/components/switchbot_cloud/fixtures/sensor_status.json b/tests/components/switchbot_cloud/fixtures/sensor_status.json index 2001d96339ee3..389028792190d 100644 --- a/tests/components/switchbot_cloud/fixtures/sensor_status.json +++ b/tests/components/switchbot_cloud/fixtures/sensor_status.json @@ -47,6 +47,19 @@ "fanSpeed": 3, "battery": 22 }, + { + "deviceId": "A1C3E5F7D9B0", + "deviceType": "Battery Circulator Fan 2 Pro", + "hubDeviceId": "FFFFFFFFFFF", + "mode": "direct", + "version": "V6.3", + "power": "on", + "nightStatus": "off", + "oscillation": "on", + "verticalOscillation": "on", + "fanSpeed": 3, + "battery": 22 + }, { "deviceId": "9B0D2F4A6C8E", "deviceType": "Meter", diff --git a/tests/components/switchbot_cloud/test_select.py b/tests/components/switchbot_cloud/test_select.py new file mode 100644 index 0000000000000..20e42e4475b60 --- /dev/null +++ b/tests/components/switchbot_cloud/test_select.py @@ -0,0 +1,144 @@ +"""Test for the switchbot_cloud select.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from switchbot_api import Device, SwitchBotAPI + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +@pytest.mark.parametrize( + "device", + [ + "Standing Fan", + "Battery Circulator Fan", + "Battery Circulator Fan 2 Pro", + ], +) +async def test_night_light_coordinator_data_is_none( + hass: HomeAssistant, + mock_list_devices: AsyncMock, + mock_get_status: AsyncMock, + device: str, +) -> None: + """Test coordinator data is none.""" + + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="device-id-1", + deviceName="device-1", + deviceType=device, + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [None, None] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "select.device_1_night_light" + state = hass.states.get(entity_id) + assert state.state == "unknown" + + +@pytest.mark.parametrize( + ("device", "key_type", "expected"), + [ + ("Standing Fan", "on", "1"), + ("Standing Fan", "off", "off"), + ("Standing Fan", "bright", "1"), + ("Standing Fan", "soft", "2"), + ("Battery Circulator Fan 2 Pro", "bright", "0"), + ("Battery Circulator Fan 2 Pro", "soft", "1"), + ], +) +async def test_night_light_options( + hass: HomeAssistant, + mock_list_devices: AsyncMock, + mock_get_status: AsyncMock, + device: str, + key_type: str, + expected: str, +) -> None: + """Test night light options.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="device-id-1", + deviceName="device-1", + deviceType=device, + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "deviceId": "B0E9FEDEB68C", + "deviceType": device, + "power": "on", + "fanSpeed": 3, + "mode": "direct", + "nightStatus": expected, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "select.device_1_night_light" + + with ( + patch.object(SwitchBotAPI, "send_command") as mocked_send_command, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": key_type}, + blocking=True, + ) + + mocked_send_command.assert_awaited_once() + assert mocked_send_command.await_args.args[3] == expected + + state = hass.states.get(entity_id) + assert state.state == key_type + + +async def test_night_light_options_not_exist( + hass: HomeAssistant, + mock_list_devices: AsyncMock, + mock_get_status: AsyncMock, +) -> None: + """Test night light options.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="standing-fan-id-1", + deviceName="standing-fan-1", + deviceType="Standing Fan", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "deviceId": "B0E9FEDEB68C", + "deviceType": "Standing Fan", + "power": "on", + "fanSpeed": 3, + "mode": "direct", + "nightStatus": "fake_option", + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "select.standing_fan_1_night_light" + + state = hass.states.get(entity_id) + assert state.state == "unknown" From a4bb1d04cee6ded0878cb98430844a5fc46e0f71 Mon Sep 17 00:00:00 2001 From: Fistacho <98597610+Fistacho@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:16:22 +0200 Subject: [PATCH 11/38] Pass config entry explicitly in Supla coordinator (#178347) --- homeassistant/components/supla/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/supla/coordinator.py b/homeassistant/components/supla/coordinator.py index debb78b2590bb..107ec4a88316f 100644 --- a/homeassistant/components/supla/coordinator.py +++ b/homeassistant/components/supla/coordinator.py @@ -28,6 +28,7 @@ def __init__( super().__init__( hass, _LOGGER, + config_entry=None, name=f"supla-{server_name}", update_interval=SCAN_INTERVAL, ) From a1b783d215ad33810d462d4e59d2ff3e918653c8 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 21 Aug 2026 21:19:12 +0200 Subject: [PATCH 12/38] Fix missing device trackers after initial setup in devolo Home Network (#179327) --- .../components/devolo_home_network/device_tracker.py | 1 + .../devolo_home_network/test_device_tracker.py | 9 +++------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 2910dd3067b5e..185e01a09432d 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -75,6 +75,7 @@ def restore_entities() -> None: async_add_entities(missing) restore_entities() + new_device_callback() entry.async_on_unload( coordinators[CONNECTED_WIFI_CLIENTS].async_add_listener(new_device_callback) ) diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 0e88be1b9c3a1..86ce0122c2e6b 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.devolo_home_network.const import ( DOMAIN, - LONG_UPDATE_INTERVAL, + SHORT_UPDATE_INTERVAL, ) from homeassistant.const import STATE_NOT_HOME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -40,16 +40,13 @@ async def test_device_tracker( entry = configure_integration(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - freezer.tick(LONG_UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() assert hass.states.get(entity_id) == snapshot # Emulate state change mock_device.device.async_get_wifi_connected_station = AsyncMock( return_value=NO_CONNECTED_STATIONS ) - freezer.tick(LONG_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -61,7 +58,7 @@ async def test_device_tracker( mock_device.device.async_get_wifi_connected_station = AsyncMock( side_effect=DeviceUnavailable ) - freezer.tick(LONG_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() From 7d730cbc8469777db3170a62de56c4de9cf2b2ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Alves?= <32654466+luismalves@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:20:16 +0100 Subject: [PATCH 13/38] Fix LiteLLM conversation subentry not remembering unselected LLM APIs (#178490) --- .../components/litellm/config_flow.py | 5 +-- tests/components/litellm/test_config_flow.py | 39 +++++++++++++++++-- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/litellm/config_flow.py b/homeassistant/components/litellm/config_flow.py index 0b8df8be1d44a..2ffecc07e3923 100644 --- a/homeassistant/components/litellm/config_flow.py +++ b/homeassistant/components/litellm/config_flow.py @@ -241,10 +241,7 @@ async def async_step_init( ): TemplateSelector(), vol.Optional( CONF_LLM_HASS_API, - default=self.options.get( - CONF_LLM_HASS_API, - RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API], - ), + default=self.options.get(CONF_LLM_HASS_API, []), ): SelectSelector( SelectSelectorConfig(options=hass_apis, multiple=True) ), diff --git a/tests/components/litellm/test_config_flow.py b/tests/components/litellm/test_config_flow.py index ac3a1675ab116..74dd58cfa5e28 100644 --- a/tests/components/litellm/test_config_flow.py +++ b/tests/components/litellm/test_config_flow.py @@ -17,6 +17,7 @@ from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import llm from . import get_subentry_id, setup_integration from .conftest import TEST_URL, models_response @@ -191,10 +192,10 @@ async def test_create_conversation_agent( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - assert ( - result["data_schema"].schema["model"].config["options"] - == CONVERSATION_MODEL_OPTIONS - ) + schema = result["data_schema"].schema + assert schema["model"].config["options"] == CONVERSATION_MODEL_OPTIONS + key = next(k for k in schema if k == CONF_LLM_HASS_API) + assert key.default() == [llm.LLM_API_ASSIST] result = await hass.config_entries.subentries.async_configure( result["flow_id"], @@ -337,6 +338,36 @@ async def test_reconfigure_conversation_agent( assert subentry.data[CONF_LLM_HASS_API] == ["assist"] +@pytest.mark.usefixtures("mock_models") +async def test_reconfigure_conversation_agent_disable_llm_api( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unchecking all LLM APIs is remembered when reopening the form.""" + await setup_integration(hass, mock_config_entry) + + subentry_id = get_subentry_id(mock_config_entry, "conversation") + + result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-4", + CONF_PROMPT: "updated prompt", + CONF_LLM_HASS_API: [], + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert CONF_LLM_HASS_API not in mock_config_entry.subentries[subentry_id].data + + result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + schema = result["data_schema"].schema + key = next(k for k in schema if k == CONF_LLM_HASS_API) + assert key.default() == [] + + async def test_reconfigure_entry_not_loaded( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 4712c5438f400fb6a761acf1ffdc94d6c276d12d Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:21:08 +0200 Subject: [PATCH 14/38] Fix HomeWizard setup flow giving error already_in_progress when trying to manually setup already discovered device (#177458) --- .../components/homewizard/config_flow.py | 8 ++-- .../components/homewizard/strings.json | 1 + .../components/homewizard/test_config_flow.py | 43 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 3d397e26014a1..627073d47b04d 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -19,7 +19,7 @@ import voluptuous as vol from homeassistant.components import onboarding -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import AbortFlow @@ -60,7 +60,8 @@ async def async_step_user( return await self.async_step_authorize() else: await self.async_set_unique_id( - f"{device_info.product_type}_{device_info.serial}" + f"{device_info.product_type}_{device_info.serial}", + raise_on_progress=False, ) self._abort_if_unique_id_configured(updates=user_input) return self.async_create_entry( @@ -110,7 +111,8 @@ async def async_step_authorize( } await self.async_set_unique_id( - f"{device_info.product_type}_{device_info.serial}" + f"{device_info.product_type}_{device_info.serial}", + raise_on_progress=self.source != SOURCE_USER, ) self._abort_if_unique_id_configured(updates=data) return self.async_create_entry( diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 475bff8640fc1..bbc6521a5f349 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "device_not_supported": "This device is not supported", "invalid_discovery_parameters": "Invalid discovery parameters", "reauth_enable_api_successful": "Enabling API was successful", diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 09f10b8e9edbf..e02a53901e232 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -379,6 +379,49 @@ async def test_discovery_flow_updates_new_ip( assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" +@pytest.mark.usefixtures("mock_homewizardenergy", "mock_setup_entry") +async def test_manual_flow_ignores_pending_discovery_for_same_device( + hass: HomeAssistant, +) -> None: + """Test the user flow is not blocked by a stale discovery flow for the same device.""" + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("1.0.0.127"), + ip_addresses=[ip_address("1.0.0.127")], + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "1", + "path": "/api/v1", + "product_name": "P1 Meter", + "product_type": "HWE-P1", + "serial": "5c2fafabcdef", + }, + ), + ) + + assert discovery_result["type"] is FlowResultType.FORM + assert discovery_result["step_id"] == "discovery_confirm" + assert len(hass.config_entries.flow.async_progress()) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_IP_ADDRESS] == "2.2.2.2" + + # The stale discovery flow is cleaned up once the manual flow succeeds + assert len(hass.config_entries.flow.async_progress()) == 0 + + @pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( ("exception", "reason"), From 28d14948d67cba467f60e36f96114fc2b1bf2b43 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:21:47 +0200 Subject: [PATCH 15/38] fix enphase envoy diagnostics report error (#177849) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/enphase_envoy/diagnostics.py | 6 ++- .../enphase_envoy/test_diagnostics.py | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index 1e0679bea5d9e..9dccdd46cf18c 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Any -from aiohttp import ClientResponse +from aiohttp import ClientError, ClientResponse from pyenphase.envoy import Envoy from pyenphase.exceptions import EnvoyError @@ -92,6 +92,10 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]: "code": response.status, } ) + except ClientError as err: + fixture_data[f"{end_point}_log"] = { + "Error": f"Aiohttp Client error {type(err).__name__ if not hasattr(err, 'status') else err.status}" + } except EnvoyError as err: fixture_data[f"{end_point}_log"] = {"Error": repr(err)} return fixture_data diff --git a/tests/components/enphase_envoy/test_diagnostics.py b/tests/components/enphase_envoy/test_diagnostics.py index fa3f2db5a77bc..d43e34a924dd5 100644 --- a/tests/components/enphase_envoy/test_diagnostics.py +++ b/tests/components/enphase_envoy/test_diagnostics.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock +from aiohttp import ClientConnectionError, ClientResponseError +from aiohttp.client import RequestInfo from freezegun.api import FrozenDateTimeFactory from pyenphase.exceptions import EnvoyError from pyenphase.models.meters import CtType @@ -95,6 +97,49 @@ async def test_entry_diagnostics_with_fixtures_with_error( ) == snapshot(exclude=limit_diagnostic_attrs) +async def test_entry_diagnostics_with_fixtures_with_clientresponse_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry_options: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_envoy: AsyncMock, +) -> None: + """Test diagnostics test fixtures with client errors.""" + await setup_integration(hass, config_entry_options) + mock_envoy.request.side_effect = ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers={ + "Host": "www.example.com", + "Connection": "keep-alive", + "secret": "very secret secret", + }, + real_url="http://example.com", + ), + None, + status=0, + ) + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry_options + ) + assert diagnostics["fixtures"]["/info_log"] == {"Error": "Aiohttp Client error 0"} + + mock_envoy.request.side_effect = EnvoyError("Test") + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry_options + ) + assert diagnostics["fixtures"]["/info_log"] == {"Error": "EnvoyError('Test')"} + + mock_envoy.request.side_effect = ClientConnectionError + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry_options + ) + assert diagnostics["fixtures"]["/info_log"] == { + "Error": "Aiohttp Client error ClientConnectionError" + } + + @pytest.mark.parametrize( ("mock_envoy"), [ From b28592f8badaa2f03d7453f991fdcc09c238082c Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 21 Aug 2026 22:25:13 +0300 Subject: [PATCH 16/38] Fix OpenRGB turn_off being a no-op for devices without Off mode in non-color modes (#179515) --- homeassistant/components/openrgb/light.py | 7 ++- tests/components/openrgb/test_light.py | 65 ++++++++++++++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openrgb/light.py b/homeassistant/components/openrgb/light.py index e88190944edb1..c8760ea7d56e4 100644 --- a/homeassistant/components/openrgb/light.py +++ b/homeassistant/components/openrgb/light.py @@ -407,7 +407,12 @@ async def async_turn_off(self, **kwargs: Any) -> None: if self._supports_off_mode: await self._async_apply_mode(OpenRGBMode.OFF) else: - # If the device does not support Off mode, set color to black + # If the device does not support Off mode, set color to black. + # Color writes are ignored while a mode without PER_LED color + # support (e.g. a firmware effect) is active — switch to the + # preferred no-effect mode first so the black actually lands. + if self._mode not in self._supports_color_modes: + await self._async_apply_mode(self._preferred_no_effect_mode) await self._async_apply_color(OFF_COLOR, 0) await self._async_refresh_data() diff --git a/tests/components/openrgb/test_light.py b/tests/components/openrgb/test_light.py index 61e16c216cb5b..4006b9843c854 100644 --- a/tests/components/openrgb/test_light.py +++ b/tests/components/openrgb/test_light.py @@ -2,7 +2,7 @@ from collections.abc import Generator import copy -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch from freezegun.api import FrozenDateTimeFactory from openrgb.utils import OpenRGBDisconnected, RGBColor @@ -620,10 +620,71 @@ async def test_turn_off_light_without_off_mode( blocking=True, ) - # Device should have set_color called with black/off color instead + # Device should have set_color called with black/off color instead, + # without any mode switch (the active mode is already color-capable) + mock_openrgb_device.set_mode.assert_not_called() mock_openrgb_device.set_color.assert_called_once_with(RGBColor(*OFF_COLOR), True) +@pytest.mark.usefixtures("mock_openrgb_client") +async def test_turn_off_light_without_off_mode_in_non_color_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openrgb_device: MagicMock, +) -> None: + """Test turning off a light without Off mode while a non-color mode is active. + + Color writes are ignored by the device while a mode without PER_LED + color support (e.g. a firmware effect) is active, so turning off must + first switch to the preferred no-effect mode — otherwise painting + black is a silent no-op and the light never turns off. + """ + # Modify the device to not have Off mode + mock_openrgb_device.modes = [ + mode_data + for mode_data in mock_openrgb_device.modes + if mode_data.name != OpenRGBMode.OFF + ] + # Activate a mode without PER_LED color support ("Spectrum Cycle") + mock_openrgb_device.active_mode = next( + index + for index, mode_data in enumerate(mock_openrgb_device.modes) + if mode_data.name == "Spectrum Cycle" + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Verify light is initially on + state = hass.states.get("light.ene_dram") + assert state + assert state.state == STATE_ON + + # Turn off the light + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.ene_dram"}, + blocking=True, + ) + + # The device must first be switched to the preferred no-effect mode + # (color-capable), then painted black — in that order + mock_openrgb_device.set_mode.assert_called_once_with(OpenRGBMode.DIRECT) + mock_openrgb_device.set_color.assert_called_once_with(RGBColor(*OFF_COLOR), True) + assert [ + call + for call in mock_openrgb_device.mock_calls + if call[0] in ("set_mode", "set_color") + ] == [ + call.set_mode(OpenRGBMode.DIRECT), + call.set_color(RGBColor(*OFF_COLOR), True), + ] + + # Test error handling @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( From 63f60b544d8686cefc9ab6e62647e91dd345fd73 Mon Sep 17 00:00:00 2001 From: moridew <83013847+moridew@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:26:23 +0900 Subject: [PATCH 17/38] Map SmartThings aIComfort AC mode to HVAC auto (#174513) --- .../components/smartthings/climate.py | 15 ++ tests/components/smartthings/test_climate.py | 195 ++++++++++++++++++ 2 files changed, 210 insertions(+) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 13130a9858c42..63e57b50a8f64 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -64,6 +64,7 @@ AC_MODE_TO_STATE = { "auto": HVACMode.AUTO, + "aIComfort": HVACMode.AUTO, "cool": HVACMode.COOL, "dry": HVACMode.DRY, "coolClean": HVACMode.COOL, @@ -453,6 +454,20 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: tasks.append(self.async_turn_on()) mode = STATE_TO_AC_MODE[hvac_mode] + + # If new hvac_mode is HVACMode.AUTO and + # AirConditioner doesn't support "auto" + # but supports "aIComfort", change mode to "aIComfort" + if hvac_mode == HVACMode.AUTO: + supported_modes = ( + self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ) + or [] + ) + if "auto" not in supported_modes and "aIComfort" in supported_modes: + mode = "aIComfort" + # If new hvac_mode is HVAC_MODE_FAN_ONLY and # AirConditioner supports "wind" or "fan" mode, # the AirConditioner new mode has to be "wind" or "fan" diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 208345390a434..be68cc0da838e 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -206,6 +206,201 @@ async def test_ac_set_hvac_mode_turns_on( ] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_auto_uses_aicomfort( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode auto uses aIComfort when auto is not supported.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["aIComfort", "cool", "dry", "heat", "fanOnly"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.theater_ac_office_granit", + ATTR_HVAC_MODE: HVACMode.AUTO, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="aIComfort", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_auto_prefers_auto_when_aicomfort_supported( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode auto uses auto when both auto and aIComfort are supported.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["aIComfort", "auto", "cool", "dry", "heat", "fanOnly"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.theater_ac_office_granit", + ATTR_HVAC_MODE: HVACMode.AUTO, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_auto_turns_on_uses_aicomfort( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode auto turns on and uses aIComfort when auto is not supported.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["aIComfort", "cool", "dry", "heat", "fanOnly"], + ) + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.theater_ac_office_granit", + ATTR_HVAC_MODE: HVACMode.AUTO, + }, + blocking=True, + ) + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="aIComfort", + ), + ] + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode_auto_uses_aicomfort( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC temperature and HVAC mode auto uses aIComfort when auto is not supported.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["aIComfort", "cool", "dry", "heat", "fanOnly"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.theater_ac_office_granit", + ATTR_TEMPERATURE: 23, + ATTR_HVAC_MODE: HVACMode.AUTO, + }, + blocking=True, + ) + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="aIComfort", + ), + ] + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_aicomfort_mode_state( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aIComfort AC mode is reported as auto.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.AIR_CONDITIONER_MODE, + "aIComfort", + ) + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.theater_ac_office_granit").state == HVACMode.AUTO + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_hvac_modes_includes_auto_for_aicomfort( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test hvac_modes includes auto when only aIComfort is supported.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["aIComfort", "cool", "heat"], + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.theater_ac_office_granit") + assert state + assert HVACMode.AUTO in state.attributes[ATTR_HVAC_MODES] + + @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) @pytest.mark.parametrize("mode", ["fan", "wind"]) async def test_ac_set_hvac_mode_fan( From 0ac7efc1559a39f3fe5985ffc71c2c353ba035f4 Mon Sep 17 00:00:00 2001 From: Martin <32802427+mstu01@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:27:06 +0200 Subject: [PATCH 18/38] Fix swallowed exceptions in action handlers for Simplepush (#178012) --- homeassistant/components/simplepush/notify.py | 18 +- .../components/simplepush/strings.json | 8 + tests/components/simplepush/test_notify.py | 199 ++++++++++++++++++ 3 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 tests/components/simplepush/test_notify.py diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index 4c06f170f5725..2c99a99beebe4 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -13,9 +13,10 @@ ) from homeassistant.const import CONF_EVENT, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_ATTACHMENTS, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT +from .const import ATTR_ATTACHMENTS, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -97,8 +98,13 @@ def send_message(self, message: str, **kwargs: Any) -> None: event=event, ) - # pylint: disable-next=home-assistant-action-swallowed-exception - except BadRequest: - _LOGGER.error("Bad request. Title or message are too long") - except UnknownError: - _LOGGER.error("Failed to send the notification") + except BadRequest as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="title_or_message_too_long", + ) from err + except UnknownError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_failed", + ) from err diff --git a/homeassistant/components/simplepush/strings.json b/homeassistant/components/simplepush/strings.json index a0c41ea4b0a61..c52a42ffe8df7 100644 --- a/homeassistant/components/simplepush/strings.json +++ b/homeassistant/components/simplepush/strings.json @@ -17,5 +17,13 @@ } } } + }, + "exceptions": { + "send_message_failed": { + "message": "Failed to send the Simplepush notification." + }, + "title_or_message_too_long": { + "message": "The notification title or message is too long." + } } } diff --git a/tests/components/simplepush/test_notify.py b/tests/components/simplepush/test_notify.py new file mode 100644 index 0000000000000..5d4bdd70e8385 --- /dev/null +++ b/tests/components/simplepush/test_notify.py @@ -0,0 +1,199 @@ +"""Test Simplepush notifications.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from simplepush import BadRequest, UnknownError + +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.simplepush.const import CONF_DEVICE_KEY, CONF_SALT, DOMAIN +from homeassistant.const import CONF_NAME, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +MOCK_CONFIG = { + CONF_DEVICE_KEY: "abc", + CONF_NAME: "simplepush", +} + +SERVICE_NAME = "simplepush" + + +@pytest.fixture +def mock_send() -> Generator[MagicMock]: + """Mock the simplepush send call.""" + with patch("homeassistant.components.simplepush.notify.send") as mock: + yield mock + + +async def setup_config_entry(hass: HomeAssistant, data: dict[str, str]) -> None: + """Set up the simplepush integration.""" + entry = MockConfigEntry(domain=DOMAIN, data=data) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) + + +@pytest.mark.parametrize( + ("service_data", "expected_attachments", "expected_event"), + [ + pytest.param({}, None, None, id="message_only"), + pytest.param({"data": {"event": "event"}}, None, "event", id="event_in_data"), + pytest.param( + {"data": {"attachments": "image.jpg"}}, + None, + None, + id="attachments_not_a_list", + ), + pytest.param( + {"data": {"attachments": [{"image": "image.jpg"}]}}, + ["image.jpg"], + None, + id="image_attachment", + ), + pytest.param( + {"data": {"attachments": [{"video": "video.mp4"}]}}, + ["video.mp4"], + None, + id="video_attachment", + ), + pytest.param( + { + "data": { + "attachments": [{"video": "video.mp4", "thumbnail": "thumb.jpg"}] + } + }, + [{"video": "video.mp4", "thumbnail": "thumb.jpg"}], + None, + id="video_attachment_with_thumbnail", + ), + ], +) +async def test_send_message( + hass: HomeAssistant, + mock_send: MagicMock, + service_data: dict[str, Any], + expected_attachments: list[Any] | None, + expected_event: str | None, +) -> None: + """Test sending a message.""" + await setup_config_entry(hass, MOCK_CONFIG) + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_NAME, + {"message": "Hello", **service_data}, + blocking=True, + ) + + mock_send.assert_called_once_with( + key="abc", + title="Home Assistant", + message="Hello", + attachments=expected_attachments, + event=expected_event, + ) + + +async def test_send_message_with_password( + hass: HomeAssistant, mock_send: MagicMock +) -> None: + """Test sending a message with an encryption password.""" + await setup_config_entry( + hass, {**MOCK_CONFIG, CONF_PASSWORD: "password", CONF_SALT: "salt"} + ) + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_NAME, + {"message": "Hello"}, + blocking=True, + ) + + mock_send.assert_called_once_with( + key="abc", + password="password", + salt="salt", + title="Home Assistant", + message="Hello", + attachments=None, + event=None, + ) + + +async def test_send_message_with_invalid_attachment( + hass: HomeAssistant, mock_send: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: + """Test that an invalid attachment format sends nothing.""" + await setup_config_entry(hass, MOCK_CONFIG) + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_NAME, + {"message": "Hello", "data": {"attachments": [{"file": "image.jpg"}]}}, + blocking=True, + ) + + assert "Attachment format is incorrect" in caplog.text + mock_send.assert_not_called() + + +@pytest.mark.parametrize( + ("side_effect", "expected_exception", "translation_key"), + [ + pytest.param( + BadRequest, + ServiceValidationError, + "title_or_message_too_long", + id="bad_request", + ), + pytest.param( + UnknownError, + HomeAssistantError, + "send_message_failed", + id="unknown_error", + ), + ], +) +async def test_send_message_error( + hass: HomeAssistant, + mock_send: MagicMock, + side_effect: type[Exception], + expected_exception: type[HomeAssistantError], + translation_key: str, +) -> None: + """Test that a failing send raises the correct exception.""" + await setup_config_entry(hass, MOCK_CONFIG) + mock_send.side_effect = side_effect + + with pytest.raises(expected_exception) as exc_info: + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_NAME, + {"message": "Hello"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == translation_key + + +async def test_no_discovery_info( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test setup of the legacy platform without discovery info.""" + assert await async_setup_component( + hass, + NOTIFY_DOMAIN, + {NOTIFY_DOMAIN: {"platform": DOMAIN}}, + ) + await hass.async_block_till_done() + + assert f"Failed to initialize notification service {DOMAIN}" in caplog.text + assert not hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) From 20872432cf6d4605e768ba2ed2e0cc6640852690 Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Fri, 21 Aug 2026 20:28:51 +0100 Subject: [PATCH 19/38] Bump lyngdorf to 1.10.0 (#179739) --- homeassistant/components/lyngdorf/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lyngdorf/manifest.json b/homeassistant/components/lyngdorf/manifest.json index 2f5242de5b430..91b800d6aeb0f 100644 --- a/homeassistant/components/lyngdorf/manifest.json +++ b/homeassistant/components/lyngdorf/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["lyngdorf", "async_upnp_client"], "quality_scale": "silver", - "requirements": ["lyngdorf==1.9.0"], + "requirements": ["lyngdorf==1.10.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", diff --git a/requirements_all.txt b/requirements_all.txt index 49af0f6f9dadc..e3867783f3092 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1537,7 +1537,7 @@ lw12==0.9.2 lxml==6.1.2 # homeassistant.components.lyngdorf -lyngdorf==1.9.0 +lyngdorf==1.10.0 # homeassistant.components.matrix matrix-nio==0.26.0 From ca732cba653560ae50d83621a916a51c488ec0f5 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:29:43 +0200 Subject: [PATCH 20/38] bump pyenphase to 4.0 and add required None handling and tests (#179673) --- .../components/enphase_envoy/manifest.json | 2 +- .../components/enphase_envoy/sensor.py | 86 ++- requirements_all.txt | 2 +- tests/components/enphase_envoy/conftest.py | 18 +- .../fixtures/envoy_metered_batt_relay.json | 2 +- .../envoy_metered_batt_relay_none.json | 639 ++++++++++++++++++ .../fixtures/envoy_tot_cons_metered.json | 2 +- .../snapshots/test_diagnostics.ambr | 1 + .../enphase_envoy/snapshots/test_sensor.ambr | 241 +++++++ tests/components/enphase_envoy/test_sensor.py | 269 +++++++- 10 files changed, 1225 insertions(+), 37 deletions(-) create mode 100644 tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay_none.json diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 015b358ced714..046a8b033511f 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==3.2.1"], + "requirements": ["pyenphase==4.0.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index baf61e4711531..676e8352b76e4 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -21,7 +21,7 @@ EnvoySystemConsumption, EnvoySystemProduction, ) -from pyenphase.const import PHASENAMES +from pyenphase.const import PHASENAMES, SupportedFeatures from pyenphase.models.acb import ACBChargeStatus, ACBSleepState from pyenphase.models.meters import ( CtMeterStatus, @@ -383,7 +383,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): """Describes an Envoy CT sensor entity.""" value_fn: Callable[ - [EnvoyMeterData], + [EnvoyMeterData | None], int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None, ] on_phase: str | None = None @@ -586,7 +586,9 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): translation_key=(translation_key if translation_key != "" else key), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), + value_fn=lambda ct: ( + 0 if ct is None or ct.status_flags is None else len(ct.status_flags) + ), cttype=cttype, ) for cttype, key, translation_key in ( @@ -1020,7 +1022,9 @@ async def async_setup_entry( ) -> None: """Set up envoy sensor platform.""" coordinator = config_entry.runtime_data - envoy_data = coordinator.envoy.data + envoy = coordinator.envoy + assert envoy is not None + envoy_data = envoy.data assert envoy_data is not None _LOGGER.debug("Envoy data: %s", envoy_data) @@ -1028,39 +1032,57 @@ async def async_setup_entry( EnvoyProductionEntity(coordinator, description) for description in PRODUCTION_SENSORS ] - if envoy_data.system_consumption: + # add unconditionally if TOTAL_CONSUMPTION is available to overcome + # None value at startup caused by envoy fw issues + if envoy.supported_features & SupportedFeatures.TOTAL_CONSUMPTION: entities.extend( EnvoyConsumptionEntity(coordinator, description) for description in CONSUMPTION_SENSORS ) - if envoy_data.system_net_consumption: + # add unconditionally if NET_CONSUMPTION is available to overcome + # None value at startup caused by envoy fw issues + if envoy.supported_features & SupportedFeatures.NET_CONSUMPTION: entities.extend( EnvoyNetConsumptionEntity(coordinator, description) for description in NET_CONSUMPTION_SENSORS ) # For each production phase reported add production entities - if envoy_data.system_production_phases: + # if PRODUCTION is available and phases detected even if None + # to overcome None value at startup caused by envoy fw issues + if envoy.active_phase_count and ( + envoy.supported_features & SupportedFeatures.PRODUCTION + ): entities.extend( EnvoyProductionPhaseEntity(coordinator, description) - for use_phase, phase in envoy_data.system_production_phases.items() + for index, use_phase in enumerate(PHASENAMES) for description in PRODUCTION_PHASE_SENSORS[use_phase] - if phase is not None + if index < (envoy.phase_count if envoy.phase_count > 1 else 0) ) # For each consumption phase reported add consumption entities - if envoy_data.system_consumption_phases: + # if TOTAL_CONSUMPTION is available and phases detected even if None + # to overcome None value at startup caused by envoy fw issues + if ( + envoy.active_phase_count + and envoy.phase_count > 1 + and (envoy.supported_features & SupportedFeatures.TOTAL_CONSUMPTION) + ): entities.extend( EnvoyConsumptionPhaseEntity(coordinator, description) - for use_phase, phase in envoy_data.system_consumption_phases.items() + for index, use_phase in enumerate(PHASENAMES) for description in CONSUMPTION_PHASE_SENSORS[use_phase] - if phase is not None + if index < (envoy.phase_count if envoy.phase_count > 1 else 0) ) # For each net_consumption phase reported add consumption entities - if envoy_data.system_net_consumption_phases: + # if NET_CONSUMPTION is available and phases detected even if None + # to overcome None value at startup caused by envoy fw issues + if envoy.active_phase_count and ( + envoy.supported_features & SupportedFeatures.NET_CONSUMPTION + ): entities.extend( EnvoyNetConsumptionPhaseEntity(coordinator, description) - for use_phase, phase in envoy_data.system_net_consumption_phases.items() + for index, use_phase in enumerate(PHASENAMES) for description in NET_CONSUMPTION_PHASE_SENSORS[use_phase] - if phase is not None + if index < (envoy.phase_count if envoy.phase_count > 1 else 0) ) # Add Current Transformer entities if envoy_data.ctmeters: @@ -1181,8 +1203,8 @@ class EnvoyProductionEntity(EnvoySystemSensorEntity): @override def native_value(self) -> int | None: """Return the state of the sensor.""" - system_production = self.data.system_production - assert system_production is not None + if (system_production := self.data.system_production) is None: + return None return self.entity_description.value_fn(system_production) @@ -1195,8 +1217,8 @@ class EnvoyConsumptionEntity(EnvoySystemSensorEntity): @override def native_value(self) -> int | None: """Return the state of the sensor.""" - system_consumption = self.data.system_consumption - assert system_consumption is not None + if (system_consumption := self.data.system_consumption) is None: + return None return self.entity_description.value_fn(system_consumption) @@ -1209,8 +1231,8 @@ class EnvoyNetConsumptionEntity(EnvoySystemSensorEntity): @override def native_value(self) -> int | None: """Return the state of the sensor.""" - system_net_consumption = self.data.system_net_consumption - assert system_net_consumption is not None + if (system_net_consumption := self.data.system_net_consumption) is None: + return None return self.entity_description.value_fn(system_net_consumption) @@ -1225,8 +1247,11 @@ def native_value(self) -> int | None: """Return the state of the sensor.""" if TYPE_CHECKING: assert self.entity_description.on_phase - assert self.data.system_production_phases + if self.data.system_production_phases is None: + return None + if self.entity_description.on_phase not in self.data.system_production_phases: + return None if ( system_production := self.data.system_production_phases[ self.entity_description.on_phase @@ -1247,8 +1272,11 @@ def native_value(self) -> int | None: """Return the state of the sensor.""" if TYPE_CHECKING: assert self.entity_description.on_phase - assert self.data.system_consumption_phases + if self.data.system_consumption_phases is None: + return None + if self.entity_description.on_phase not in self.data.system_consumption_phases: + return None if ( system_consumption := self.data.system_consumption_phases[ self.entity_description.on_phase @@ -1269,8 +1297,14 @@ def native_value(self) -> int | None: """Return the state of the sensor.""" if TYPE_CHECKING: assert self.entity_description.on_phase - assert self.data.system_net_consumption_phases + if self.data.system_net_consumption_phases is None: + return None + if ( + self.entity_description.on_phase + not in self.data.system_net_consumption_phases + ): + return None if ( system_net_consumption := self.data.system_net_consumption_phases[ self.entity_description.on_phase @@ -1293,6 +1327,8 @@ def native_value( """Return the state of the CT sensor.""" if (cttype := self.entity_description.cttype) not in self.data.ctmeters: return None + if self.data.ctmeters[cttype] is None: + return None return self.entity_description.value_fn(self.data.ctmeters[cttype]) @@ -1315,6 +1351,8 @@ def native_value( cttype ]: return None + if self.data.ctmeters_phases[cttype][phase] is None: + return None return self.entity_description.value_fn( self.data.ctmeters_phases[cttype][phase] ) diff --git a/requirements_all.txt b/requirements_all.txt index e3867783f3092..16389b7216b40 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2177,7 +2177,7 @@ pyegps==0.2.5 pyemoncms==0.1.3 # homeassistant.components.enphase_envoy -pyenphase==3.2.1 +pyenphase==4.0.0 # homeassistant.components.envertech_evt800 pyenvertechevt800==0.2.4 diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index dde581c43f946..30b7694a8a2f8 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -206,8 +206,8 @@ def _load_json_2_production_data( if item := json_fixture["data"].get("system_consumption_phases"): mocked_data.system_consumption_phases = {} for sub_item, item_data in item.items(): - mocked_data.system_consumption_phases[sub_item] = EnvoySystemConsumption( - **item_data + mocked_data.system_consumption_phases[sub_item] = ( + None if not item_data else EnvoySystemConsumption(**item_data) ) if item := json_fixture["data"].get("system_net_consumption_phases"): mocked_data.system_net_consumption_phases = {} @@ -218,8 +218,8 @@ def _load_json_2_production_data( if item := json_fixture["data"].get("system_production_phases"): mocked_data.system_production_phases = {} for sub_item, item_data in item.items(): - mocked_data.system_production_phases[sub_item] = EnvoySystemProduction( - **item_data + mocked_data.system_production_phases[sub_item] = ( + None if not item_data else EnvoySystemProduction(**item_data) ) if item := json_fixture["data"].get("acb_power"): mocked_data.acb_power = EnvoyACBPower(**item) @@ -232,15 +232,19 @@ def _load_json_2_meter_data( if meters := json_fixture["data"].get("ctmeters"): mocked_data.ctmeters = {} [ - mocked_data.ctmeters.update({meter: EnvoyMeterData(**meter_data)}) + mocked_data.ctmeters.update( + {meter: None if not meter_data else EnvoyMeterData(**meter_data)} + ) for meter, meter_data in meters.items() ] if meters := json_fixture["data"].get("ctmeters_phases"): mocked_data.ctmeters_phases = {} for meter, meter_data in meters.items(): - meter_phase_data: dict[str, EnvoyMeterData] = {} + meter_phase_data: dict[str, EnvoyMeterData | None] = {} [ - meter_phase_data.update({phase: EnvoyMeterData(**phase_data)}) + meter_phase_data.update( + {phase: None if not phase_data else EnvoyMeterData(**phase_data)} + ) for phase, phase_data in meter_data.items() ] mocked_data.ctmeters_phases.update({meter: meter_phase_data}) diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index 32cde3bf04b01..748ebfec099b5 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -3,7 +3,7 @@ "firmware": "7.1.2", "part_number": "123456789", "envoy_model": "Envoy, phases: 3, phase mode: split, net-consumption CT, production CT, storage CT", - "supported_features": 1659, + "supported_features": 1663, "phase_mode": "three", "phase_count": 3, "active_phase_count": 3, diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay_none.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay_none.json new file mode 100644 index 0000000000000..c8e994b7987d1 --- /dev/null +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay_none.json @@ -0,0 +1,639 @@ +{ + "serial_number": "1234", + "firmware": "7.1.2", + "part_number": "123456789", + "envoy_model": "Envoy, phases: 3, phase mode: split, net-consumption CT, production CT, storage CT", + "supported_features": 1663, + "phase_mode": "three", + "phase_count": 3, + "active_phase_count": 3, + "ct_meter_count": 2, + "consumption_meter_type": "net-consumption", + "production_meter_type": "production", + "storage_meter_type": "storage", + "data": { + "encharge_inventory": { + "123456": { + "admin_state": 6, + "admin_state_str": "ENCHG_STATE_READY", + "bmu_firmware_version": "2.1.34", + "comm_level_2_4_ghz": 4, + "comm_level_sub_ghz": 4, + "communicating": true, + "dc_switch_off": false, + "encharge_capacity": 3500, + "encharge_revision": 2, + "firmware_loaded_date": 1695330323, + "firmware_version": "2.6.5973_rel/22.11", + "installed_date": 1695330323, + "last_report_date": 1695769447, + "led_status": 17, + "max_cell_temp": 30, + "operating": true, + "part_number": "830-01760-r37", + "percent_full": 15, + "serial_number": "123456", + "temperature": 29, + "temperature_unit": "C", + "zigbee_dongle_fw_version": "100F" + } + }, + "encharge_power": { + "123456": { + "apparent_power_mva": 0, + "real_power_mw": 0, + "soc": 15 + } + }, + "encharge_aggregate": { + "available_energy": 525, + "backup_reserve": 526, + "state_of_charge": 15, + "reserve_state_of_charge": 15, + "configured_reserve_state_of_charge": 15, + "max_available_capacity": 3500 + }, + "enpower": { + "grid_mode": "multimode-ongrid", + "admin_state": 24, + "admin_state_str": "ENPWR_STATE_OPER_CLOSED", + "comm_level_2_4_ghz": 5, + "comm_level_sub_ghz": 5, + "communicating": true, + "firmware_loaded_date": 1695330323, + "firmware_version": "1.2.2064_release/20.34", + "installed_date": 1695330323, + "last_report_date": 1695769447, + "mains_admin_state": "closed", + "mains_oper_state": "closed", + "operating": true, + "part_number": "830-01760-r37", + "serial_number": "654321", + "temperature": 79, + "temperature_unit": "F", + "zigbee_dongle_fw_version": "1009" + }, + "system_consumption": null, + "system_net_consumption": { + "watt_hours_lifetime": 4321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 2341 + }, + "system_production": null, + "system_consumption_phases": { + "L1": null, + "L2": null, + "L3": null + }, + "system_net_consumption_phases": { + "L1": { + "watt_hours_lifetime": 1321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 12341 + }, + "L2": { + "watt_hours_lifetime": 2321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 22341 + }, + "L3": { + "watt_hours_lifetime": 3321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 32341 + } + }, + "system_production_phases": { + "L1": null, + "L2": null, + "L3": null + }, + "ctmeters": { + "production": { + "eid": "100000010", + "timestamp": 1708006110, + "energy_delivered": 11234, + "energy_received": 12345, + "active_power": 100, + "power_factor": 0.11, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance", "power-on-unused-phase"] + }, + "net-consumption": { + "eid": "100000020", + "timestamp": 1708006120, + "energy_delivered": 21234, + "energy_received": 22345, + "active_power": 101, + "power_factor": 0.21, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "storage": null, + "backfeed": null, + "load": { + "eid": "100000050", + "timestamp": 1708006120, + "energy_delivered": 51234, + "energy_received": 52345, + "active_power": 105, + "power_factor": 0.25, + "voltage": 115, + "current": 0.6, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "load", + "metering_status": "normal", + "status_flags": [] + }, + "evse": { + "eid": "100000060", + "timestamp": 1708006120, + "energy_delivered": 61234, + "energy_received": 62345, + "active_power": 106, + "power_factor": 0.26, + "voltage": 116, + "current": 0.7, + "frequency": 50.7, + "state": "enabled", + "measurement_type": "evse", + "metering_status": "normal", + "status_flags": [] + }, + "pv3p": { + "eid": "100000070", + "timestamp": 1708006120, + "energy_delivered": 71234, + "energy_received": 72345, + "active_power": 107, + "power_factor": 0.27, + "voltage": 117, + "current": 0.8, + "frequency": 50.8, + "state": "enabled", + "measurement_type": "pv3p", + "metering_status": "normal", + "status_flags": [] + } + }, + "ctmeters_phases": { + "production": { + "L1": { + "eid": "100000011", + "timestamp": 1708006111, + "energy_delivered": 112341, + "energy_received": 123451, + "active_power": 20, + "power_factor": 0.12, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance"] + }, + "L2": { + "eid": "100000012", + "timestamp": 1708006112, + "energy_delivered": 112342, + "energy_received": 123452, + "active_power": 30, + "power_factor": 0.13, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["power-on-unused-phase"] + }, + "L3": { + "eid": "100000013", + "timestamp": 1708006113, + "energy_delivered": 112343, + "energy_received": 123453, + "active_power": 50, + "power_factor": 0.14, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": [] + } + }, + "net-consumption": { + "L1": { + "eid": "100000021", + "timestamp": 1708006121, + "energy_delivered": 212341, + "energy_received": 223451, + "active_power": 21, + "power_factor": 0.22, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000022", + "timestamp": 1708006122, + "energy_delivered": 212342, + "energy_received": 223452, + "active_power": 31, + "power_factor": 0.23, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000023", + "timestamp": 1708006123, + "energy_delivered": 212343, + "energy_received": 223453, + "active_power": 51, + "power_factor": 0.24, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + } + }, + "storage": { + "L1": null, + "L2": { + "eid": "100000032", + "timestamp": 1708006122, + "energy_delivered": 312342, + "energy_received": 323452, + "active_power": 33, + "power_factor": 0.23, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "storage", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000033", + "timestamp": 1708006123, + "energy_delivered": 312343, + "energy_received": 323453, + "active_power": 53, + "power_factor": 0.24, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "storage", + "metering_status": "normal", + "status_flags": [] + } + }, + "backfeed": { + "L1": null, + "L2": null, + "L3": null + }, + "load": { + "L1": { + "eid": "100000051", + "timestamp": 1708006121, + "energy_delivered": 512341, + "energy_received": 523451, + "active_power": 115, + "power_factor": 0.25, + "voltage": 115, + "current": 5.1, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "load", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000052", + "timestamp": 1708006122, + "energy_delivered": 512342, + "energy_received": 523452, + "active_power": 125, + "power_factor": 0.25, + "voltage": 115, + "current": 5.2, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "load", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000052", + "timestamp": 1708006123, + "energy_delivered": 512343, + "energy_received": 523453, + "active_power": 135, + "power_factor": 0.25, + "voltage": 115, + "current": 5.3, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "load", + "metering_status": "normal", + "status_flags": [] + } + }, + "evse": { + "L1": { + "eid": "100000061", + "timestamp": 1708006121, + "energy_delivered": 612341, + "energy_received": 623451, + "active_power": 116, + "power_factor": 0.26, + "voltage": 116, + "current": 6.1, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "evse", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000062", + "timestamp": 1708006122, + "energy_delivered": 612342, + "energy_received": 623452, + "active_power": 126, + "power_factor": 0.26, + "voltage": 116, + "current": 6.2, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "evse", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000063", + "timestamp": 1708006123, + "energy_delivered": 612343, + "energy_received": 623453, + "active_power": 136, + "power_factor": 0.26, + "voltage": 116, + "current": 6.3, + "frequency": 50.6, + "state": "enabled", + "measurement_type": "evse", + "metering_status": "normal", + "status_flags": [] + } + }, + "pv3p": { + "L1": { + "eid": "100000071", + "timestamp": 1708006127, + "energy_delivered": 712341, + "energy_received": 723451, + "active_power": 117, + "power_factor": 0.27, + "voltage": 117, + "current": 7.1, + "frequency": 50.7, + "state": "enabled", + "measurement_type": "pv3p", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000072", + "timestamp": 1708006122, + "energy_delivered": 712342, + "energy_received": 723452, + "active_power": 127, + "power_factor": 0.27, + "voltage": 117, + "current": 7.2, + "frequency": 50.7, + "state": "enabled", + "measurement_type": "pv3p", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000073", + "timestamp": 1708006123, + "energy_delivered": 712343, + "energy_received": 723453, + "active_power": 137, + "power_factor": 0.27, + "voltage": 117, + "current": 7.3, + "frequency": 50.7, + "state": "enabled", + "measurement_type": "pv3p", + "metering_status": "normal", + "status_flags": [] + } + } + }, + "dry_contact_status": { + "NC1": { + "id": "NC1", + "status": "open" + }, + "NC2": { + "id": "NC2", + "status": "closed" + }, + "NC3": { + "id": "NC3", + "status": "open" + } + }, + "dry_contact_settings": { + "NC1": { + "id": "NC1", + "black_start": 5.0, + "essential_end_time": 32400.0, + "essential_start_time": 57600.0, + "generator_action": "shed", + "grid_action": "shed", + "load_name": "NC1 Fixture", + "manual_override": true, + "micro_grid_action": "shed", + "mode": "manual", + "override": true, + "priority": 1.0, + "pv_serial_nb": [], + "soc_high": 70.0, + "soc_low": 25.0, + "type": "LOAD" + }, + "NC2": { + "id": "NC2", + "black_start": 5.0, + "essential_end_time": 57600.0, + "essential_start_time": 32400.0, + "generator_action": "shed", + "grid_action": "apply", + "load_name": "NC2 Fixture", + "manual_override": true, + "micro_grid_action": "shed", + "mode": "manual", + "override": true, + "priority": 2.0, + "pv_serial_nb": [], + "soc_high": 70.0, + "soc_low": 30.0, + "type": "LOAD" + }, + "NC3": { + "id": "NC3", + "black_start": 5.0, + "essential_end_time": 57600.0, + "essential_start_time": 32400.0, + "generator_action": "apply", + "grid_action": "shed", + "load_name": "NC3 Fixture", + "manual_override": true, + "micro_grid_action": "apply", + "mode": "manual", + "override": true, + "priority": 3.0, + "pv_serial_nb": [], + "soc_high": 70.0, + "soc_low": 30.0, + "type": "NONE" + } + }, + "collar": { + "admin_state": 88, + "admin_state_str": "ENCMN_MDE_ON_GRID", + "firmware_loaded_date": 1752939759, + "firmware_version": "3.0.6-D0", + "installed_date": 1752939759, + "last_report_date": 1752939759, + "communicating": true, + "mid_state": "close", + "grid_state": "on_grid", + "part_number": "865-00400-r22", + "serial_number": "482520020939", + "temperature": 42, + "temperature_unit": "C", + "control_error": 0, + "collar_state": "Installed" + }, + "c6cc": { + "admin_state": 82, + "admin_state_str": "ENCMN_C6_CC_READY", + "firmware_loaded_date": 1752945451, + "firmware_version": "0.1.20-D1", + "installed_date": 1752945451, + "last_report_date": 1752945451, + "communicating": true, + "part_number": "800-02403-r08", + "serial_number": "482523040549", + "dmir_version": "0.1.20-D1" + }, + "inverters": { + "1": { + "serial_number": "1", + "last_report_date": 1, + "last_report_watts": 1, + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null + } + }, + "tariff": { + "currency": { + "code": "EUR" + }, + "logger": "mylogger", + "date": "1695744220", + "storage_settings": { + "mode": "self-consumption", + "operation_mode_sub_type": "", + "reserved_soc": 15.0, + "very_low_soc": 5, + "charge_from_grid": true, + "date": "1695598084", + "opt_schedules": true + }, + "single_rate": { + "rate": 0.0, + "sell": 0.0 + }, + "seasons": [ + { + "id": "season_1", + "start": "1/1", + "days": [ + { + "id": "all_days", + "days": "Mon,Tue,Wed,Thu,Fri,Sat,Sun", + "must_charge_start": 444, + "must_charge_duration": 35, + "must_charge_mode": "CG", + "enable_discharge_to_grid": true, + "periods": [ + { + "id": "period_1", + "start": 480, + "rate": 0.1898 + }, + { + "id": "filler", + "start": 1320, + "rate": 0.1034 + } + ] + } + ], + "tiers": [] + } + ], + "seasons_sell": [] + }, + "raw": { + "varies_by": "firmware_version" + } + } +} diff --git a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json index 0d0d1957c1933..125f789326c4c 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json @@ -3,7 +3,7 @@ "firmware": "7.6.175", "part_number": "123456789", "envoy_model": "Envoy, phases: 1, phase mode: three, total-consumption CT, production CT", - "supported_features": 1217, + "supported_features": 1231, "phase_mode": "three", "phase_count": 1, "active_phase_count": 0, diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index dee465efea12c..23f7fb3655bde 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -19923,6 +19923,7 @@ 'supported_features': list([ 'INVERTERS', 'METERING', + 'TOTAL_CONSUMPTION', 'NET_CONSUMPTION', 'ENCHARGE', 'ENPOWER', diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index 355c82ae6ad2e..1adb7ff38b1ad 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -43540,6 +43540,67 @@ 'state': '2.341', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_current_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current power consumption', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current power consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_power_consumption', + 'unique_id': '1234_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_current_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Envoy 1234 Current power consumption', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_current_power_production-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -43601,6 +43662,125 @@ 'state': '1.234', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_consumption_last_seven_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.envoy_1234_energy_consumption_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy consumption last seven days', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumption last seven days', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'seven_days_consumption', + 'unique_id': '1234_seven_days_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_consumption_last_seven_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Envoy 1234 Energy consumption last seven days', + : , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_energy_consumption_last_seven_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_energy_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy consumption today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumption today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'daily_consumption', + 'unique_id': '1234_daily_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Envoy 1234 Energy consumption today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_energy_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_production_last_seven_days-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -43897,6 +44077,67 @@ 'state': '4.321', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_lifetime_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lifetime energy consumption', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_consumption', + 'unique_id': '1234_lifetime_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_lifetime_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Envoy 1234 Lifetime energy consumption', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_lifetime_energy_production-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/enphase_envoy/test_sensor.py b/tests/components/enphase_envoy/test_sensor.py index 6d7da94560b04..af0fdfdd75787 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -6,13 +6,14 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +from pyenphase import EnvoyData from pyenphase.const import PHASENAMES, PhaseNames from pyenphase.models.acb import ACBChargeStatus, EnvoyACB from pyenphase.models.meters import CtType import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.enphase_envoy.const import Platform +from homeassistant.components.enphase_envoy.const import DOMAIN, Platform from homeassistant.components.enphase_envoy.coordinator import SCAN_INTERVAL from homeassistant.components.enphase_envoy.sensor import aggregate_acb_sleep_state from homeassistant.components.sensor import SensorStateClass @@ -23,8 +24,14 @@ from homeassistant.util.unit_conversion import TemperatureConverter from . import setup_integration +from .conftest import _load_json_2_meter_data, _load_json_2_production_data -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) @pytest.mark.parametrize( @@ -1413,6 +1420,264 @@ async def test_sensor_missing_data( assert entity_state.state == STATE_UNKNOWN +def reference_fixture(fixture: str) -> EnvoyData: + """Load reference fixture in envoy data model.""" + reference_data = EnvoyData() + json_fixture: dict[str, Any] = load_json_object_fixture(f"{fixture}.json", DOMAIN) + _load_json_2_production_data(reference_data, json_fixture) + _load_json_2_meter_data(reference_data, json_fixture) + return reference_data + + +@pytest.mark.parametrize( + ("mock_envoy", "ref_fixture"), + [ + ( + "envoy_metered_batt_relay_none", + "envoy_metered_batt_relay", + ) + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_load_none_data( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + ref_fixture: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test enphase_envoy sensor platform load None data handling.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + ENTITY_BASE = f"{Platform.SENSOR}.envoy_{mock_envoy.serial_number}" + + # these have None data and should show up as unknown + for entity in ( + "lifetime_energy_production", + "lifetime_energy_consumption", + "current_battery_discharge", + "backfeed_ct_energy_delivered", + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "backfeed_ct_energy_delivered_l1", + "current_battery_discharge_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + assert entity_state.state == STATE_UNKNOWN + + # restore None data to operational state + + reference_data = reference_fixture(ref_fixture) + mock_envoy.data.system_production = reference_data.system_production + mock_envoy.data.system_consumption = reference_data.system_consumption + mock_envoy.data.ctmeters[CtType.BACKFEED] = reference_data.ctmeters[CtType.BACKFEED] + mock_envoy.data.ctmeters[CtType.STORAGE] = reference_data.ctmeters[CtType.STORAGE] + + mock_envoy.data.system_production_phases = reference_data.system_production_phases + mock_envoy.data.system_consumption_phases = reference_data.system_consumption_phases + mock_envoy.data.ctmeters_phases[CtType.BACKFEED] = reference_data.ctmeters_phases[ + CtType.BACKFEED + ] + mock_envoy.data.ctmeters_phases[CtType.STORAGE][PhaseNames.PHASE_1] = ( + reference_data.ctmeters_phases[CtType.STORAGE][PhaseNames.PHASE_1] + ) + + # force HA to detect changed data by changing raw + mock_envoy.data.raw = {"I": "am changed"} + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # all these should now no longer be in unknown state + for entity in ( + "lifetime_energy_production", + "lifetime_energy_consumption", + "current_battery_discharge", + "backfeed_ct_energy_delivered", + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "backfeed_ct_energy_delivered_l1", + "current_battery_discharge_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + assert entity_state.state != STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_metered_batt_relay", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_none_data( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test enphase_envoy sensor platform None data handling.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + ENTITY_BASE = f"{Platform.SENSOR}.envoy_{mock_envoy.serial_number}" + + for entity in ( + "lifetime_energy_production", + "lifetime_energy_consumption", + "lifetime_balanced_net_energy_consumption", + "backfeed_ct_energy_delivered", + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "lifetime_balanced_net_energy_consumption_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + + # force None data to test 'if == none' code sections + mock_envoy.data.system_production = None + mock_envoy.data.system_consumption = None + mock_envoy.data.system_net_consumption = None + mock_envoy.data.ctmeters[CtType.BACKFEED] = None + + mock_envoy.data.system_production_phases = None + mock_envoy.data.system_consumption_phases = None + mock_envoy.data.system_net_consumption_phases = None + + # force HA to detect changed data by changing raw + mock_envoy.data.raw = {"I": "am changed"} + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # all these should now be in unknown state + for entity in ( + "lifetime_energy_production", + "lifetime_energy_consumption", + "lifetime_balanced_net_energy_consumption", + "backfeed_ct_energy_delivered", + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "lifetime_balanced_net_energy_consumption_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + assert entity_state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_metered_batt_relay", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_phase_values_none_data( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test enphase_envoy sensor platform phase None data handling.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + ENTITY_BASE = f"{Platform.SENSOR}.envoy_{mock_envoy.serial_number}" + + for entity in ( + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "lifetime_balanced_net_energy_consumption_l1", + "backfeed_ct_energy_delivered_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + + # force None data to test 'if == none' code sections + mock_envoy.data.system_production_phases[PhaseNames.PHASE_1] = None + mock_envoy.data.system_consumption_phases[PhaseNames.PHASE_1] = None + mock_envoy.data.system_net_consumption_phases[PhaseNames.PHASE_1] = None + mock_envoy.data.ctmeters_phases[CtType.BACKFEED][PhaseNames.PHASE_1] = None + + # force HA to detect changed data by changing raw + mock_envoy.data.raw = {"I": "am changed"} + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # all these should now be in unknown state + for entity in ( + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "lifetime_balanced_net_energy_consumption_l1", + "backfeed_ct_energy_delivered_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + assert entity_state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_metered_batt_relay", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_phase_values_missing_data( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test enphase_envoy sensor platform missing phase data handling.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + ENTITY_BASE = f"{Platform.SENSOR}.envoy_{mock_envoy.serial_number}" + + for entity in ( + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "lifetime_balanced_net_energy_consumption_l1", + "backfeed_ct_energy_delivered_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + + # test handling of missing phase data + del mock_envoy.data.system_production_phases[PhaseNames.PHASE_1] + del mock_envoy.data.system_consumption_phases[PhaseNames.PHASE_1] + del mock_envoy.data.system_net_consumption_phases[PhaseNames.PHASE_1] + del mock_envoy.data.ctmeters_phases[CtType.BACKFEED][PhaseNames.PHASE_1] + + # force HA to detect changed data by changing raw + mock_envoy.data.raw = {"I": "am changed"} + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # all these should now be in unknown state + for entity in ( + "lifetime_energy_production_l1", + "lifetime_energy_consumption_l1", + "lifetime_balanced_net_energy_consumption_l1", + "backfeed_ct_energy_delivered_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + assert entity_state.state == STATE_UNKNOWN + + @pytest.mark.parametrize( ("mock_envoy"), [ From 90fceb873a9704f7cbd60aa73a8b0d30349d7e3c Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:50:32 -0700 Subject: [PATCH 21/38] Fix missing Z-Wave power monitoring sensors in isy994 integration (#171908) Signed-off-by: Matt Jones <47545907+SoundMatt@users.noreply.github.com> --- homeassistant/components/isy994/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index ebd9a38e13474..764f50988e037 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -362,7 +362,7 @@ def _categorize_nodes( isy_data.nodes[ISY_GROUP_PLATFORM].append(node) continue - if node.protocol == PROTO_INSTEON: + if node.protocol in (PROTO_INSTEON, PROTO_ZWAVE): for control in node.aux_properties: if control in SKIP_AUX_PROPS: continue From 770da0afde9e136430c8eaee93702148b111f933 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 21 Aug 2026 21:52:10 +0200 Subject: [PATCH 22/38] Add OCI image labels to Portainer (#176678) --- homeassistant/components/portainer/icons.json | 6 + homeassistant/components/portainer/sensor.py | 39 +++- .../components/portainer/strings.json | 6 + .../portainer/fixtures/containers.json | 8 +- .../portainer/snapshots/test_sensor.ambr | 202 ++++++++++++++++++ 5 files changed, 257 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json index c0907a461658d..e78c26c996c64 100644 --- a/homeassistant/components/portainer/icons.json +++ b/homeassistant/components/portainer/icons.json @@ -60,12 +60,18 @@ "image": { "default": "mdi:docker" }, + "image_created": { + "default": "mdi:calendar-clock" + }, "image_disk_usage_reclaimable": { "default": "mdi:file-restore" }, "image_disk_usage_total_size": { "default": "mdi:harddisk" }, + "image_version": { + "default": "mdi:tag-outline" + }, "images_count": { "default": "mdi:image-multiple" }, diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py index 7062674e86f25..3c8d15f100242 100644 --- a/homeassistant/components/portainer/sensor.py +++ b/homeassistant/components/portainer/sensor.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from itertools import chain from typing import TYPE_CHECKING, override @@ -19,6 +20,7 @@ from homeassistant.const import UnitOfInformation, UnitOfRatio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .coordinator import ( PortainerConfigEntry, @@ -42,7 +44,7 @@ class PortainerContainerSensorEntityDescription(SensorEntityDescription): """Class to hold Portainer container sensor description.""" - value_fn: Callable[[PortainerContainerData], StateType] + value_fn: Callable[[PortainerContainerData], StateType | datetime] supported_fn: Callable[[PortainerContainerData], bool] = lambda _: True @@ -80,6 +82,39 @@ class PortainerVolumeSensorEntityDescription(SensorEntityDescription): translation_key="image", value_fn=lambda data: data.container.image, ), + PortainerContainerSensorEntityDescription( + key="image_version", + translation_key="image_version", + supported_fn=lambda data: bool( + data.container.labels + and data.container.labels.get("org.opencontainers.image.version") + ), + value_fn=lambda data: ( + data.container.labels.get("org.opencontainers.image.version") + if data.container.labels + else None + ), + ), + PortainerContainerSensorEntityDescription( + key="image_created", + translation_key="image_created", + supported_fn=lambda data: bool( + data.container.labels + and data.container.labels.get("org.opencontainers.image.created") + ), + value_fn=lambda data: ( + parsed + if data.container.labels + and ( + created := data.container.labels.get("org.opencontainers.image.created") + ) + and (parsed := dt_util.parse_datetime(created)) is not None + and parsed.tzinfo is not None + else None + ), + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + ), PortainerContainerSensorEntityDescription( key="container_state", translation_key="container_state", @@ -488,7 +523,7 @@ class PortainerContainerSensor(PortainerContainerEntity, SensorEntity): @property @override - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.container_data) diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index 53f7befbc440b..6f748880a8eda 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -142,12 +142,18 @@ "image": { "name": "Image" }, + "image_created": { + "name": "Image created" + }, "image_disk_usage_reclaimable": { "name": "Image disk usage reclaimable" }, "image_disk_usage_total_size": { "name": "Image disk usage total size" }, + "image_version": { + "name": "Image version" + }, "images_count": { "name": "Image count" }, diff --git a/tests/components/portainer/fixtures/containers.json b/tests/components/portainer/fixtures/containers.json index 3728db9fbb043..51c2af256672c 100644 --- a/tests/components/portainer/fixtures/containers.json +++ b/tests/components/portainer/fixtures/containers.json @@ -110,7 +110,9 @@ } ], "Labels": { - "com.docker.compose.project": "webstack" + "com.docker.compose.project": "webstack", + "org.opencontainers.image.version": "1.29.3", + "org.opencontainers.image.created": "2026-05-02T14:31:00Z" }, "State": "running", "Status": "Up 2 days" @@ -130,7 +132,9 @@ } ], "Labels": { - "com.docker.compose.project": "webstack" + "com.docker.compose.project": "webstack", + "org.opencontainers.image.version": "15.14", + "org.opencontainers.image.created": "not-a-timestamp" }, "State": "running", "Status": "Up 1 day" diff --git a/tests/components/portainer/snapshots/test_sensor.ambr b/tests/components/portainer/snapshots/test_sensor.ambr index 8336c03bfcfa8..357a4042e3e05 100644 --- a/tests/components/portainer/snapshots/test_sensor.ambr +++ b/tests/components/portainer/snapshots/test_sensor.ambr @@ -3273,6 +3273,107 @@ 'state': 'docker.io/library/nginx:latest', }) # --- +# name: test_all_entities[sensor.serene_banach_image_created-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.serene_banach_image_created', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image created', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Image created', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_created', + 'unique_id': 'portainer_test_entry_123_serene_banach_image_created', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.serene_banach_image_created-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'serene_banach Image created', + }), + 'context': , + 'entity_id': 'sensor.serene_banach_image_created', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-05-02T14:31:00+00:00', + }) +# --- +# name: test_all_entities[sensor.serene_banach_image_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.serene_banach_image_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image version', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_version', + 'unique_id': 'portainer_test_entry_123_serene_banach_image_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.serene_banach_image_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'serene_banach Image version', + }), + 'context': , + 'entity_id': 'sensor.serene_banach_image_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.29.3', + }) +# --- # name: test_all_entities[sensor.serene_banach_memory_limit-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -3691,6 +3792,107 @@ 'state': 'docker.io/library/postgres:15', }) # --- +# name: test_all_entities[sensor.stoic_turing_image_created-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.stoic_turing_image_created', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image created', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Image created', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_created', + 'unique_id': 'portainer_test_entry_123_stoic_turing_image_created', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.stoic_turing_image_created-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'stoic_turing Image created', + }), + 'context': , + 'entity_id': 'sensor.stoic_turing_image_created', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.stoic_turing_image_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.stoic_turing_image_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image version', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_version', + 'unique_id': 'portainer_test_entry_123_stoic_turing_image_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.stoic_turing_image_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'stoic_turing Image version', + }), + 'context': , + 'entity_id': 'sensor.stoic_turing_image_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.14', + }) +# --- # name: test_all_entities[sensor.stoic_turing_memory_limit-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From 99262963c9d918dcdfeade75fda7e292f7f66fbc Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 21 Aug 2026 21:54:07 +0200 Subject: [PATCH 23/38] Remove just go2rtc sessions of the failing camera (#179534) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/go2rtc/__init__.py | 44 +++-- tests/components/go2rtc/__init__.py | 4 +- tests/components/go2rtc/conftest.py | 57 ++++-- tests/components/go2rtc/test_init.py | 186 +++++++++++++++++++- 4 files changed, 260 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 3c736aa03f1d7..c15fab8f2de2a 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -261,6 +261,14 @@ async def _get_binary(hass: HomeAssistant) -> str | None: return await hass.async_add_executor_job(shutil.which, "go2rtc") +@dataclass(frozen=True) +class _SessionInfo: + """Session info.""" + + ws_client: Go2RtcWsClient + camera: Camera + + class WebRTCProvider(CameraWebRTCProvider): """WebRTC provider.""" @@ -276,7 +284,7 @@ def __init__( self._url = url self._session = session self._rest_client = rest_client - self._sessions: dict[str, Go2RtcWsClient] = {} + self._sessions: dict[str, _SessionInfo] = {} self._supported_schemes: set[str] = set() @property @@ -310,9 +318,13 @@ async def async_handle_async_webrtc_offer( send_message(WebRTCError("go2rtc_webrtc_offer_failed", str(err))) return - self._sessions[session_id] = ws_client = Go2RtcWsClient( + ws_client = Go2RtcWsClient( self._session, self._url, source=get_camera_identifier(camera) ) + self._sessions[session_id] = _SessionInfo( + ws_client=ws_client, + camera=camera, + ) @callback def on_messages(message: ReceiveMessages) -> None: @@ -338,8 +350,8 @@ async def async_on_webrtc_candidate( ) -> None: """Handle the WebRTC candidate.""" - if ws_client := self._sessions.get(session_id): - await ws_client.send(WebRTCCandidate(candidate.candidate)) + if session_info := self._sessions.get(session_id): + await session_info.ws_client.send(WebRTCCandidate(candidate.candidate)) else: _LOGGER.debug("Unknown session %s. Ignoring candidate", session_id) @@ -347,8 +359,8 @@ async def async_on_webrtc_candidate( @override def async_close_session(self, session_id: str) -> None: """Close the session.""" - ws_client = self._sessions.pop(session_id) - self._hass.async_create_task(ws_client.close()) + if session_info := self._sessions.pop(session_id, None): + self._hass.async_create_task(session_info.ws_client.close()) @override async def async_get_image( @@ -366,7 +378,7 @@ async def async_get_image( async def _update_stream_source(self, camera: Camera) -> None: """Update the stream source in go2rtc config if needed.""" if not (stream_source := await camera.stream_source()): - await self.teardown() + await self._close_camera_sessions(camera) raise HomeAssistantError("Camera has no stream source") if camera.platform.platform_name == "generic": @@ -376,7 +388,7 @@ async def _update_stream_source(self, camera: Camera) -> None: stream_source = "ffmpeg:" + stream_source if not self.async_is_supported(stream_source): - await self.teardown() + await self._close_camera_sessions(camera) raise HomeAssistantError("Stream source is not supported by go2rtc") camera_prefs = await get_dynamic_camera_stream_settings( @@ -440,11 +452,20 @@ async def _update_preload_stream(self, camera: Camera) -> None: else: await self._rest_client.preload.disable(identifier) + async def _close_camera_sessions(self, camera: Camera) -> None: + for session_id in list(self._sessions): + session_info = self._sessions.get(session_id) + if session_info is None or session_info.camera != camera: + continue + # Unregister before closing, as closing yields to the event loop + del self._sessions[session_id] + await session_info.ws_client.close() + async def teardown(self) -> None: """Tear down the provider.""" - for ws_client in self._sessions.values(): - await ws_client.close() - self._sessions.clear() + while self._sessions: + _, session_info = self._sessions.popitem() + await session_info.ws_client.close() @override async def async_register_camera( @@ -460,6 +481,7 @@ async def async_unregister_camera( camera: Camera, ) -> None: """Will be called when the provider is unregistered for a camera.""" + await self._close_camera_sessions(camera) identifier = get_camera_identifier(camera) if identifier in await self._rest_client.preload.list(): await self._rest_client.preload.disable(identifier) diff --git a/tests/components/go2rtc/__init__.py b/tests/components/go2rtc/__init__.py index c7c07be1f77ca..1944368b29a7d 100644 --- a/tests/components/go2rtc/__init__.py +++ b/tests/components/go2rtc/__init__.py @@ -6,14 +6,14 @@ class MockCamera(Camera): """Mock Camera Entity.""" - _attr_name = "Test" _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - def __init__(self, unique_id: str | None) -> None: + def __init__(self, unique_id: str | None, name: str = "Test") -> None: """Initialize the mock entity.""" super().__init__() self._stream_source: str | None = "rtsp://stream" self._attr_unique_id = unique_id + self._attr_name = name def set_stream_source(self, stream_source: str | None) -> None: """Set the stream source.""" diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 41d2f03031f1c..c94fa8bee0c4b 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -2,7 +2,8 @@ from collections.abc import Generator from pathlib import Path -from unittest.mock import AsyncMock, Mock, patch +from typing import Any +from unittest.mock import AsyncMock, Mock, create_autospec, patch from awesomeversion import AwesomeVersion from go2rtc_client.rest import ( @@ -11,6 +12,7 @@ _StreamClient, _WebRTCClient, ) +from go2rtc_client.ws import Go2RtcWsClient import pytest from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN @@ -82,6 +84,19 @@ def ws_client() -> Generator[Mock]: yield ws_client_mock.return_value +@pytest.fixture +def ws_clients() -> Generator[list[Mock]]: + """Mock go2rtc websocket clients with a separate mock per created client.""" + clients: list[Mock] = [] + + def create_client(*args: Any, **kwargs: Any) -> Mock: + clients.append(client := create_autospec(Go2RtcWsClient, instance=True)) + return client + + with patch(f"{GO2RTC_PATH}.Go2RtcWsClient", side_effect=create_client): + yield clients + + @pytest.fixture def server_stdout() -> list[str]: """Server stdout lines.""" @@ -198,13 +213,12 @@ def camera_unique_id() -> str | None: return "camera_unique_id" -@pytest.fixture -async def init_test_integration( +async def _setup_test_integration( hass: HomeAssistant, integration_config_entry: ConfigEntry, - camera_unique_id: str | None, -) -> MockCamera: - """Initialize components.""" + cameras: list[MockCamera], +) -> None: + """Set up the test integration with the given cameras.""" async def async_setup_entry_init( hass: HomeAssistant, config_entry: ConfigEntry @@ -232,17 +246,38 @@ async def async_unload_entry_init( async_unload_entry=async_unload_entry_init, ), ) - test_camera = MockCamera(camera_unique_id) - setup_test_component_platform( - hass, CAMERA_DOMAIN, [test_camera], from_config_entry=True - ) + setup_test_component_platform(hass, CAMERA_DOMAIN, cameras, from_config_entry=True) mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock()) with mock_config_flow(TEST_DOMAIN, ConfigFlow): assert await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() - return test_camera + +@pytest.fixture +async def init_test_integration( + hass: HomeAssistant, + integration_config_entry: ConfigEntry, + camera_unique_id: str | None, +) -> MockCamera: + """Initialize components.""" + camera = MockCamera(camera_unique_id) + await _setup_test_integration(hass, integration_config_entry, [camera]) + return camera + + +@pytest.fixture +async def init_test_integration_two_cameras( + hass: HomeAssistant, + integration_config_entry: ConfigEntry, +) -> tuple[MockCamera, MockCamera]: + """Initialize components with two cameras.""" + cameras = ( + MockCamera("camera_unique_id_1"), + MockCamera("camera_unique_id_2", "Test 2"), + ) + await _setup_test_integration(hass, integration_config_entry, list(cameras)) + return cameras @pytest.fixture diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 4a32083214382..6d073c3c28877 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -1,5 +1,6 @@ """The tests for the go2rtc component.""" +import asyncio from collections.abc import Awaitable, Callable import logging from pathlib import Path @@ -194,14 +195,16 @@ async def test(session: str) -> None: receive_message_callback.assert_called_once_with( WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") ) - teardown.assert_called_once() + # Only the sessions of the failing camera are closed, the provider stays up + teardown.assert_not_called() # We use one ws_client mock for all sessions assert ws_client.close.call_count == len(sessions) + assert not provider._sessions await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED - assert teardown.call_count == 2 + teardown.assert_called_once() @pytest.mark.usefixtures( @@ -466,8 +469,7 @@ async def test_close_session( session_id = "session_id" # Session doesn't exist - with pytest.raises(KeyError): - camera.close_webrtc_session(session_id) + camera.close_webrtc_session(session_id) ws_client.close.assert_not_called() # Store session @@ -485,13 +487,183 @@ async def test_close_session( camera.close_webrtc_session(session_id) ws_client.close.assert_called_once() - # Close again should raise an error + # Closing an already closed session is a no-op ws_client.reset_mock() - with pytest.raises(KeyError): - camera.close_webrtc_session(session_id) + camera.close_webrtc_session(session_id) ws_client.close.assert_not_called() +async def _fail_with_offer(hass: HomeAssistant, camera: MockCamera, error: str) -> None: + """Update the stream source via a new WebRTC offer, expecting an error.""" + send_message = Mock(spec_set=WebRTCSendMessage) + await camera.async_handle_async_webrtc_offer(OFFER_SDP, "new_session", send_message) + send_message.assert_called_once_with( + WebRTCError("go2rtc_webrtc_offer_failed", error) + ) + + +async def _fail_with_image_request( + hass: HomeAssistant, camera: MockCamera, error: str +) -> None: + """Update the stream source via a snapshot request, expecting an error.""" + with pytest.raises(HomeAssistantError, match=error): + await async_get_image(hass, camera.entity_id) + + +@pytest.mark.parametrize( + ("stream_source", "error"), + [ + ( + None, + "Camera has no stream source", + ), + ( + "invalid://not_supported", + "Stream source is not supported by go2rtc", + ), + ], + ids=["no_stream_source", "unsupported_stream_source"], +) +@pytest.mark.parametrize( + "trigger", + [ + _fail_with_offer, + _fail_with_image_request, + ], + ids=["offer", "image_request"], +) +@pytest.mark.usefixtures("init_integration") +async def test_invalid_stream_source_closes_only_sessions_of_that_camera( + hass: HomeAssistant, + ws_clients: list[Mock], + init_test_integration_two_cameras: tuple[MockCamera, MockCamera], + caplog: pytest.LogCaptureFixture, + trigger: Callable[[HomeAssistant, MockCamera, str], Awaitable[None]], + stream_source: str | None, + error: str, +) -> None: + """Test an invalid stream source only closes the sessions of that camera.""" + camera_1, camera_2 = init_test_integration_two_cameras + + await camera_1.async_handle_async_webrtc_offer(OFFER_SDP, "session_1", Mock()) + await camera_2.async_handle_async_webrtc_offer(OFFER_SDP, "session_2", Mock()) + ws_client_1, ws_client_2 = ws_clients + ws_client_1.reset_mock() + ws_client_2.reset_mock() + caplog.clear() + + camera_1.set_stream_source(stream_source) + await trigger(hass, camera_1, error) + + ws_client_1.close.assert_called_once() + ws_client_2.close.assert_not_called() + + # The session of camera 1 is gone + await camera_1.async_on_webrtc_candidate( + "session_1", RTCIceCandidateInit("candidate") + ) + assert ( + "homeassistant.components.go2rtc", + logging.DEBUG, + "Unknown session session_1. Ignoring candidate", + ) in caplog.record_tuples + ws_client_1.send.assert_not_called() + + # Closing the already closed session, e.g. by the frontend, is a no-op + camera_1.close_webrtc_session("session_1") + ws_client_1.close.assert_called_once() + + # The session of camera 2 is untouched + await camera_2.async_on_webrtc_candidate( + "session_2", RTCIceCandidateInit("candidate") + ) + ws_client_2.send.assert_called_once_with(WebRTCCandidate("candidate")) + camera_2.close_webrtc_session("session_2") + ws_client_2.close.assert_called_once() + + +@pytest.mark.usefixtures("init_integration") +async def test_unregister_camera_closes_only_sessions_of_that_camera( + ws_clients: list[Mock], + init_test_integration_two_cameras: tuple[MockCamera, MockCamera], +) -> None: + """Test removing a camera closes only the sessions of that camera.""" + camera_1, camera_2 = init_test_integration_two_cameras + + await camera_1.async_handle_async_webrtc_offer(OFFER_SDP, "session_1", Mock()) + await camera_2.async_handle_async_webrtc_offer(OFFER_SDP, "session_2", Mock()) + ws_client_1, ws_client_2 = ws_clients + ws_client_1.reset_mock() + ws_client_2.reset_mock() + + await camera_1.async_remove() + + ws_client_1.close.assert_called_once() + ws_client_2.close.assert_not_called() + + # The session of camera 2 is untouched + await camera_2.async_on_webrtc_candidate( + "session_2", RTCIceCandidateInit("candidate") + ) + ws_client_2.send.assert_called_once_with(WebRTCCandidate("candidate")) + + +@pytest.mark.usefixtures("init_integration") +async def test_teardown_while_a_camera_is_removed( + ws_clients: list[Mock], + init_test_integration_two_cameras: tuple[MockCamera, MockCamera], +) -> None: + """Test tearing down the provider while a camera is removed.""" + camera_1, camera_2 = init_test_integration_two_cameras + + await camera_1.async_handle_async_webrtc_offer(OFFER_SDP, "session_1", Mock()) + await camera_2.async_handle_async_webrtc_offer(OFFER_SDP, "session_2", Mock()) + ws_client_1, ws_client_2 = ws_clients + assert isinstance(camera_1.webrtc_provider, WebRTCProvider) + provider = camera_1.webrtc_provider + + async def yield_control() -> None: + """Let the camera removal run while the teardown is in progress.""" + await asyncio.sleep(0) + + ws_client_1.close.side_effect = yield_control + ws_client_2.close.side_effect = yield_control + + await asyncio.gather(provider.teardown(), camera_2.async_remove()) + + ws_client_1.close.assert_called_once() + ws_client_2.close.assert_called_once() + assert not provider._sessions + + +@pytest.mark.usefixtures("init_integration") +async def test_camera_removed_while_a_snapshot_fails( + hass: HomeAssistant, + ws_clients: list[Mock], + init_test_integration: MockCamera, +) -> None: + """Test a camera being removed while a snapshot closes the same session.""" + camera = init_test_integration + + await camera.async_handle_async_webrtc_offer(OFFER_SDP, "session_1", Mock()) + (ws_client,) = ws_clients + + async def yield_control() -> None: + """Let the camera removal run while the snapshot is still failing.""" + await asyncio.sleep(0) + + ws_client.close.side_effect = yield_control + camera.set_stream_source(None) + + async def failing_snapshot() -> None: + with pytest.raises(HomeAssistantError, match="Camera has no stream source"): + await async_get_image(hass, camera.entity_id) + + await asyncio.gather(failing_snapshot(), camera.async_remove()) + + ws_client.close.assert_called_once() + + ERR_BINARY_NOT_FOUND = "Could not find go2rtc docker binary" ERR_CONNECT = "Could not connect to go2rtc instance" ERR_CONNECT_RETRY = ( From 9c524652d7fe87b96f2b3fda5888a9f0734f280a Mon Sep 17 00:00:00 2001 From: soldier2008 <217476753+soldier2008@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:04:51 -0300 Subject: [PATCH 24/38] Remove system_timezone in local_calendar diagnostics (#178237) --- homeassistant/components/local_calendar/diagnostics.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/local_calendar/diagnostics.py b/homeassistant/components/local_calendar/diagnostics.py index 121da9e659463..0d19acd0e0d94 100644 --- a/homeassistant/components/local_calendar/diagnostics.py +++ b/homeassistant/components/local_calendar/diagnostics.py @@ -1,6 +1,5 @@ """Provides diagnostics for local calendar.""" -import datetime from typing import Any from ical.diagnostics import redact_ics @@ -18,7 +17,7 @@ async def async_get_config_entry_diagnostics( payload: dict[str, Any] = { "now": dt_util.now().isoformat(), "timezone": str(dt_util.get_default_time_zone()), - "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), # pylint: disable=home-assistant-enforce-naive-now + "system_timezone": str(dt_util.naive_now().astimezone().tzinfo), } store = config_entry.runtime_data ics = await store.async_load() From 78134fc5edd77dbaa51e0244b7274f19def43100 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:05:24 +0200 Subject: [PATCH 25/38] Migrate to ha-utils for datetime.now for fyta (#177924) --- homeassistant/components/fyta/coordinator.py | 8 +++----- homeassistant/components/fyta/image.py | 4 ++-- tests/components/fyta/test_image.py | 4 ++++ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index 71cff99c6adfb..bb11e994112e8 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -1,7 +1,7 @@ """Coordinator for FYTA integration.""" from collections.abc import Callable -from datetime import datetime, timedelta +from datetime import timedelta import logging from typing import override @@ -20,6 +20,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import CONF_EXPIRATION, DOMAIN @@ -54,10 +55,7 @@ async def _async_update_data( ) -> dict[int, Plant]: """Fetch data from API endpoint.""" - if ( - self.fyta.expiration is None - or self.fyta.expiration.timestamp() < datetime.now().timestamp() # pylint: disable=home-assistant-enforce-naive-now - ): + if self.fyta.expiration is None or self.fyta.expiration < dt_util.now(): await self.renew_authentication() try: diff --git a/homeassistant/components/fyta/image.py b/homeassistant/components/fyta/image.py index e1fb27e2ef130..f0ed90b3ff92d 100644 --- a/homeassistant/components/fyta/image.py +++ b/homeassistant/components/fyta/image.py @@ -2,7 +2,6 @@ from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime import logging from typing import Final, override @@ -17,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .coordinator import FytaConfigEntry, FytaCoordinator from .entity import FytaPlantEntity @@ -119,5 +119,5 @@ def image_url(self) -> str: if url != self._attr_image_url: self._cached_image = None - self._attr_image_last_updated = datetime.now() # pylint: disable=home-assistant-enforce-naive-now + self._attr_image_last_updated = dt_util.utcnow() return url diff --git a/tests/components/fyta/test_image.py b/tests/components/fyta/test_image.py index 82d2e22374451..8580f156247cb 100644 --- a/tests/components/fyta/test_image.py +++ b/tests/components/fyta/test_image.py @@ -15,6 +15,7 @@ from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from . import setup_platform @@ -148,6 +149,9 @@ async def test_update_image( assert image_entity.image_url == "http://www.plant_picture.com/picture1" assert image_state_1 != image_state_2 + # The state is image_last_updated serialized, so it has to carry a timezone + assert dt_util.parse_datetime(image_state_2.state).tzinfo is not None + async def test_update_user_image_error( freezer: FrozenDateTimeFactory, From 8978003864a20dbf32eeb6671ab3a7ba933d7b4b Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 21 Aug 2026 23:05:48 +0300 Subject: [PATCH 26/38] Bump aiowebostv to 0.9.2 (#179757) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 45c5b3375756d..674a28b9f6b66 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiowebostv"], "quality_scale": "platinum", - "requirements": ["aiowebostv==0.9.1"], + "requirements": ["aiowebostv==0.9.2"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index 16389b7216b40..9d1d03fb71f82 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -474,7 +474,7 @@ aiowatttime==0.1.1 aiowebdav2==0.6.2 # homeassistant.components.webostv -aiowebostv==0.9.1 +aiowebostv==0.9.2 # homeassistant.components.withings aiowithings==3.1.6 From df00965f70aaf4ee8930d443d26586ba08530f6e Mon Sep 17 00:00:00 2001 From: soldier2008 <217476753+soldier2008@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:06:29 -0300 Subject: [PATCH 27/38] Avoid a naive datetime.now() in broadlink (#179181) --- homeassistant/components/broadlink/heartbeat.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/broadlink/heartbeat.py b/homeassistant/components/broadlink/heartbeat.py index 388dbc4f2f67f..b4ad268731db3 100644 --- a/homeassistant/components/broadlink/heartbeat.py +++ b/homeassistant/components/broadlink/heartbeat.py @@ -8,6 +8,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import event +from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -31,7 +32,7 @@ def __init__(self, hass: HomeAssistant) -> None: async def async_setup(self) -> None: """Set up the heartbeat.""" if self._unsubscribe is None: - await self.async_heartbeat(dt.datetime.now()) # pylint: disable=home-assistant-enforce-naive-now + await self.async_heartbeat(dt_util.utcnow()) self._unsubscribe = event.async_track_time_interval( self._hass, self.async_heartbeat, self.HEARTBEAT_INTERVAL ) From 124a081a69e250dcacb79b8a31a677bb32e4436a Mon Sep 17 00:00:00 2001 From: David Wu <133224895+David-Wu1119@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:19:22 +0800 Subject: [PATCH 28/38] Type the caldav warned-calendar cache with HassKey (#176143) --- homeassistant/components/caldav/api.py | 7 ++-- homeassistant/components/caldav/const.py | 7 ++++ tests/components/caldav/test_init.py | 41 +++++++++++++++++++++++- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index b64b7fb8e7340..0f0b5a60e409c 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -1,5 +1,4 @@ """Library for working with CalDAV api.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging @@ -8,7 +7,7 @@ from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import WARNED_CALENDARS _LOGGER = logging.getLogger(__name__) @@ -45,9 +44,7 @@ def _get_calendars() -> tuple[ calendars, needs_warning = await hass.async_add_executor_job(_get_calendars) if needs_warning: - warned_calendars: set[tuple[str, str]] = hass.data.setdefault( - DOMAIN, {} - ).setdefault("warned_calendars", set()) + warned_calendars = hass.data.setdefault(WARNED_CALENDARS, set()) for url, name, comp in needs_warning: # This workaround and warning can be removed when we upgrade to caldav 3.0 if (url, comp) not in warned_calendars: diff --git a/homeassistant/components/caldav/const.py b/homeassistant/components/caldav/const.py index e133bb1b8bc83..5c3b512be4514 100644 --- a/homeassistant/components/caldav/const.py +++ b/homeassistant/components/caldav/const.py @@ -2,5 +2,12 @@ from typing import Final +from homeassistant.util.hass_dict import HassKey + DOMAIN: Final = "caldav" TIMEOUT: Final = 30 + +# Calendars we have already warned about, keyed by (url, component). This is +# deliberately not stored on a config entry: the warning is per CalDAV server +# and must survive reloads, and the same server may back more than one entry. +WARNED_CALENDARS: HassKey[set[tuple[str, str]]] = HassKey(f"{DOMAIN}_warned_calendars") diff --git a/tests/components/caldav/test_init.py b/tests/components/caldav/test_init.py index 543446b146f98..aefe6a73ecf3c 100644 --- a/tests/components/caldav/test_init.py +++ b/tests/components/caldav/test_init.py @@ -1,12 +1,14 @@ """Unit tests for the CalDav integration.""" -from unittest.mock import patch +import logging +from unittest.mock import MagicMock, Mock, patch from caldav.lib.error import AuthorizationError, DAVError import pytest import requests from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -71,3 +73,40 @@ async def test_client_failure( flows = hass.config_entries.flow.async_progress() assert [flow.get("step_id") for flow in flows] == expected_flows + + +@pytest.fixture(name="calendars") +def mock_unsupported_calendar() -> list[Mock]: + """Fixture for a calendar that does not report its supported components.""" + calendar = Mock() + calendar.name = "Example" + calendar.search = MagicMock(return_value=[]) + calendar.get_supported_components = MagicMock(side_effect=KeyError()) + return [calendar] + + +@pytest.mark.parametrize("platforms", [[Platform.CALENDAR]]) +async def test_supported_components_warning_survives_reload( + hass: HomeAssistant, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the unsupported-components warning is not repeated after a reload. + + The de-duplication cache is per CalDAV server rather than per config entry, + so reloading the entry must not warn about the same calendar again. + """ + caplog.set_level(logging.WARNING, logger="homeassistant.components.caldav.api") + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert "does not report supported components" in caplog.text + + caplog.clear() + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert "does not report supported components" not in caplog.text From 64d00b7683e0959941b037a97b7a48f42c253734 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 21 Aug 2026 22:21:09 +0200 Subject: [PATCH 29/38] Fix unused Gardena Bluetooth aqua contour sensor snapshots (#179763) Co-authored-by: Claude --- tests/components/gardena_bluetooth/test_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/gardena_bluetooth/test_sensor.py b/tests/components/gardena_bluetooth/test_sensor.py index 4000a5f798ad3..f35fcb55df50f 100644 --- a/tests/components/gardena_bluetooth/test_sensor.py +++ b/tests/components/gardena_bluetooth/test_sensor.py @@ -114,10 +114,10 @@ async def test_setup( AquaContourWatering.remaining_watering_time.unique_id: ( AquaContourWatering.remaining_watering_time.encode(100) ), - AquaContourWatering.activation_reason.uuid: AquaContourWatering.activation_reason.encode( + AquaContourWatering.activation_reason.unique_id: AquaContourWatering.activation_reason.encode( ActivationReason.SCHEDULE ), - AquaContourWatering.skipped_reason.uuid: AquaContourWatering.skipped_reason.encode( + AquaContourWatering.skipped_reason.unique_id: AquaContourWatering.skipped_reason.encode( SkipReason.RAIN_SENSOR ), }, From 9f55544ab1d98d08048bd7a5c1f475accfb98dfc Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Fri, 21 Aug 2026 13:21:24 -0700 Subject: [PATCH 30/38] Add preferred_bitrate option to TTS audio conversion (#179760) --- homeassistant/components/tts/__init__.py | 25 +++++++++++- tests/components/tts/test_init.py | 48 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 798c5debab6ee..7556369d1ad1e 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -71,6 +71,7 @@ __all__ = [ "ATTR_AUDIO_OUTPUT", + "ATTR_PREFERRED_BITRATE", "ATTR_PREFERRED_FORMAT", "ATTR_PREFERRED_SAMPLE_BYTES", "ATTR_PREFERRED_SAMPLE_CHANNELS", @@ -99,6 +100,7 @@ ATTR_PREFERRED_SAMPLE_RATE = "preferred_sample_rate" ATTR_PREFERRED_SAMPLE_CHANNELS = "preferred_sample_channels" ATTR_PREFERRED_SAMPLE_BYTES = "preferred_sample_bytes" +ATTR_PREFERRED_BITRATE = "preferred_bitrate" ATTR_MEDIA_PLAYER_ENTITY_ID = "media_player_entity_id" ATTR_VOICE = "voice" @@ -108,6 +110,7 @@ ATTR_PREFERRED_SAMPLE_RATE, ATTR_PREFERRED_SAMPLE_CHANNELS, ATTR_PREFERRED_SAMPLE_BYTES, + ATTR_PREFERRED_BITRATE, } CONF_LANG = "language" @@ -317,6 +320,7 @@ async def _async_convert_audio( to_sample_rate: int | None = None, to_sample_channels: int | None = None, to_sample_bytes: int | None = None, + to_bitrate: int | None = None, ) -> AsyncGenerator[bytes]: """Convert audio to a preferred format using ffmpeg.""" ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) @@ -345,8 +349,13 @@ async def _async_convert_audio( if to_sample_channels is not None: command.extend(["-ac", str(to_sample_channels)]) if to_extension == "mp3": - # Max quality for MP3. - command.extend(["-q:a", "0"]) + if to_bitrate is not None: + # Constant bitrate. Some hardware decoders cannot handle the + # variable bitrate that -q:a produces. + command.extend(["-b:a", f"{to_bitrate}k"]) + else: + # Max quality for MP3. + command.extend(["-q:a", "0"]) if to_sample_bytes == 2: # 16-bit samples. command.extend(["-sample_fmt", "s16"]) @@ -588,6 +597,7 @@ def _needs_conversion(self) -> bool: ATTR_PREFERRED_SAMPLE_RATE, ATTR_PREFERRED_SAMPLE_CHANNELS, ATTR_PREFERRED_SAMPLE_BYTES, + ATTR_PREFERRED_BITRATE, ) ) @@ -633,6 +643,7 @@ async def _async_stream_override_result(self) -> AsyncGenerator[bytes]: to_sample_rate=self.options.get(ATTR_PREFERRED_SAMPLE_RATE), to_sample_channels=self.options.get(ATTR_PREFERRED_SAMPLE_CHANNELS), to_sample_bytes=self.options.get(ATTR_PREFERRED_SAMPLE_BYTES), + to_bitrate=self.options.get(ATTR_PREFERRED_BITRATE), ) async for chunk in converted_audio: yield chunk @@ -1082,6 +1093,14 @@ async def _async_generate_tts_audio( if sample_bytes is not None: sample_bytes = int(sample_bytes) + if ATTR_PREFERRED_BITRATE in supported_options: + bitrate = options.get(ATTR_PREFERRED_BITRATE) + else: + bitrate = options.pop(ATTR_PREFERRED_BITRATE, None) + + if bitrate is not None: + bitrate = int(bitrate) + if engine_instance.name is None or engine_instance.name is UNDEFINED: raise HomeAssistantError("TTS engine name is not set.") @@ -1134,6 +1153,7 @@ async def gen_stream() -> AsyncGenerator[str]: or (sample_rate is not None) or (sample_channels is not None) or (sample_bytes is not None) + or (bitrate is not None) ) if needs_conversion: @@ -1145,6 +1165,7 @@ async def gen_stream() -> AsyncGenerator[str]: to_sample_rate=sample_rate, to_sample_channels=sample_channels, to_sample_bytes=sample_bytes, + to_bitrate=bitrate, ) async for chunk in data_gen: diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 9dd312fc3a5e5..f9ae16712372a 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1930,6 +1930,54 @@ async def test_async_convert_audio_probe_size( ] +@pytest.mark.parametrize( + ("to_bitrate", "expected_encoder_args"), + [ + pytest.param(None, ["-q:a", "0"], id="default_vbr"), + pytest.param(48, ["-b:a", "48k"], id="cbr_48k"), + ], +) +async def test_async_convert_audio_mp3_bitrate( + hass: HomeAssistant, + to_bitrate: int | None, + expected_encoder_args: list[str], +) -> None: + """Test that a preferred bitrate produces a constant bitrate MP3.""" + assert await async_setup_component(hass, ffmpeg.DOMAIN, {}) + + mock_process = MagicMock() + mock_process.stdin.drain = AsyncMock() + mock_process.stdout.read = AsyncMock(return_value=b"") + mock_process.wait = AsyncMock(return_value=0) + + with patch( + "asyncio.create_subprocess_exec", return_value=mock_process + ) as mock_create_subprocess_exec: + async for _chunk in tts._async_convert_audio( + hass, + "wav", + _audio_data_gen(), + "mp3", + to_sample_rate=24000, + to_sample_channels=1, + to_bitrate=to_bitrate, + ): + pass + + command = list(mock_create_subprocess_exec.call_args.args) + input_index = command.index("-i") + assert command[input_index + 2 :] == [ + "-f", + "mp3", + "-ar", + "24000", + "-ac", + "1", + *expected_encoder_args, + "pipe:1", + ] + + async def test_default_engine_prefer_entity( hass: HomeAssistant, mock_tts_entity: MockTTSEntity, From c387c4844c3ef015f2b6ec4f18f4d48e16d3996f Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Sat, 22 Aug 2026 06:27:19 +1000 Subject: [PATCH 31/38] Split user flow init and data submission in gdacs config flow tests (#179526) --- tests/components/gdacs/test_config_flow.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/components/gdacs/test_config_flow.py b/tests/components/gdacs/test_config_flow.py index da9b2f7c9bff1..11f09f316e317 100644 --- a/tests/components/gdacs/test_config_flow.py +++ b/tests/components/gdacs/test_config_flow.py @@ -25,12 +25,17 @@ def gdacs_setup_fixture(): async def test_duplicate_error(hass: HomeAssistant, config_entry) -> None: """Test that errors are shown when duplicates are added.""" - conf = {CONF_LATITUDE: -41.2, CONF_LONGITUDE: 174.7, CONF_RADIUS: 25} + hass.config.latitude = -41.2 + hass.config.longitude = 174.7 + conf = {CONF_RADIUS: 25} config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf + DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], conf) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -51,8 +56,11 @@ async def test_step_user(hass: HomeAssistant) -> None: conf = {CONF_RADIUS: 25} result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf + DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], conf) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "-41.2, 174.7" assert result["data"] == { From f670ebbf7a0fc0a105f00eef8fd60c3aa6e6c9b4 Mon Sep 17 00:00:00 2001 From: EnjoyingM <6302356+EnjoyingM@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:28:25 +0200 Subject: [PATCH 32/38] Split user flow init and data submission in wolflink config flow tests (#179228) --- tests/components/wolflink/test_config_flow.py | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/components/wolflink/test_config_flow.py b/tests/components/wolflink/test_config_flow.py index 51c7313f6805d..e95c5c9eba213 100644 --- a/tests/components/wolflink/test_config_flow.py +++ b/tests/components/wolflink/test_config_flow.py @@ -26,6 +26,15 @@ SECOND_DEVICE = Device(5678, 9999, "second-device") +async def _start_user_flow(hass: HomeAssistant) -> dict: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + return result + + async def test_show_form(hass: HomeAssistant) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -37,6 +46,8 @@ async def test_show_form(hass: HomeAssistant) -> None: async def test_create_entry(hass: HomeAssistant) -> None: """Test entry creation only stores credentials, not the device list.""" + result = await _start_user_flow(hass) + with ( patch( "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", @@ -44,8 +55,8 @@ async def test_create_entry(hass: HomeAssistant) -> None: ), patch("homeassistant.components.wolflink.async_setup_entry", return_value=True), ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=INPUT_CONFIG ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -66,12 +77,14 @@ async def test_user_flow_errors( hass: HomeAssistant, side_effect: Exception, expected_error: str ) -> None: """Test error handling in the user step keeps the form open with errors.""" + result = await _start_user_flow(hass) + with patch( "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", side_effect=side_effect, ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=INPUT_CONFIG ) assert result["type"] is FlowResultType.FORM @@ -80,12 +93,14 @@ async def test_user_flow_errors( async def test_no_devices_abort(hass: HomeAssistant) -> None: """Test we abort if the account has no devices.""" + result = await _start_user_flow(hass) + with patch( "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", return_value=[], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=INPUT_CONFIG ) assert result["type"] is FlowResultType.ABORT @@ -98,8 +113,10 @@ async def test_already_configured_aborts( """Test entries with the same username can't be configured twice.""" mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + result = await _start_user_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=INPUT_CONFIG ) assert result["type"] is FlowResultType.ABORT From d75fc219da7efbf518efc4dee15e4fc85f5a275e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:28:47 +0200 Subject: [PATCH 33/38] Split user flow init and data submission in fritzbox config flow tests (#179311) --- tests/components/fritzbox/test_config_flow.py | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/tests/components/fritzbox/test_config_flow.py b/tests/components/fritzbox/test_config_flow.py index 0c8a7996898c5..1f51b4310dafc 100644 --- a/tests/components/fritzbox/test_config_flow.py +++ b/tests/components/fritzbox/test_config_flow.py @@ -90,8 +90,15 @@ async def test_user_auth_failed(hass: HomeAssistant, fritz: Mock) -> None: fritz().login.side_effect = [LoginError("Boom"), mock.DEFAULT] result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA + DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "invalid_auth" @@ -102,7 +109,13 @@ async def test_user_not_successful(hass: HomeAssistant, fritz: Mock) -> None: fritz().login.side_effect = OSError("Boom") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" @@ -110,14 +123,17 @@ async def test_user_not_successful(hass: HomeAssistant, fritz: Mock) -> None: async def test_user_already_configured(hass: HomeAssistant, fritz: Mock) -> None: """Test starting a flow by user when already configured.""" + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert not result["result"].unique_id + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -409,15 +425,13 @@ async def test_ssdp_already_in_progress_host(hass: HomeAssistant, fritz: Mock) - async def test_ssdp_already_configured(hass: HomeAssistant, fritz: Mock) -> None: """Test starting a flow from discovery when already configured.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_DATA - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert not result["result"].unique_id + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + assert not mock_config.unique_id result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA["ip4_valid"] ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" - assert result["result"].unique_id == "only-a-test" + assert mock_config.unique_id == "only-a-test" From 39e110679244b17e4a7368847c95683ae79a271c Mon Sep 17 00:00:00 2001 From: Yardian Support Date: Sat, 22 Aug 2026 04:29:54 +0800 Subject: [PATCH 34/38] Bump pyyardian to 1.4.2 (#179717) Co-authored-by: Paulus Schoutsen --- homeassistant/components/yardian/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yardian/manifest.json b/homeassistant/components/yardian/manifest.json index ce074b44646e3..2ef8f7afb3d2d 100644 --- a/homeassistant/components/yardian/manifest.json +++ b/homeassistant/components/yardian/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/yardian", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pyyardian==1.4.1"] + "requirements": ["pyyardian==1.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9d1d03fb71f82..04a57bc4a5834 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2874,7 +2874,7 @@ pyws66i==1.1 pyxeoma==1.4.2 # homeassistant.components.yardian -pyyardian==1.4.1 +pyyardian==1.4.2 # homeassistant.components.qrcode pyzbar==0.1.9 From a13300c63869d044a0c025dec48d58a9028d6083 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:30:11 -0400 Subject: [PATCH 35/38] USB Websocket API to list serial ports (#179524) Co-authored-by: Paulus Schoutsen --- homeassistant/components/usb/__init__.py | 110 +++- homeassistant/components/usb/consumers.py | 238 +++++++++ homeassistant/components/usb/manifest.json | 1 + homeassistant/components/usb/models.py | 15 + homeassistant/components/usb/utils.py | 2 + tests/components/usb/test_consumers.py | 586 +++++++++++++++++++++ tests/components/usb/test_init.py | 14 + 7 files changed, 951 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/usb/consumers.py create mode 100644 tests/components/usb/test_consumers.py diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 1d2ab9b8f6c3a..0f54b862e3d85 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -3,7 +3,6 @@ import asyncio from collections.abc import Callable, Coroutine, Sequence from contextlib import suppress -import dataclasses from datetime import datetime, timedelta import logging import os @@ -32,7 +31,8 @@ from homeassistant.util.hass_dict import HassKey from .const import DOMAIN -from .models import SerialDevice, USBDevice +from .consumers import UNSCANNABLE_PORT_SCHEMES, async_get_serial_port_consumers +from .models import SerialDevice, SerialPortConsumer, USBDevice from .serial_proxy_stub import register_serialx_transport from .utils import ( scan_serial_ports, @@ -54,8 +54,10 @@ __all__ = [ "SerialDevice", + "SerialPortConsumer", "USBCallbackMatcher", "USBDevice", + "async_get_serial_port_consumers", "async_register_port_event_callback", "async_register_scan_request_callback", "async_register_serial_port_scanner", @@ -539,33 +541,111 @@ async def websocket_usb_scan( connection.send_result(msg["id"]) +@hass_callback +def _async_serialize_port( + hass: HomeAssistant, port: USBDevice | SerialDevice, *, present: bool = True +) -> dict[str, Any]: + """Serialize a serial port for the websocket API.""" + entry: dict[str, Any] = { + "device": port.device, + "resolved_device": port.resolved_device, + "serial_number": port.serial_number, + "manufacturer": port.manufacturer, + "description": port.description, + "interface_description": port.interface_description, + "interface_num": port.interface_num, + "matching_integrations": [], + "present": present, + } + + if isinstance(port, USBDevice): + entry["vid"] = port.vid + entry["pid"] = port.pid + entry["bcd_device"] = port.bcd_device + matchers = async_get_usb_matchers_for_device(hass, port) + entry["matching_integrations"] = list( + dict.fromkeys(matcher["domain"] for matcher in matchers) + ) + + return entry + + +@hass_callback +def _async_get_discovery_flows( + hass: HomeAssistant, device: str +) -> list[dict[str, str]]: + """Return the in-progress USB discovery flows for a device path.""" + return [ + {"flow_id": flow["flow_id"], "domain": flow["handler"]} + for flow in hass.config_entries.flow.async_progress_by_init_data_type( + UsbServiceInfo, lambda service_info: service_info.device == device + ) + ] + + +def _serialize_consumer(consumer: SerialPortConsumer) -> dict[str, Any]: + """Serialize a serial port consumer for the websocket API.""" + return { + "kind": consumer.kind, + "title": consumer.title, + "active": consumer.active, + "domain": consumer.domain, + "config_entry_id": consumer.config_entry_id, + "slug": consumer.slug, + } + + @websocket_api.require_admin -@websocket_api.websocket_command({vol.Required("type"): "usb/list_serial_ports"}) +@websocket_api.websocket_command( + { + vol.Required("type"): "usb/list_serial_ports", + vol.Optional("include_usage", default=False): bool, + } +) @websocket_api.async_response async def websocket_usb_list_serial_ports( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], ) -> None: - """List available serial ports.""" + """List serial ports, optionally with the integrations and apps using them.""" try: ports = await async_scan_serial_ports(hass) except OSError as err: connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err)) return - result = [] - for port in ports: - entry = dataclasses.asdict(port) + result = [_async_serialize_port(hass, port) for port in ports] - if isinstance(port, USBDevice): - matchers = async_get_usb_matchers_for_device(hass, port) - entry["matching_integrations"] = list( - dict.fromkeys(matcher["domain"] for matcher in matchers) - ) - else: - entry["matching_integrations"] = [] + if not msg["include_usage"]: + connection.send_result(msg["id"], result) + return - result.append(entry) + consumers = await async_get_serial_port_consumers(hass, ports) + + # Configured ports missing from the scan are absent, except for URLs no + # scanner can contribute, which are assumed present while claimed + scanned_devices = {port.device for port in ports} + result.extend( + _async_serialize_port( + hass, + SerialDevice( + device=device, + serial_number=None, + manufacturer=None, + description=None, + ), + present=device.startswith(UNSCANNABLE_PORT_SCHEMES), + ) + for device in consumers + if device not in scanned_devices + ) + + for entry in result: + device = entry["device"] + entry["consumers"] = [ + _serialize_consumer(consumer) for consumer in consumers.get(device, []) + ] + entry["discovery_flows"] = _async_get_discovery_flows(hass, device) connection.send_result(msg["id"], result) diff --git a/homeassistant/components/usb/consumers.py b/homeassistant/components/usb/consumers.py new file mode 100644 index 0000000000000..8dd32c97dda5e --- /dev/null +++ b/homeassistant/components/usb/consumers.py @@ -0,0 +1,238 @@ +"""Attribution of serial ports to the integrations and apps using them.""" + +from collections.abc import Mapping, Sequence +import os +import re +from typing import Any + +from homeassistant.components.hassio import HassioNotReadyError, get_addons_info +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.hassio import is_hassio +from homeassistant.loader import async_get_integrations + +from .const import DOMAIN +from .models import SerialDevice, SerialPortConsumer, USBDevice + +# Key paths holding a serial port in the config entry data and options of +# searched integrations. Traversed literally, never recursively. +SERIAL_PORT_KEY_PATHS: tuple[tuple[str, ...], ...] = ( + ("device",), + ("device", "path"), # zha + ("device_path",), # alarmdecoder + ("filename",), # bryant_evolution + ("host",), # elkm1 + ("port",), + ("serial_port",), # edl21, teleinfo + ("socket_path",), # zwave_js + ("usb_path",), # zwave_js, crownstone +) + +# Integrations configured with a serial port but not depending on `usb` +NON_USB_SERIAL_DOMAINS = ("alarmdecoder", "bryant_evolution", "elkm1", "mysensors") + +# States in which the entry claims its configured port, even if the port is not +# open right now: a retrying setup typically failed to open the port, while an +# unloading or failed-to-unload entry may still hold it +ACTIVE_CONFIG_ENTRY_STATES = ( + ConfigEntryState.LOADED, + ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_IN_PROGRESS, + ConfigEntryState.UNLOAD_IN_PROGRESS, + ConfigEntryState.FAILED_UNLOAD, +) + +# Remote ports contributed by serial port scanners; a configured port missing +# from the scan is absent, e.g. because the providing integration is offline +SCANNED_PORT_SCHEMES = ("esphome-hass://",) + +# Serial port URLs no scanner contributes; they can never be scanned, so a +# claiming consumer is the only evidence such a port exists +UNSCANNABLE_PORT_SCHEMES = ( + "esphome://", + "rfc2217://", + "socket://", + "tcp://", +) + +# upb wraps the port in a URL with an optional baud rate, e.g. +# `serial:///dev/ttyS0:4800`, which upb_lib strips itself when connecting +BAUD_SUFFIX_RE = re.compile(r":\d+$") + +# Supervisor app state, mirrors `aiohasupervisor.models.AddonState.STARTED` +APP_STATE_STARTED = "started" + + +def _resolve_key_path(data: Mapping[str, Any], key_path: tuple[str, ...]) -> Any: + """Return the value at a key path, or `None` if the path does not exist.""" + value: Any = data + + for key in key_path: + if not isinstance(value, Mapping) or key not in value: + return None + value = value[key] + + return value + + +def _serial_port_from_value( + value: Any, known_devices: set[str], domain: str +) -> str | None: + """Return the serial port a config entry value refers to.""" + if not isinstance(value, str): + return None + + if value in known_devices: + return value + + if value.startswith(SCANNED_PORT_SCHEMES): + return value + + if value.startswith(UNSCANNABLE_PORT_SCHEMES): + # zwave_js's esphome:// socket path embeds the noise PSK as `?key=` + return value.partition("?")[0] + + path = value + + if domain in ("elkm1", "upb"): + path = path.removeprefix("serial://").removeprefix("device://") + path = BAUD_SUFFIX_RE.sub("", path) + + if path.startswith("/dev/"): + return path + + return None + + +def _resolve_paths(paths: set[str]) -> dict[str, str]: + """Resolve symlinks of local device paths, passing other values through.""" + return { + path: os.path.realpath(path) if path.startswith("/") else path for path in paths + } + + +async def _async_get_config_entry_consumers( + hass: HomeAssistant, known_devices: set[str] +) -> dict[str, list[SerialPortConsumer]]: + """Return serial ports configured in config entries of `usb` integrations.""" + entries = hass.config_entries.async_entries(include_ignore=False) + integrations = await async_get_integrations( + hass, {entry.domain for entry in entries} + ) + consumers: dict[str, list[SerialPortConsumer]] = {} + + for entry in entries: + integration = integrations[entry.domain] + + if isinstance(integration, Exception): + continue + + if ( + entry.domain not in NON_USB_SERIAL_DOMAINS + and DOMAIN not in integration.dependencies + and DOMAIN not in integration.after_dependencies + ): + continue + + for key_path in SERIAL_PORT_KEY_PATHS: + for data in (entry.data, entry.options): + port = _serial_port_from_value( + _resolve_key_path(data, key_path), known_devices, entry.domain + ) + + if port is None: + continue + + consumers.setdefault(port, []).append( + SerialPortConsumer( + kind="config_entry", + title=entry.title, + active=entry.state in ACTIVE_CONFIG_ENTRY_STATES, + domain=entry.domain, + config_entry_id=entry.entry_id, + ) + ) + + return consumers + + +@callback +def _async_get_app_consumers( + hass: HomeAssistant, +) -> dict[str, list[SerialPortConsumer]]: + """Return devices mapped into apps, either statically or through options. + + Supervisor resolves `device(subsystem=tty)` options into real devices, so device + paths that no longer exist are missing and non-serial devices are included. + """ + if not is_hassio(hass): + return {} + + try: + apps_info = get_addons_info(hass) + except HassioNotReadyError: + return {} + + consumers: dict[str, list[SerialPortConsumer]] = {} + + for slug, info in apps_info.items(): + if info is None: + continue + + for device in info["devices"]: + consumers.setdefault(device, []).append( + SerialPortConsumer( + kind="app", + title=info["name"], + active=info["state"] == APP_STATE_STARTED, + slug=slug, + ) + ) + + return consumers + + +async def async_get_serial_port_consumers( + hass: HomeAssistant, ports: Sequence[USBDevice | SerialDevice] +) -> dict[str, list[SerialPortConsumer]]: + """Return the consumers of every serial port, keyed by device path. + + Scanned ports are keyed by their scanned device path, ports that are configured + but not currently present are keyed by their configured path. + """ + known_devices = {port.device for port in ports} + + entry_consumers = await _async_get_config_entry_consumers(hass, known_devices) + app_consumers = _async_get_app_consumers(hass) + + resolved = await hass.async_add_executor_job( + _resolve_paths, known_devices | set(entry_consumers) | set(app_consumers) + ) + + # A port can be referred to by any of its symlinks, e.g. `/dev/serial/by-id` + aliases: dict[str, str] = {} + + for port in ports: + aliases[resolved[port.device]] = port.device + aliases[port.device] = port.device + + consumers: dict[str, list[SerialPortConsumer]] = {} + + for path, path_consumers in entry_consumers.items(): + # Ports that are configured but missing are kept and shown as absent + device = aliases.get(resolved[path], path) + consumers.setdefault(device, []).extend(path_consumers) + + for path, path_consumers in app_consumers.items(): + # Apps also map non-serial devices, only scanned ports are of interest + resolved_path = resolved[path] + + if resolved_path not in aliases: + continue + + consumers.setdefault(aliases[resolved_path], []).extend(path_consumers) + + return { + device: list(dict.fromkeys(device_consumers)) + for device, device_consumers in consumers.items() + } diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json index 7f934dc9ee516..daa33746b363b 100644 --- a/homeassistant/components/usb/manifest.json +++ b/homeassistant/components/usb/manifest.json @@ -1,6 +1,7 @@ { "domain": "usb", "name": "USB Discovery", + "after_dependencies": ["hassio"], "codeowners": ["@bdraco"], "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/usb", diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py index 840978e5ea466..912dc7e59d796 100644 --- a/homeassistant/components/usb/models.py +++ b/homeassistant/components/usb/models.py @@ -1,6 +1,7 @@ """Models helper class for the usb integration.""" from dataclasses import dataclass +from typing import Literal @dataclass(slots=True, frozen=True, kw_only=True) @@ -8,6 +9,8 @@ class SerialDevice: """A serial device.""" device: str + resolved_device: str | None = None + serial_number: str | None manufacturer: str | None description: str | None @@ -24,3 +27,15 @@ class USBDevice(SerialDevice): # bcdDevice descriptor, often the firmware revision bcd_device: int | None = None + + +@dataclass(slots=True, frozen=True, kw_only=True) +class SerialPortConsumer: + """An integration or app configured to use a serial port.""" + + kind: Literal["config_entry", "app"] + title: str + active: bool + domain: str | None = None + config_entry_id: str | None = None + slug: str | None = None diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py index 3d048a56777fc..d7ca251ae8dcc 100644 --- a/homeassistant/components/usb/utils.py +++ b/homeassistant/components/usb/utils.py @@ -19,6 +19,7 @@ def usb_device_from_port(port: SerialPortInfo) -> USBDevice: return USBDevice( device=port.device, + resolved_device=port.resolved_device, vid=f"{hex(port.vid)[2:]:0>4}".upper(), pid=f"{hex(port.pid)[2:]:0>4}".upper(), serial_number=port.serial_number, @@ -34,6 +35,7 @@ def serial_device_from_port(port: SerialPortInfo) -> SerialDevice: """Convert serialx SerialPortInfo to SerialDevice.""" return SerialDevice( device=port.device, + resolved_device=port.resolved_device, serial_number=port.serial_number, manufacturer=port.manufacturer, description=port.description, diff --git a/tests/components/usb/test_consumers.py b/tests/components/usb/test_consumers.py new file mode 100644 index 0000000000000..e130bb46edcd8 --- /dev/null +++ b/tests/components/usb/test_consumers.py @@ -0,0 +1,586 @@ +"""Tests for serial port consumer attribution.""" + +from collections.abc import AsyncGenerator +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.hassio import HassioNotReadyError +from homeassistant.components.usb import DOMAIN +from homeassistant.components.usb.models import SerialDevice, USBDevice +from homeassistant.components.usb.utils import usb_service_info_from_device +from homeassistant.config_entries import ( + SOURCE_IGNORE, + SOURCE_USB, + SOURCE_USER, + ConfigEntryDisabler, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.setup import async_setup_component + +from . import patch_scanned_serial_ports + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, +) +from tests.typing import WebSocketGenerator + +TTY_USB0 = "/dev/ttyUSB0" +TTY_USB0_BY_ID = "/dev/serial/by-id/usb-Silicon_Labs_CP2102-if00-port0" +TTY_USB1 = "/dev/ttyUSB1" +ESPHOME_PORT = "esphome-hass://01JZ/uart0" + +USB0_PORT = USBDevice( + device=TTY_USB0, + vid="10C4", + pid="EA60", + serial_number="001234", + manufacturer="Silicon Labs", + description="CP2102 USB to UART", +) + + +@pytest.fixture(name="setup_ports") +async def setup_ports_fixture( + hass: HomeAssistant, force_usb_polling_watcher: None +) -> AsyncGenerator[None]: + """Set up the USB integration with a local and a remote serial port.""" + with ( + patch("homeassistant.components.usb.async_get_usb", return_value=[]), + patch_scanned_serial_ports( + return_value=[ + USB0_PORT, + SerialDevice( + device=ESPHOME_PORT, + serial_number="01JZ-uart0", + manufacturer="ESPHome", + description="Serial proxy", + ), + ] + ), + ): + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) + await hass.async_block_till_done() + yield + + +async def _async_get_serial_ports( + hass_ws_client: WebSocketGenerator, hass: HomeAssistant +) -> list[dict[str, Any]]: + """Return the result of the `usb/list_serial_ports` command with usage.""" + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + {"id": 1, "type": "usb/list_serial_ports", "include_usage": True} + ) + response = await ws_client.receive_json() + + assert response["success"] + return response["result"] + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + ("data", "options"), + [ + pytest.param({"device": TTY_USB0}, {}, id="device"), + pytest.param({"device": {"path": TTY_USB0}}, {}, id="nested_device_path"), + pytest.param({"port": TTY_USB0}, {}, id="port"), + pytest.param({"usb_path": TTY_USB0}, {}, id="usb_path"), + pytest.param({}, {"usb_path": TTY_USB0}, id="usb_path_in_options"), + pytest.param({"serial_port": TTY_USB0}, {}, id="serial_port"), + pytest.param({"device": TTY_USB0_BY_ID}, {}, id="by_id_symlink"), + pytest.param({"device": TTY_USB0}, {"device": TTY_USB0}, id="data_and_options"), + ], +) +async def test_config_entry_consumers( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + data: dict[str, Any], + options: dict[str, Any], +) -> None: + """Test detecting serial ports configured in config entries.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + entry = MockConfigEntry( + domain="test_usb", title="Test USB", data=data, options=options + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.usb.consumers.os.path.realpath", + side_effect=lambda path: TTY_USB0 if path == TTY_USB0_BY_ID else path, + ): + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [(port["device"], port["consumers"]) for port in result] == [ + ( + TTY_USB0, + [ + { + "kind": "config_entry", + "title": "Test USB", + "active": False, + "domain": "test_usb", + "config_entry_id": entry.entry_id, + "slug": None, + } + ], + ), + (ESPHOME_PORT, []), + ] + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + "device", + [ + pytest.param(f"serial://{TTY_USB0}", id="serial_url"), + pytest.param(f"serial://{TTY_USB0}:4800", id="serial_url_with_baud"), + pytest.param(f"device://{TTY_USB0}:4800", id="device_url_with_baud"), + pytest.param(f"{TTY_USB0}:4800", id="bare_path_with_baud"), + ], +) +async def test_config_entry_upb_url( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device: str, +) -> None: + """Test upb's URL forms with an optional baud rate suffix.""" + mock_integration(hass, MockModule("upb", dependencies=["usb"])) + MockConfigEntry(domain="upb", title="UPB", data={"device": device}).add_to_hass( + hass + ) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [(port["device"], len(port["consumers"])) for port in result] == [ + (TTY_USB0, 1), + (ESPHOME_PORT, 0), + ] + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + ("state", "active"), + [ + pytest.param(ConfigEntryState.LOADED, True, id="loaded"), + pytest.param(ConfigEntryState.SETUP_RETRY, True, id="setup_retry"), + pytest.param(ConfigEntryState.SETUP_IN_PROGRESS, True, id="setup_in_progress"), + pytest.param( + ConfigEntryState.UNLOAD_IN_PROGRESS, True, id="unload_in_progress" + ), + pytest.param(ConfigEntryState.FAILED_UNLOAD, True, id="failed_unload"), + pytest.param(ConfigEntryState.NOT_LOADED, False, id="not_loaded"), + pytest.param(ConfigEntryState.SETUP_ERROR, False, id="setup_error"), + pytest.param(ConfigEntryState.MIGRATION_ERROR, False, id="migration_error"), + ], +) +async def test_config_entry_active_states( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + state: ConfigEntryState, + active: bool, +) -> None: + """Test which config entry states mark the consumer as active.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + MockConfigEntry( + domain="test_usb", title="Test USB", data={"device": TTY_USB0}, state=state + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [consumer["active"] for consumer in result[0]["consumers"]] == [active] + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + "data", + [ + pytest.param({"port": 8080}, id="tcp_port"), + pytest.param({"port": "192.0.2.1:1234"}, id="host_and_port"), + pytest.param({"device": {"other": TTY_USB0}}, id="unknown_nested_key"), + pytest.param({"other": TTY_USB0}, id="unknown_key"), + ], +) +async def test_config_entry_non_serial_values( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + data: dict[str, Any], +) -> None: + """Test values that do not refer to a serial port are ignored.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + MockConfigEntry(domain="test_usb", title="Test USB", data=data).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [port["consumers"] for port in result] == [[], []] + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + ("source", "disabled_by", "num_consumers"), + [ + pytest.param(SOURCE_IGNORE, None, 0, id="ignored_entry_hidden"), + pytest.param( + SOURCE_USER, ConfigEntryDisabler.USER, 1, id="disabled_entry_shown" + ), + ], +) +async def test_config_entry_ignored_and_disabled( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + source: str, + disabled_by: ConfigEntryDisabler | None, + num_consumers: int, +) -> None: + """Test ignored entries are hidden while disabled entries are shown.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + MockConfigEntry( + domain="test_usb", + title="Test USB", + data={"device": TTY_USB0}, + source=source, + disabled_by=disabled_by, + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [len(port["consumers"]) for port in result] == [num_consumers, 0] + + +@pytest.mark.usefixtures("setup_ports") +async def test_socket_path_psk_not_exposed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the noise PSK in zwave_js's esphome socket path is stripped.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + MockConfigEntry( + domain="test_usb", + title="Test USB", + data={"socket_path": "esphome://192.0.2.5:6053/?key=secret-psk"}, + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [ + (port["device"], port["present"], len(port["consumers"])) for port in result + ] == [ + (TTY_USB0, True, 0), + (ESPHOME_PORT, True, 0), + ("esphome://192.0.2.5:6053/", True, 1), + ] + assert "secret-psk" not in str(result) + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + ("domain", "data"), + [ + pytest.param("alarmdecoder", {"device_path": TTY_USB0}, id="alarmdecoder"), + pytest.param("bryant_evolution", {"filename": TTY_USB0}, id="bryant_evolution"), + pytest.param("elkm1", {"host": f"serial://{TTY_USB0}:115200"}, id="elkm1"), + pytest.param("mysensors", {"device": TTY_USB0}, id="mysensors"), + ], +) +async def test_non_usb_serial_domains( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + domain: str, + data: dict[str, Any], +) -> None: + """Test integrations holding a serial port without a `usb` dependency.""" + mock_integration(hass, MockModule(domain)) + MockConfigEntry(domain=domain, title="Test", data=data).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [len(port["consumers"]) for port in result] == [1, 0] + + +@pytest.mark.usefixtures("setup_ports") +async def test_config_entry_unknown_integration( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test config entries of integrations that fail to resolve are ignored.""" + MockConfigEntry( + domain="removed_custom_component", title="Test", data={"device": TTY_USB0} + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [port["consumers"] for port in result] == [[], []] + + +@pytest.mark.usefixtures("setup_ports") +async def test_config_entry_without_usb_dependency( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test config entries of integrations not depending on `usb` are ignored.""" + mock_integration(hass, MockModule("test_no_usb")) + MockConfigEntry( + domain="test_no_usb", title="Test", data={"device": TTY_USB0} + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [port["consumers"] for port in result] == [[], []] + + +@pytest.mark.usefixtures("setup_ports") +async def test_config_entry_after_dependency( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test config entries of integrations depending on `usb` after setup.""" + mock_integration( + hass, + MockModule("test_after_usb", partial_manifest={"after_dependencies": ["usb"]}), + ) + MockConfigEntry( + domain="test_after_usb", title="Test", data={"device": TTY_USB0} + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [len(port["consumers"]) for port in result] == [1, 0] + + +@pytest.mark.usefixtures("setup_ports") +async def test_remote_port_consumer( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a config entry using a remote serial port.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + MockConfigEntry( + domain="test_usb", title="Test USB", data={"device": ESPHOME_PORT} + ).add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [(port["device"], len(port["consumers"])) for port in result] == [ + (TTY_USB0, 0), + (ESPHOME_PORT, 1), + ] + + +@pytest.mark.usefixtures("setup_ports") +@pytest.mark.parametrize( + ("device", "present"), + [ + pytest.param(TTY_USB1, False, id="local"), + pytest.param("esphome-hass://02AB/uart0", False, id="esphome_proxy"), + pytest.param("esphome://ttl-to-serial.local/uart1", True, id="esphome"), + pytest.param("socket://192.0.2.1:1234", True, id="socket"), + pytest.param("tcp://192.0.2.1:1234", True, id="tcp"), + pytest.param("rfc2217://192.0.2.1:1234", True, id="rfc2217"), + ], +) +async def test_configured_port_not_scanned( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device: str, + present: bool, +) -> None: + """Test a configured port that is not in the scan. + + Scannable ports are absent, unscannable URLs are assumed present. + """ + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + entry = MockConfigEntry( + domain="test_usb", title="Test USB", data={"device": device} + ) + entry.add_to_hass(hass) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [(port["device"], port["present"]) for port in result] == [ + (TTY_USB0, True), + (ESPHOME_PORT, True), + (device, present), + ] + assert result[2] == { + "device": device, + "resolved_device": None, + "serial_number": None, + "manufacturer": None, + "description": None, + "interface_description": None, + "interface_num": None, + "matching_integrations": [], + "present": present, + "discovery_flows": [], + "consumers": [ + { + "kind": "config_entry", + "title": "Test USB", + "active": False, + "domain": "test_usb", + "config_entry_id": entry.entry_id, + "slug": None, + } + ], + } + + +@pytest.mark.usefixtures("setup_ports") +async def test_app_consumers( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test detecting serial ports mapped into apps.""" + apps_info = { + "core_zwave_js": { + "name": "Z-Wave JS", + "state": "started", + "devices": [TTY_USB0_BY_ID, "/dev/dri/card0"], + }, + "some_app": { + "name": "Some App", + "state": "stopped", + "devices": [TTY_USB1], + }, + "uninstalled_app": None, + } + + with ( + patch("homeassistant.components.usb.consumers.is_hassio", return_value=True), + patch( + "homeassistant.components.usb.consumers.get_addons_info", + return_value=apps_info, + ), + patch( + "homeassistant.components.usb.consumers.os.path.realpath", + side_effect=lambda path: TTY_USB0 if path == TTY_USB0_BY_ID else path, + ), + ): + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [(port["device"], port["consumers"]) for port in result] == [ + ( + TTY_USB0, + [ + { + "kind": "app", + "title": "Z-Wave JS", + "active": True, + "domain": None, + "config_entry_id": None, + "slug": "core_zwave_js", + } + ], + ), + (ESPHOME_PORT, []), + ] + + +@pytest.mark.usefixtures("setup_ports") +async def test_app_consumers_without_supervisor( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test apps are not considered without a supervisor.""" + with patch( + "homeassistant.components.usb.consumers.get_addons_info" + ) as mock_apps_info: + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert len(mock_apps_info.mock_calls) == 0 + assert [port["consumers"] for port in result] == [[], []] + + +@pytest.mark.usefixtures("setup_ports") +async def test_app_consumers_supervisor_not_ready( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test apps are not considered when the supervisor is not ready yet.""" + with ( + patch("homeassistant.components.usb.consumers.is_hassio", return_value=True), + patch( + "homeassistant.components.usb.consumers.get_addons_info", + side_effect=HassioNotReadyError("Not ready"), + ), + ): + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [port["consumers"] for port in result] == [[], []] + + +@pytest.mark.usefixtures("setup_ports") +async def test_multiple_consumers( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a port used by both an integration and an app.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + entry = MockConfigEntry( + domain="test_usb", title="Test USB", data={"device": TTY_USB0} + ) + entry.add_to_hass(hass) + + apps_info = { + "some_app": {"name": "Some App", "state": "started", "devices": [TTY_USB0]} + } + + with ( + patch("homeassistant.components.usb.consumers.is_hassio", return_value=True), + patch( + "homeassistant.components.usb.consumers.get_addons_info", + return_value=apps_info, + ), + ): + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [ + (consumer["kind"], consumer["title"]) for consumer in result[0]["consumers"] + ] == [("config_entry", "Test USB"), ("app", "Some App")] + + +class MockUsbFlow(ConfigFlow): + """Config flow that keeps USB discoveries in progress.""" + + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: + """Show a form so the discovery flow stays in progress.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show a form so the discovery flow stays in progress.""" + return self.async_show_form(step_id="confirm") + + +@pytest.mark.usefixtures("setup_ports") +async def test_discovery_flows( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that in-progress discovery flows are listed for their serial port.""" + mock_integration(hass, MockModule("test_usb", dependencies=["usb"])) + mock_platform(hass, "test_usb.config_flow", None) + + with mock_config_flow("test_usb", MockUsbFlow): + flow = await hass.config_entries.flow.async_init( + "test_usb", + context={"source": SOURCE_USB}, + data=usb_service_info_from_device(USB0_PORT), + ) + + result = await _async_get_serial_ports(hass_ws_client, hass) + + assert [(port["device"], port["discovery_flows"]) for port in result] == [ + (TTY_USB0, [{"flow_id": flow["flow_id"], "domain": "test_usb"}]), + (ESPHOME_PORT, []), + ] diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 39da6c140f5bc..4e75ee9339e49 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -1327,12 +1327,14 @@ async def test_async_scan_serial_ports(hass: HomeAssistant) -> None: assert devices == [ SerialDevice( device="/dev/ttyAMA1", + resolved_device="/dev/ttyAMA1", serial_number=None, manufacturer=None, description="ttyAMA1", ), USBDevice( device="/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_10B41DE589FC-if00", + resolved_device="/dev/ttyACM0", vid="303A", pid="4001", serial_number="10B41DE589FC", @@ -1693,6 +1695,7 @@ async def test_list_serial_ports( mock_ports = [ USBDevice( device="/dev/ttyUSB0", + resolved_device="/dev/ttyUSB0", vid="10C4", pid="EA60", serial_number="001234", @@ -1704,6 +1707,7 @@ async def test_list_serial_ports( ), USBDevice( device="/dev/ttyUSB1", + resolved_device="/dev/ttyUSB1", vid="DEAD", pid="BEEF", serial_number=None, @@ -1712,6 +1716,7 @@ async def test_list_serial_ports( ), USBDevice( device="/dev/ttyUSB2", + resolved_device="/dev/ttyUSB2", vid="0000", pid="0000", serial_number=None, @@ -1720,6 +1725,7 @@ async def test_list_serial_ports( ), SerialDevice( device="/dev/ttyS0", + resolved_device="/dev/ttyS0", serial_number=None, manufacturer=None, description="ttyS0", @@ -1741,6 +1747,7 @@ async def test_list_serial_ports( assert response["result"] == [ { "device": "/dev/ttyUSB0", + "resolved_device": "/dev/ttyUSB0", "vid": "10C4", "pid": "EA60", "serial_number": "001234", @@ -1750,9 +1757,11 @@ async def test_list_serial_ports( "interface_description": "CP2102 USB to UART Bridge", "interface_num": 0, "matching_integrations": ["homeassistant_sky_connect"], + "present": True, }, { "device": "/dev/ttyUSB1", + "resolved_device": "/dev/ttyUSB1", "vid": "DEAD", "pid": "BEEF", "serial_number": None, @@ -1762,9 +1771,11 @@ async def test_list_serial_ports( "interface_description": None, "interface_num": None, "matching_integrations": ["custom_component"], + "present": True, }, { "device": "/dev/ttyUSB2", + "resolved_device": "/dev/ttyUSB2", "vid": "0000", "pid": "0000", "serial_number": None, @@ -1774,15 +1785,18 @@ async def test_list_serial_ports( "interface_description": None, "interface_num": None, "matching_integrations": [], + "present": True, }, { "device": "/dev/ttyS0", + "resolved_device": "/dev/ttyS0", "serial_number": None, "manufacturer": None, "description": "ttyS0", "interface_description": None, "interface_num": None, "matching_integrations": [], + "present": True, }, ] From daef5e95b8170af02f1017f35a19c5d787d0ca5f Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:50:51 +0200 Subject: [PATCH 36/38] Bump guntamatic to v1.11.0 (#179767) --- homeassistant/components/guntamatic/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json index b59b5251e70e3..2416bc0e07c5d 100644 --- a/homeassistant/components/guntamatic/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["guntamatic==1.9.3"] + "requirements": ["guntamatic==1.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 04a57bc4a5834..daa56831b5313 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1201,7 +1201,7 @@ growattServer==2.1.0 gspread==5.5.0 # homeassistant.components.guntamatic -guntamatic==1.9.3 +guntamatic==1.11.0 # homeassistant.components.profiler guppy3==3.1.7 From bdd2069a74905879e938e5e749d394cf14b9d414 Mon Sep 17 00:00:00 2001 From: soldier2008 <217476753+soldier2008@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:53:06 -0300 Subject: [PATCH 37/38] Avoid a naive datetime.now() in august (#179182) Co-authored-by: Paulus Schoutsen --- homeassistant/components/august/util.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/august/util.py b/homeassistant/components/august/util.py index 7dafaabfe5f40..48d31412ce9a9 100644 --- a/homeassistant/components/august/util.py +++ b/homeassistant/components/august/util.py @@ -1,6 +1,6 @@ """August util functions.""" -from datetime import datetime, timedelta +from datetime import timedelta from functools import partial import aiohttp @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client +from homeassistant.util import dt as dt_util from . import AugustData @@ -61,7 +62,7 @@ def _activity_time_based(latest: Activity) -> Activity | None: """Get the latest state of the sensor.""" start = latest.activity_start_time end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION - if start <= datetime.now() <= end: # pylint: disable=home-assistant-enforce-naive-now + if start <= dt_util.naive_now() <= end: return latest return None From f6c9b6fdf9b5a2d83ca33c25c5e080d31690935b Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Fri, 21 Aug 2026 16:54:37 -0400 Subject: [PATCH 38/38] Add Hotspring sensor platform (#179697) Co-authored-by: Moustachauve <2206577+Moustachauve@users.noreply.github.com> --- .../components/hotspring/__init__.py | 5 +- homeassistant/components/hotspring/sensor.py | 125 ++++++ .../components/hotspring/strings.json | 23 ++ tests/components/hotspring/conftest.py | 14 +- .../hotspring/snapshots/test_sensor.ambr | 372 ++++++++++++++++++ tests/components/hotspring/test_sensor.py | 24 ++ 6 files changed, 560 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/hotspring/sensor.py create mode 100644 tests/components/hotspring/snapshots/test_sensor.ambr create mode 100644 tests/components/hotspring/test_sensor.py diff --git a/homeassistant/components/hotspring/__init__.py b/homeassistant/components/hotspring/__init__.py index 18a49859683f8..d6e75e304d8d4 100644 --- a/homeassistant/components/hotspring/__init__.py +++ b/homeassistant/components/hotspring/__init__.py @@ -5,7 +5,10 @@ from .coordinator import HotSpringConfigEntry, HotSpringDataUpdateCoordinator -PLATFORMS = [Platform.NUMBER] +PLATFORMS = [ + Platform.NUMBER, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: HotSpringConfigEntry) -> bool: diff --git a/homeassistant/components/hotspring/sensor.py b/homeassistant/components/hotspring/sensor.py new file mode 100644 index 0000000000000..d8e7eb2f5e424 --- /dev/null +++ b/homeassistant/components/hotspring/sensor.py @@ -0,0 +1,125 @@ +"""Support for Hot Spring sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from hotspring import Spa + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import HotSpringConfigEntry, HotSpringDataUpdateCoordinator +from .entity import HotSpringEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class HotSpringSensorEntityDescription(SensorEntityDescription): + """Describes Hot Spring sensor entity.""" + + exists_fn: Callable[[Spa], bool] = lambda _: True + value_fn: Callable[[Spa], StateType] + + +SENSORS: tuple[HotSpringSensorEntityDescription, ...] = ( + HotSpringSensorEntityDescription( + key="current_temperature", + translation_key="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + value_fn=lambda spa: spa.heater.current_temperature, + ), + HotSpringSensorEntityDescription( + key="water_care_120_day_timer", + translation_key="water_care_120_day_timer", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda spa: spa.water_care.one_twenty_day_timer, + exists_fn=lambda spa: spa.water_care.cartridge_installed, + ), + HotSpringSensorEntityDescription( + key="water_care_salt_value", + translation_key="water_care_salt_value", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda spa: spa.water_care.salt_value, + exists_fn=lambda spa: spa.water_care.cartridge_installed, + ), + HotSpringSensorEntityDescription( + key="water_care_10_day_timer", + translation_key="water_care_10_day_timer", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda spa: spa.water_care.ten_day_timer, + exists_fn=lambda spa: spa.water_care.cartridge_installed, + ), + HotSpringSensorEntityDescription( + key="control_box_version", + translation_key="control_box_version", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda spa: spa.versions.control_box, + exists_fn=lambda spa: bool(spa.versions.control_box), + ), + HotSpringSensorEntityDescription( + key="wifi_dongle_version", + translation_key="wifi_dongle_version", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda spa: spa.versions.wifi_dongle, + exists_fn=lambda spa: bool(spa.versions.wifi_dongle), + ), + HotSpringSensorEntityDescription( + key="fwss_version", + translation_key="fwss_version", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda spa: spa.versions.fwss, + exists_fn=lambda spa: bool(spa.versions.fwss), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HotSpringConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Hot Spring sensor entities.""" + coordinator = entry.runtime_data + async_add_entities( + HotSpringSensorEntity(coordinator, description) + for description in SENSORS + if description.exists_fn(coordinator.data) + ) + + +class HotSpringSensorEntity(HotSpringEntity, SensorEntity): + """Defines a Hot Spring sensor entity.""" + + entity_description: HotSpringSensorEntityDescription + + def __init__( + self, + coordinator: HotSpringDataUpdateCoordinator, + description: HotSpringSensorEntityDescription, + ) -> None: + """Initialize the sensor entity.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + @override + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/hotspring/strings.json b/homeassistant/components/hotspring/strings.json index c4513d9d5f9d2..5fd6d7c75e323 100644 --- a/homeassistant/components/hotspring/strings.json +++ b/homeassistant/components/hotspring/strings.json @@ -30,6 +30,29 @@ "target_temperature": { "name": "Target temperature" } + }, + "sensor": { + "control_box_version": { + "name": "Control box version" + }, + "current_temperature": { + "name": "Current temperature" + }, + "fwss_version": { + "name": "FreshWater Salt System version" + }, + "water_care_10_day_timer": { + "name": "Salt 10-day check timer" + }, + "water_care_120_day_timer": { + "name": "Salt cartridge age" + }, + "water_care_salt_value": { + "name": "Salt value" + }, + "wifi_dongle_version": { + "name": "Wi-Fi dongle version" + } } }, "exceptions": { diff --git a/tests/components/hotspring/conftest.py b/tests/components/hotspring/conftest.py index 167c1404b548f..a9f2d96c96aca 100644 --- a/tests/components/hotspring/conftest.py +++ b/tests/components/hotspring/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from hotspring import Heater, Spa, SpaBrand, SpaInfo, Versions +from hotspring import Heater, Spa, SpaBrand, SpaInfo, Versions, WaterCare import pytest from homeassistant.components.hotspring.const import DOMAIN @@ -52,7 +52,7 @@ def device_fixture() -> Spa: spa.versions = Versions( control_box="3.0.0", control_panel="2.0.0", - fwss="", + fwss="1.0.0", fwiq="", btxr="", cool_zone="", @@ -66,6 +66,16 @@ def device_fixture() -> Spa: heater.set_temperature = 104.0 heater.is_on = True spa.heater = heater + spa.water_care = WaterCare( + cartridge_installed=True, + ten_day_timer=0, + one_twenty_day_timer=117, + level=2, + system_enabled=True, + ace_mode="inactive", + boost_active=False, + salt_value=12, + ) return spa diff --git a/tests/components/hotspring/snapshots/test_sensor.ambr b/tests/components/hotspring/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..15e8bcbbc99a5 --- /dev/null +++ b/tests/components/hotspring/snapshots/test_sensor.ambr @@ -0,0 +1,372 @@ +# serializer version: 1 +# name: test_sensors[sensor.connectedspa_ddeeff_control_box_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.connectedspa_ddeeff_control_box_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Control box version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Control box version', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'control_box_version', + 'unique_id': 'AA:BB:CC:DD:EE:FF_control_box_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_control_box_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ConnectedSpa_DDEEFF Control box version', + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_control_box_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0.0', + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_current_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.connectedspa_ddeeff_current_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current temperature', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_temperature', + 'unique_id': 'AA:BB:CC:DD:EE:FF_current_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_current_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'ConnectedSpa_DDEEFF Current temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_current_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.8888888888889', + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_freshwater_salt_system_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.connectedspa_ddeeff_freshwater_salt_system_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'FreshWater Salt System version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'FreshWater Salt System version', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fwss_version', + 'unique_id': 'AA:BB:CC:DD:EE:FF_fwss_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_freshwater_salt_system_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ConnectedSpa_DDEEFF FreshWater Salt System version', + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_freshwater_salt_system_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0.0', + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_salt_10_day_check_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.connectedspa_ddeeff_salt_10_day_check_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Salt 10-day check timer', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Salt 10-day check timer', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_care_10_day_timer', + 'unique_id': 'AA:BB:CC:DD:EE:FF_water_care_10_day_timer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_salt_10_day_check_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'ConnectedSpa_DDEEFF Salt 10-day check timer', + : , + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_salt_10_day_check_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_salt_cartridge_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.connectedspa_ddeeff_salt_cartridge_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Salt cartridge age', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Salt cartridge age', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_care_120_day_timer', + 'unique_id': 'AA:BB:CC:DD:EE:FF_water_care_120_day_timer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_salt_cartridge_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'ConnectedSpa_DDEEFF Salt cartridge age', + : , + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_salt_cartridge_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '117', + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_salt_value-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.connectedspa_ddeeff_salt_value', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Salt value', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Salt value', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_care_salt_value', + 'unique_id': 'AA:BB:CC:DD:EE:FF_water_care_salt_value', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_salt_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ConnectedSpa_DDEEFF Salt value', + : , + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_salt_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_wi_fi_dongle_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.connectedspa_ddeeff_wi_fi_dongle_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi dongle version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi dongle version', + 'platform': 'hotspring', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_dongle_version', + 'unique_id': 'AA:BB:CC:DD:EE:FF_wifi_dongle_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.connectedspa_ddeeff_wi_fi_dongle_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ConnectedSpa_DDEEFF Wi-Fi dongle version', + }), + 'context': , + 'entity_id': 'sensor.connectedspa_ddeeff_wi_fi_dongle_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0.0', + }) +# --- diff --git a/tests/components/hotspring/test_sensor.py b/tests/components/hotspring/test_sensor.py new file mode 100644 index 0000000000000..970913b19a8ad --- /dev/null +++ b/tests/components/hotspring/test_sensor.py @@ -0,0 +1,24 @@ +"""Tests for the Hot Spring sensor platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_hotspring") +async def test_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor platform state.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)