From b474a42844fe497144612485fa166c17f2cb7bfc Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:39:40 +0200 Subject: [PATCH 1/8] unifiprotect: bump uiprotect to 10.4.0 (#169146) Co-authored-by: RaHehl --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 8a79f2e0d54871..ac8396b654372e 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==10.3.1"] + "requirements": ["uiprotect==10.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3e0e24b239152b..a17214d75e641c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3192,7 +3192,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==10.3.1 +uiprotect==10.4.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f79e7697e96ffb..ad956fb564e02a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2713,7 +2713,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==10.3.1 +uiprotect==10.4.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 759ac2eacd4e353b56e0fde5e8ddad6ea4fb155f Mon Sep 17 00:00:00 2001 From: Mika Date: Sat, 25 Apr 2026 23:00:49 +0200 Subject: [PATCH 2/8] Add battery storage data sensors to SolarEdge integration (#161722) Co-authored-by: Claude Co-authored-by: it-rec <19797875+it-rec@users.noreply.github.com> --- homeassistant/components/solaredge/const.py | 1 + .../components/solaredge/coordinator.py | 81 +++ homeassistant/components/solaredge/sensor.py | 206 ++++++- .../components/solaredge/strings.json | 18 + .../components/solaredge/test_coordinator.py | 4 +- tests/components/solaredge/test_sensor.py | 503 ++++++++++++++++++ 6 files changed, 797 insertions(+), 16 deletions(-) create mode 100644 tests/components/solaredge/test_sensor.py diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index 35a14091e68b0f..e2e141402fbeb6 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -22,6 +22,7 @@ INVENTORY_UPDATE_DELAY = timedelta(hours=12) POWER_FLOW_UPDATE_DELAY = timedelta(minutes=15) ENERGY_DETAILS_DELAY = timedelta(minutes=15) +STORAGE_DATA_UPDATE_DELAY = timedelta(hours=4) MODULE_STATISTICS_UPDATE_DELAY = timedelta(hours=12) SCAN_INTERVAL = timedelta(minutes=15) diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py index ed3bff8cea2e37..9fb33a755f3810 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -38,6 +38,7 @@ MODULE_STATISTICS_UPDATE_DELAY, OVERVIEW_UPDATE_DELAY, POWER_FLOW_UPDATE_DELAY, + STORAGE_DATA_UPDATE_DELAY, ) if TYPE_CHECKING: @@ -334,6 +335,86 @@ async def async_update_data(self) -> None: LOGGER.debug("Updated SolarEdge power flow: %s, %s", self.data, self.attributes) +class SolarEdgeStorageDataService(SolarEdgeDataService): + """Get and update the latest storage data.""" + + @property + def update_interval(self) -> timedelta: + """Update interval.""" + return STORAGE_DATA_UPDATE_DELAY + + async def async_update_data(self) -> None: + """Update the data from the SolarEdge Monitoring API.""" + now = dt_util.now() + start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) + data = await self.api.get_storage_data( + self.site_id, + start_of_day, + now, + ) + storage_data = data.get("storageData") + if storage_data is None: + raise UpdateFailed("Storage data not available from API") + + batteries = storage_data.get("batteries") + if batteries is None: + raise UpdateFailed("Battery data not available from API") + + self.data = {} + self.attributes = {} + + if not batteries: + LOGGER.debug("No batteries found in storage data") + return + + # Aggregate totals across all batteries + total_charge_energy = 0.0 + total_discharge_energy = 0.0 + + for battery in batteries: + serial = battery.get("serialNumber") + if not serial: + LOGGER.debug("Skipping battery without serialNumber") + continue + + telemetries = battery.get("telemetries", []) + + if not telemetries: + continue + + latest = telemetries[-1] + + # Per-battery current values + self.data[f"{serial}_state_of_charge"] = latest.get( + "batteryPercentageState" + ) + self.data[f"{serial}_power"] = latest.get("power") + + # Compute daily charge/discharge delta from lifetime counters + if len(telemetries) >= 2: + first = telemetries[0] + charge_energy = latest.get("lifeTimeEnergyCharged", 0.0) - first.get( + "lifeTimeEnergyCharged", 0.0 + ) + discharge_energy = latest.get( + "lifeTimeEnergyDischarged", 0.0 + ) - first.get("lifeTimeEnergyDischarged", 0.0) + else: + charge_energy = 0.0 + discharge_energy = 0.0 + + total_charge_energy += charge_energy + total_discharge_energy += discharge_energy + + self.data[f"{serial}_charge_energy"] = charge_energy + self.data[f"{serial}_discharge_energy"] = discharge_energy + + self.data["charge_energy"] = total_charge_energy + self.data["discharge_energy"] = total_discharge_energy + + LOGGER.debug("Updated SolarEdge storage data: %s", self.data) + + class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): """Handle fetching SolarEdge Modules data and inserting statistics.""" diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index b56c35be16023d..096b1eed70dc69 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -22,7 +22,7 @@ DataUpdateCoordinator, ) -from .const import CONF_SITE_ID, DATA_API_CLIENT, DOMAIN +from .const import CONF_SITE_ID, DATA_API_CLIENT, DOMAIN, LOGGER from .coordinator import ( SolarEdgeDataService, SolarEdgeDetailsDataService, @@ -30,6 +30,7 @@ SolarEdgeInventoryDataService, SolarEdgeOverviewDataService, SolarEdgePowerFlowDataService, + SolarEdgeStorageDataService, ) from .types import SolarEdgeConfigEntry @@ -207,6 +208,64 @@ class SolarEdgeSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), + SolarEdgeSensorEntityDescription( + key="storage_charge_energy", + json_key="charge_energy", + translation_key="storage_charge_energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="storage_discharge_energy", + json_key="discharge_energy", + translation_key="storage_discharge_energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), +] + +# Per-battery sensor descriptions, created dynamically per serial number +BATTERY_SENSOR_TYPES = [ + SolarEdgeSensorEntityDescription( + key="battery_charge_energy", + json_key="charge_energy", + translation_key="battery_charge_energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="battery_discharge_energy", + json_key="discharge_energy", + translation_key="battery_discharge_energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="battery_state_of_charge", + json_key="state_of_charge", + translation_key="battery_state_of_charge", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + SolarEdgeSensorEntityDescription( + key="battery_power", + json_key="power", + translation_key="battery_power", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + ), ] @@ -222,15 +281,43 @@ async def async_setup_entry( api = entry.runtime_data[DATA_API_CLIENT] sensor_factory = SolarEdgeSensorFactory(hass, entry, entry.data[CONF_SITE_ID], api) + + # Set up and refresh base services first for service in sensor_factory.all_services: service.async_setup() await service.coordinator.async_refresh() - entities = [] + entities: list[SolarEdgeSensorEntity] = [] + + # Set up storage sensors only if inventory shows batteries are present + storage_result = sensor_factory.setup_storage_sensors() + if storage_result is not None: + if storage_result: + await sensor_factory.storage_service.coordinator.async_refresh() + entities.extend(storage_result) + else: + # Inventory fetch failed, register listener to retry when data arrives + def on_inventory_update() -> None: + """Handle inventory update to set up storage sensors.""" + result = sensor_factory.setup_storage_sensors() + if result is not None: + if result: + hass.async_create_task( + sensor_factory.storage_service.coordinator.async_refresh() + ) + async_add_entities(result) + # Success or confirmed no batteries - stop listening + unsub() + + unsub = sensor_factory.inventory_service.coordinator.async_add_listener( + on_inventory_update + ) + entry.async_on_unload(unsub) + for sensor_type in SENSOR_TYPES: - sensor = sensor_factory.create_sensor(sensor_type) - if sensor is not None: - entities.append(sensor) + if sensor_type.key in ("storage_charge_energy", "storage_discharge_energy"): + continue + entities.append(sensor_factory.create_sensor(sensor_type)) async_add_entities(entities) @@ -251,8 +338,17 @@ def __init__( inventory = SolarEdgeInventoryDataService(hass, config_entry, api, site_id) flow = SolarEdgePowerFlowDataService(hass, config_entry, api, site_id) energy = SolarEdgeEnergyDetailsService(hass, config_entry, api, site_id) - - self.all_services = (details, overview, inventory, flow, energy) + storage = SolarEdgeStorageDataService(hass, config_entry, api, site_id) + + self.all_services: list[SolarEdgeDataService] = [ + details, + overview, + inventory, + flow, + energy, + ] + self.inventory_service = inventory + self.storage_service = storage self.services: dict[ str, @@ -289,6 +385,56 @@ def __init__( ): self.services[key] = (SolarEdgeEnergyDetailsSensor, energy) + def setup_storage_sensors( + self, + ) -> list[SolarEdgeSensorEntity] | None: + """Set up storage sensors if batteries are available. + + Returns: + list: Storage sensor entities to add (empty if no batteries) + None: Inventory fetch failed, should retry later + """ + # Check if inventory data was successfully fetched + if not self.inventory_service.coordinator.last_update_success: + LOGGER.debug("Inventory data not available, will retry later") + return None + + battery_attr = self.inventory_service.attributes.get("batteries", {}) + inventory_batteries = battery_attr.get("batteries", []) + if not inventory_batteries: + LOGGER.debug("No batteries found in inventory, skipping storage sensors") + return [] + + # Set up storage service and add to services + self.storage_service.async_setup() + self.all_services.append(self.storage_service) + + for key in ("storage_charge_energy", "storage_discharge_energy"): + self.services[key] = (SolarEdgeStorageDataSensor, self.storage_service) + + # Create aggregate storage sensors + storage_entities: list[SolarEdgeSensorEntity] = [ + self.create_sensor(sensor_type) + for sensor_type in SENSOR_TYPES + if sensor_type.key in ("storage_charge_energy", "storage_discharge_energy") + ] + + # Create per-battery entities + for battery in inventory_batteries: + serial = battery.get("SN") or battery.get("serialNumber") + if not serial: + LOGGER.debug("Skipping battery without serial number in inventory") + continue + storage_entities.extend( + SolarEdgeBatterySensor(sensor_type, self.storage_service, serial) + for sensor_type in BATTERY_SENSOR_TYPES + ) + + LOGGER.debug( + "Storage sensors enabled, found %d batteries", len(inventory_batteries) + ) + return storage_entities + def create_sensor( self, sensor_type: SolarEdgeSensorEntityDescription ) -> SolarEdgeSensorEntity: @@ -316,17 +462,11 @@ def __init__( super().__init__(data_service.coordinator) self.entity_description = description self.data_service = data_service + self._attr_unique_id = f"{data_service.site_id}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, data_service.site_id)}, manufacturer="SolarEdge" ) - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - if not self.data_service.site_id: - return None - return f"{self.data_service.site_id}_{self.entity_description.key}" - class SolarEdgeOverviewSensor(SolarEdgeSensorEntity): """Representation of an SolarEdge Monitoring API overview sensor.""" @@ -434,3 +574,41 @@ def native_value(self) -> str | None: if attr and "soc" in attr: return attr["soc"] return None + + +class SolarEdgeStorageDataSensor(SolarEdgeSensorEntity): + """Representation of an SolarEdge aggregate storage data sensor.""" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.data_service.data.get(self.entity_description.json_key) + + +class SolarEdgeBatterySensor(SolarEdgeSensorEntity): + """Representation of a per-battery SolarEdge sensor.""" + + def __init__( + self, + description: SolarEdgeSensorEntityDescription, + data_service: SolarEdgeStorageDataService, + serial: str, + ) -> None: + """Initialize the per-battery sensor.""" + super().__init__(description, data_service) + self._serial = serial + self._attr_unique_id = f"{data_service.site_id}_{serial}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{data_service.site_id}_{serial}")}, + manufacturer="SolarEdge", + name=f"Battery {serial}", + serial_number=serial, + via_device=(DOMAIN, data_service.site_id), + ) + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.data_service.data.get( + f"{self._serial}_{self.entity_description.json_key}" + ) diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json index 2dd02f70ade838..0225262e9735d0 100644 --- a/homeassistant/components/solaredge/strings.json +++ b/homeassistant/components/solaredge/strings.json @@ -85,6 +85,18 @@ "batteries": { "name": "Batteries" }, + "battery_charge_energy": { + "name": "Charge energy today" + }, + "battery_discharge_energy": { + "name": "Discharge energy today" + }, + "battery_power": { + "name": "Power" + }, + "battery_state_of_charge": { + "name": "State of charge" + }, "consumption_energy": { "name": "Consumed energy" }, @@ -139,6 +151,12 @@ "solar_power": { "name": "Solar power" }, + "storage_charge_energy": { + "name": "Storage charge energy today" + }, + "storage_discharge_energy": { + "name": "Storage discharge energy today" + }, "storage_level": { "name": "Storage level" }, diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 5e21a39febcd23..982ccad2c92b08 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -2,7 +2,7 @@ import asyncio from datetime import datetime, timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -41,7 +41,7 @@ def enable_all_entities(entity_registry_enabled_by_default: None) -> None: @patch("homeassistant.components.solaredge.SolarEdge") async def test_solaredgeoverviewdataservice_energy_values_validity( - mock_solaredge, + mock_solaredge: MagicMock, recorder_mock: Recorder, hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/solaredge/test_sensor.py b/tests/components/solaredge/test_sensor.py new file mode 100644 index 00000000000000..0dd514d0e80ba9 --- /dev/null +++ b/tests/components/solaredge/test_sensor.py @@ -0,0 +1,503 @@ +"""Tests for the SolarEdge sensors.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.recorder import Recorder +from homeassistant.components.solaredge.const import ( + CONF_SITE_ID, + DEFAULT_NAME, + DOMAIN, + INVENTORY_UPDATE_DELAY, +) +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import API_KEY, SITE_ID + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.fixture +def mock_solaredge_api() -> AsyncMock: + """Return a mocked SolarEdge API with common defaults.""" + api = AsyncMock() + api.get_details = AsyncMock(return_value={"details": {"status": "active"}}) + api.get_overview = AsyncMock( + return_value={ + "overview": { + "lifeTimeData": {"energy": 100000}, + "lastYearData": {"energy": 50000}, + "lastMonthData": {"energy": 10000}, + "lastDayData": {"energy": 0.0}, + "currentPower": {"power": 0.0}, + } + } + ) + api.get_inventory = AsyncMock( + return_value={"Inventory": {"batteries": [{"SN": "BAT001"}]}} + ) + api.get_current_power_flow = AsyncMock( + return_value={ + "siteCurrentPowerFlow": { + "unit": "W", + "connections": [], + } + } + ) + api.get_energy_details = AsyncMock( + return_value={"energyDetails": {"unit": "Wh", "meters": []}} + ) + api.get_storage_data = AsyncMock(return_value=STORAGE_DATA_SINGLE_BATTERY) + return api + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a default mocked config entry for storage tests.""" + return MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={CONF_NAME: DEFAULT_NAME, CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY}, + ) + + +STORAGE_DATA_SINGLE_BATTERY = { + "storageData": { + "batteries": [ + { + "serialNumber": "BAT001", + "telemetries": [ + { + "timeStamp": "2025-01-01 00:00:00", + "lifeTimeEnergyCharged": 1000.0, + "lifeTimeEnergyDischarged": 500.0, + "batteryPercentageState": 50.0, + "power": 100.0, + }, + { + "timeStamp": "2025-01-01 12:00:00", + "lifeTimeEnergyCharged": 1500.0, + "lifeTimeEnergyDischarged": 800.0, + "batteryPercentageState": 75.0, + "power": 200.0, + }, + ], + } + ] + } +} + +STORAGE_DATA_MULTI_BATTERY = { + "storageData": { + "batteries": [ + { + "serialNumber": "BAT001", + "telemetries": [ + { + "timeStamp": "2025-01-01 00:00:00", + "lifeTimeEnergyCharged": 1000.0, + "lifeTimeEnergyDischarged": 500.0, + "batteryPercentageState": 50.0, + "power": 100.0, + }, + { + "timeStamp": "2025-01-01 12:00:00", + "lifeTimeEnergyCharged": 1500.0, + "lifeTimeEnergyDischarged": 800.0, + "batteryPercentageState": 75.0, + "power": 200.0, + }, + ], + }, + { + "serialNumber": "BAT002", + "telemetries": [ + { + "timeStamp": "2025-01-01 00:00:00", + "lifeTimeEnergyCharged": 2000.0, + "lifeTimeEnergyDischarged": 1000.0, + "batteryPercentageState": 40.0, + "power": 150.0, + }, + { + "timeStamp": "2025-01-01 12:00:00", + "lifeTimeEnergyCharged": 2700.0, + "lifeTimeEnergyDischarged": 1400.0, + "batteryPercentageState": 80.0, + "power": 250.0, + }, + ], + }, + ] + } +} + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_data_service( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage data service fetches battery charge/discharge energy.""" + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Aggregate sensors + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + discharge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) + assert charge_entry is not None + assert discharge_entry is not None + + state = hass.states.get(charge_entry) + assert state is not None + assert float(state.state) == 500.0 # 1500 - 1000 + + state = hass.states.get(discharge_entry) + assert state is not None + assert float(state.state) == 300.0 # 800 - 500 + + # Per-battery entities + bat_charge = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_charge_energy" + ) + bat_discharge = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_discharge_energy" + ) + bat_soc = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_state_of_charge" + ) + bat_power = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_power" + ) + assert bat_charge is not None + assert bat_discharge is not None + assert bat_soc is not None + assert bat_power is not None + + state = hass.states.get(bat_charge) + assert state is not None + assert float(state.state) == 500.0 + + state = hass.states.get(bat_discharge) + assert state is not None + assert float(state.state) == 300.0 + + state = hass.states.get(bat_soc) + assert state is not None + assert float(state.state) == 75.0 + + state = hass.states.get(bat_power) + assert state is not None + assert float(state.state) == 200.0 + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_data_service_multi_battery( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage data service aggregates data across multiple batteries.""" + mock_solaredge_api.get_inventory = AsyncMock( + return_value={"Inventory": {"batteries": [{"SN": "BAT001"}, {"SN": "BAT002"}]}} + ) + mock_solaredge_api.get_storage_data = AsyncMock( + return_value=STORAGE_DATA_MULTI_BATTERY + ) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + discharge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) + assert charge_entry is not None + assert discharge_entry is not None + + # BAT001: charge=500 (1500-1000), discharge=300 (800-500) + # BAT002: charge=700 (2700-2000), discharge=400 (1400-1000) + state = hass.states.get(charge_entry) + assert state is not None + assert float(state.state) == 1200.0 # 500 + 700 + + state = hass.states.get(discharge_entry) + assert state is not None + assert float(state.state) == 700.0 # 300 + 400 + + # Per-battery entities for BAT001 + bat1_soc = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_state_of_charge" + ) + assert bat1_soc is not None + state = hass.states.get(bat1_soc) + assert state is not None + assert float(state.state) == 75.0 + + # Per-battery entities for BAT002 + bat2_charge = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT002_battery_charge_energy" + ) + bat2_soc = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_BAT002_battery_state_of_charge" + ) + assert bat2_charge is not None + assert bat2_soc is not None + + state = hass.states.get(bat2_charge) + assert state is not None + assert float(state.state) == 700.0 + + state = hass.states.get(bat2_soc) + assert state is not None + assert float(state.state) == 80.0 + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_data_service_no_batteries( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage service is not created when no batteries in inventory.""" + mock_solaredge_api.get_inventory = AsyncMock( + return_value={"Inventory": {"batteries": []}} + ) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Sensors should not exist when inventory reports no batteries + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + discharge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) + assert charge_entry is None + assert discharge_entry is None + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_data_service_api_error( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage data service handles API errors gracefully.""" + mock_solaredge_api.get_storage_data = AsyncMock(side_effect=Exception("API error")) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + discharge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) + assert charge_entry is not None + assert discharge_entry is not None + + # Sensors should be unavailable when the API returns an error + state = hass.states.get(charge_entry) + assert state is not None + assert state.state == "unavailable" + + state = hass.states.get(discharge_entry) + assert state is not None + assert state.state == "unavailable" + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_data_missing_keys_in_response( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage service raises UpdateFailed when response is missing required keys.""" + # API returns a response but without the storageData key + mock_solaredge_api.get_storage_data = AsyncMock(return_value={"unexpected": {}}) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + discharge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) + assert charge_entry is not None + assert discharge_entry is not None + + # Sensors should be unavailable due to UpdateFailed from missing key + state = hass.states.get(charge_entry) + assert state is not None + assert state.state == "unavailable" + + state = hass.states.get(discharge_entry) + assert state is not None + assert state.state == "unavailable" + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_data_missing_batteries_key( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage service raises UpdateFailed when batteries key is missing.""" + # API returns storageData but without batteries key + mock_solaredge_api.get_storage_data = AsyncMock( + return_value={"storageData": {"otherField": "value"}} + ) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + assert charge_entry is not None + + state = hass.states.get(charge_entry) + assert state is not None + assert state.state == "unavailable" + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_service_deferred_after_inventory_failure( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage service is created after inventory recovers from failure.""" + # Initial inventory fetch fails + mock_solaredge_api.get_inventory = AsyncMock(side_effect=KeyError("Inventory")) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Storage sensors should not exist yet + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + assert charge_entry is None + + # Now inventory recovers and reports batteries + mock_solaredge_api.get_inventory = AsyncMock( + return_value={"Inventory": {"batteries": [{"SN": "BAT001"}]}} + ) + mock_solaredge_api.get_storage_data = AsyncMock( + return_value=STORAGE_DATA_SINGLE_BATTERY + ) + + # Trigger inventory coordinator refresh + freezer.tick(INVENTORY_UPDATE_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Storage sensors should now exist + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + discharge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) + assert charge_entry is not None + assert discharge_entry is not None + + +@patch("homeassistant.components.solaredge.SolarEdge") +async def test_storage_service_not_created_when_inventory_has_no_batteries( + mock_solaredge: MagicMock, + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_solaredge_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test storage service is not retried when inventory succeeds with no batteries.""" + # Initial inventory fails + mock_solaredge_api.get_inventory = AsyncMock(side_effect=KeyError("Inventory")) + mock_solaredge.return_value = mock_solaredge_api + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Inventory recovers but reports zero batteries + mock_solaredge_api.get_inventory = AsyncMock( + return_value={"Inventory": {"batteries": []}} + ) + + freezer.tick(INVENTORY_UPDATE_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Storage sensors should still not exist + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + assert charge_entry is None From f225d8162b99cad643d8059c8a65c251daffd5e3 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Sat, 25 Apr 2026 23:01:00 +0200 Subject: [PATCH 3/8] homematicip_cloud: migrate entity unique IDs to stable format (#166580) Co-authored-by: Christian Lackas <9592452+lackas@users.noreply.github.com> --- .../components/homematicip_cloud/__init__.py | 80 ++++-- .../homematicip_cloud/alarm_control_panel.py | 3 +- .../homematicip_cloud/binary_sensor.py | 64 +++-- .../components/homematicip_cloud/button.py | 6 +- .../components/homematicip_cloud/climate.py | 2 +- .../homematicip_cloud/config_flow.py | 2 +- .../components/homematicip_cloud/cover.py | 25 +- .../components/homematicip_cloud/entity.py | 12 +- .../components/homematicip_cloud/event.py | 1 + .../components/homematicip_cloud/light.py | 39 ++- .../components/homematicip_cloud/lock.py | 6 +- .../components/homematicip_cloud/migration.py | 233 ++++++++++++++++++ .../components/homematicip_cloud/sensor.py | 102 ++++++-- .../components/homematicip_cloud/siren.py | 9 +- .../components/homematicip_cloud/switch.py | 8 +- .../components/homematicip_cloud/valve.py | 7 +- .../components/homematicip_cloud/weather.py | 4 +- .../components/homematicip_cloud/test_init.py | 160 ++++++++++++ 18 files changed, 681 insertions(+), 82 deletions(-) create mode 100644 homeassistant/components/homematicip_cloud/migration.py diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 30038d1f8977ed..e3c242275429fc 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -1,5 +1,9 @@ """Support for HomematicIP Cloud devices.""" +from __future__ import annotations + +import logging + import voluptuous as vol from homeassistant import config_entries @@ -21,8 +25,11 @@ HMIPC_NAME, ) from .hap import HomematicIPConfigEntry, HomematicipHAP +from .migration import _migrate_unique_id from .services import async_setup_services +_LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = vol.Schema( { vol.Optional(DOMAIN, default=[]): vol.All( @@ -85,8 +92,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomematicIPConfigEntry) if not await hap.async_setup(): return False - _async_remove_obsolete_entities(hass, entry, hap) - # Register on HA stop event to gracefully shutdown HomematicIP Cloud connection hap.reset_connection_listener = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, hap.shutdown @@ -119,22 +124,61 @@ async def async_unload_entry( return await hap.async_reset() -@callback -def _async_remove_obsolete_entities( - hass: HomeAssistant, entry: HomematicIPConfigEntry, hap: HomematicipHAP -): - """Remove obsolete entities from entity registry.""" +async def async_migrate_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Migrate the config entry from version 1 to version 2.""" + if config_entry.version > 2: + return False + + if config_entry.version == 1: + _LOGGER.debug("Migrating HomematicIP Cloud config entry to version 2") + + # Remove obsolete entities before the bulk unique_id rewrite. + # After rewrite, old-format patterns would no longer be matchable. + # HomematicipAccesspointStatus* entities are always obsolete (removed + # in firmware 2.2.12+). HomematicipBatterySensor_{hapid} entities for + # access points are also obsolete. Those legacy access point battery + # entities do not belong to a device registry device, unlike real + # device battery sensors, so we can safely remove them before rewrite. + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + for entry in entries: + if entry.unique_id.startswith("HomematicipAccesspointStatus") or ( + entry.unique_id.startswith("HomematicipBatterySensor_") + and entry.device_id is None + ): + _LOGGER.debug( + "Removing obsolete entity: %s (%s)", + entry.entity_id, + entry.unique_id, + ) + entity_registry.async_remove(entry.entity_id) + + @callback + def _update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + new_unique_id = _migrate_unique_id(entity_entry.unique_id) + if new_unique_id is None: + _LOGGER.debug( + "Skipping unique_id %s (already stable format)", + entity_entry.unique_id, + ) + return None + _LOGGER.debug( + "Migrating %s: %s -> %s", + entity_entry.entity_id, + entity_entry.unique_id, + new_unique_id, + ) + return {"new_unique_id": new_unique_id} - if hap.home.currentAPVersion < "2.2.12": - return + await er.async_migrate_entries(hass, config_entry.entry_id, _update_unique_id) - entity_registry = er.async_get(hass) - er_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - for er_entry in er_entries: - if er_entry.unique_id.startswith("HomematicipAccesspointStatus"): - entity_registry.async_remove(er_entry.entity_id) - continue + hass.config_entries.async_update_entry(config_entry, version=2) + _LOGGER.info("Migration to version 2 successful") - for hapid in hap.home.accessPointUpdateStates: - if er_entry.unique_id == f"HomematicipBatterySensor_{hapid}": - entity_registry.async_remove(er_entry.entity_id) + return True diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index ddfe10fba54b89..1807405ffa00a0 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -42,6 +42,7 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY ) _attr_code_arm_required = False + _feature_id = "alarm" def __init__(self, hap: HomematicipHAP) -> None: """Initialize the alarm control panel.""" @@ -127,4 +128,4 @@ def available(self) -> bool: @property def unique_id(self) -> str: """Return a unique ID.""" - return f"{self.__class__.__name__}_{self._home.id}" + return f"{self._home.id}_{self._feature_id}" diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index d3b164209cebe5..7c14056e0fe469 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -179,7 +179,7 @@ class HomematicipCloudConnectionSensor(HomematicipGenericEntity, BinarySensorEnt def __init__(self, hap: HomematicipHAP) -> None: """Initialize the cloud connection sensor.""" - super().__init__(hap, hap.home) + super().__init__(hap, hap.home, feature_id="cloud_connection") @property def name(self) -> str: @@ -245,10 +245,18 @@ def extra_state_attributes(self) -> dict[str, Any]: class HomematicipAccelerationSensor(HomematicipBaseActionSensor): """Representation of the HomematicIP acceleration sensor.""" + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the acceleration sensor.""" + super().__init__(hap, device, feature_id="acceleration") + class HomematicipTiltVibrationSensor(HomematicipBaseActionSensor): """Representation of the HomematicIP tilt vibration sensor.""" + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the tilt vibration sensor.""" + super().__init__(hap, device, feature_id="tilt_vibration") + class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP multi room/area contact interface.""" @@ -262,6 +270,7 @@ def __init__( channel=1, is_multi_channel=True, channel_real_index=None, + feature_id: str = "contact", ) -> None: """Initialize the multi contact entity.""" super().__init__( @@ -270,6 +279,7 @@ def __init__( channel=channel, is_multi_channel=is_multi_channel, channel_real_index=channel_real_index, + feature_id=feature_id, ) @property @@ -286,7 +296,7 @@ class HomematicipContactInterface(HomematicipMultiContactInterface, BinarySensor def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the multi contact entity.""" - super().__init__(hap, device, is_multi_channel=False) + super().__init__(hap, device, is_multi_channel=False, feature_id="contact") class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEntity): @@ -298,7 +308,9 @@ def __init__( self, hap: HomematicipHAP, device, has_additional_state: bool = False ) -> None: """Initialize the shutter contact.""" - super().__init__(hap, device, is_multi_channel=False) + super().__init__( + hap, device, is_multi_channel=False, feature_id="shutter_contact" + ) self.has_additional_state = has_additional_state @property @@ -319,6 +331,10 @@ class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOTION + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the motion detector.""" + super().__init__(hap, device, feature_id="motion") + @property def is_on(self) -> bool: """Return true if motion is detected.""" @@ -334,7 +350,7 @@ class HomematicipFullFlushLockControllerLocked( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the full flush lock controller lock sensor.""" - super().__init__(hap, device, post="Locked") + super().__init__(hap, device, post="Locked", feature_id="lock_locked") @property def is_on(self) -> bool: @@ -359,7 +375,7 @@ class HomematicipFullFlushLockControllerGlassBreak( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the full flush lock controller glass break sensor.""" - super().__init__(hap, device, post="Glass break") + super().__init__(hap, device, post="Glass break", feature_id="glass_break") @property def is_on(self) -> bool: @@ -379,6 +395,10 @@ class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.PRESENCE + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the presence detector.""" + super().__init__(hap, device, feature_id="presence") + @property def is_on(self) -> bool: """Return true if presence is detected.""" @@ -390,6 +410,10 @@ class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.SMOKE + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the smoke detector.""" + super().__init__(hap, device, feature_id="smoke") + @property def is_on(self) -> bool: """Return true if smoke is detected.""" @@ -410,7 +434,9 @@ class HomematicipSmokeDetectorChamberDegraded( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize smoke detector chamber health sensor.""" - super().__init__(hap, device, post="Chamber Degraded") + super().__init__( + hap, device, post="Chamber Degraded", feature_id="chamber_degraded" + ) @property def is_on(self) -> bool: @@ -423,6 +449,10 @@ class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOISTURE + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the water detector.""" + super().__init__(hap, device, feature_id="water") + @property def is_on(self) -> bool: """Return true, if moisture or waterlevel is detected.""" @@ -434,7 +464,7 @@ class HomematicipStormSensor(HomematicipGenericEntity, BinarySensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize storm sensor.""" - super().__init__(hap, device, "Storm") + super().__init__(hap, device, "Storm", feature_id="storm") @property def icon(self) -> str: @@ -454,7 +484,7 @@ class HomematicipRainSensor(HomematicipGenericEntity, BinarySensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize rain sensor.""" - super().__init__(hap, device, "Raining") + super().__init__(hap, device, "Raining", feature_id="rain") @property def is_on(self) -> bool: @@ -469,7 +499,7 @@ class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize sunshine sensor.""" - super().__init__(hap, device, post="Sunshine") + super().__init__(hap, device, post="Sunshine", feature_id="sunshine") @property def is_on(self) -> bool: @@ -495,7 +525,7 @@ class HomematicipBatterySensor(HomematicipGenericEntity, BinarySensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize battery sensor.""" - super().__init__(hap, device, post="Battery") + super().__init__(hap, device, post="Battery", channel=0, feature_id="battery") @property def is_on(self) -> bool: @@ -512,7 +542,7 @@ class HomematicipPluggableMainsFailureSurveillanceSensor( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize pluggable mains failure surveillance sensor.""" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="mains_failure") @property def is_on(self) -> bool: @@ -525,10 +555,16 @@ class HomematicipSecurityZoneSensorGroup(HomematicipGenericEntity, BinarySensorE _attr_device_class = BinarySensorDeviceClass.SAFETY - def __init__(self, hap: HomematicipHAP, device, post: str = "SecurityZone") -> None: + def __init__( + self, + hap: HomematicipHAP, + device, + post: str = "SecurityZone", + feature_id: str = "security_zone", + ) -> None: """Initialize security zone group.""" device.modelType = f"HmIP-{post}" - super().__init__(hap, device, post=post) + super().__init__(hap, device, post=post, feature_id=feature_id) @property def available(self) -> bool: @@ -578,7 +614,7 @@ class HomematicipSecuritySensorGroup( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize security group.""" - super().__init__(hap, device, post="Sensors") + super().__init__(hap, device, post="Sensors", feature_id="security") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index bcd157d44d6be7..96ed3ae77e9984 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -45,7 +45,7 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize a wall mounted garage door controller.""" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="garage_button") self._attr_icon = "mdi:arrow-up-down" async def async_press(self) -> None: @@ -58,7 +58,9 @@ class HomematicipFullFlushLockControllerButton(HomematicipGenericEntity, ButtonE def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the full flush lock controller opener button.""" - super().__init__(hap, device, post="Door opener") + super().__init__( + hap, device, post="Door opener", feature_id="lock_opener_button" + ) self._attr_icon = "mdi:door-open" async def async_press(self) -> None: diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 689bce9243f4ba..881cf4878bb392 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -83,7 +83,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): def __init__(self, hap: HomematicipHAP, device: HeatingGroup) -> None: """Initialize heating group.""" device.modelType = "HmIP-Heating-Group" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="climate") self._simple_heating = None if device.actualTemperature is None: self._simple_heating = self._first_radiator_thermostat diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index 3a8614b99592e4..144770abfa6364 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -16,7 +16,7 @@ class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for the HomematicIP Cloud component.""" - VERSION = 1 + VERSION = 2 auth: HomematicipAuth diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index a8070c455d1aff..e926d2212c2808 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -69,6 +69,10 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): _attr_device_class = CoverDeviceClass.BLIND + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the blind module entity.""" + super().__init__(hap, device, feature_id="blind") + @property def current_cover_position(self) -> int | None: """Return current position of cover.""" @@ -153,10 +157,15 @@ def __init__( device, channel=1, is_multi_channel=True, + feature_id="shutter", ) -> None: """Initialize the multi cover entity.""" super().__init__( - hap, device, channel=channel, is_multi_channel=is_multi_channel + hap, + device, + channel=channel, + is_multi_channel=is_multi_channel, + feature_id=feature_id, ) @property @@ -218,7 +227,11 @@ def __init__( ) -> None: """Initialize the multi slats entity.""" super().__init__( - hap, device, channel=channel, is_multi_channel=is_multi_channel + hap, + device, + channel=channel, + is_multi_channel=is_multi_channel, + feature_id="slats", ) @property @@ -269,6 +282,10 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): _attr_device_class = CoverDeviceClass.GARAGE + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the garage door module entity.""" + super().__init__(hap, device, feature_id="garage_door") + @property def current_cover_position(self) -> int | None: """Return current position of cover.""" @@ -310,7 +327,9 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> None: """Initialize switching group.""" device.modelType = f"HmIP-{post}" - super().__init__(hap, device, post, is_multi_channel=False) + super().__init__( + hap, device, post, is_multi_channel=False, feature_id="shutter" + ) @property def available(self) -> bool: diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 81f2c7e8c7eb5f..e92b51f92ba4ab 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -86,6 +86,8 @@ def __init__( channel: int | None = None, is_multi_channel: bool | None = False, channel_real_index: int | None = None, + *, + feature_id: str, ) -> None: """Initialize the generic entity.""" self._hap = hap @@ -101,6 +103,7 @@ def __init__( # Using channel_real_index ensures you reference the correct channel. self._channel_real_index: int | None = channel_real_index + self._feature_id = feature_id self._is_multi_channel = is_multi_channel self.functional_channel = None with contextlib.suppress(ValueError): @@ -237,11 +240,10 @@ def available(self) -> bool: @property def unique_id(self) -> str: """Return a unique ID.""" - unique_id = f"{self.__class__.__name__}_{self._device.id}" - if self._is_multi_channel: - unique_id = f"{self.__class__.__name__}_Channel{self.get_channel_index()}_{self._device.id}" - - return unique_id + if not isinstance(self._device, Device): + return f"{self._device.id}_{self._feature_id}" + channel_index = self.get_channel_index() + return f"{self._device.id}_{channel_index}_{self._feature_id}" @property def icon(self) -> str | None: diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py index f98b078ab73614..a0502f72f54789 100644 --- a/homeassistant/components/homematicip_cloud/event.py +++ b/homeassistant/components/homematicip_cloud/event.py @@ -85,6 +85,7 @@ def __init__( post=description.key, channel=channel, is_multi_channel=False, + feature_id="doorbell", ) self.entity_description = description diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index 6affad00b3fcc9..e311c87904ba8d 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -126,7 +126,7 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the light entity.""" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="light") @property def is_on(self) -> bool: @@ -147,7 +147,13 @@ class HomematicipColorLight(HomematicipGenericEntity, LightEntity): def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> None: """Initialize the light entity.""" - super().__init__(hap, device, channel=channel_index, is_multi_channel=True) + super().__init__( + hap, + device, + channel=channel_index, + is_multi_channel=True, + feature_id="color_light", + ) def _supports_color(self) -> bool: """Return true if device supports hue/saturation color control.""" @@ -243,7 +249,11 @@ def __init__( ) -> None: """Initialize the dimmer light entity.""" super().__init__( - hap, device, channel=channel, is_multi_channel=is_multi_channel + hap, + device, + channel=channel, + is_multi_channel=is_multi_channel, + feature_id="dimmer", ) @property @@ -290,7 +300,14 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): def __init__(self, hap: HomematicipHAP, device, channel: int, post: str) -> None: """Initialize the notification light entity.""" - super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + super().__init__( + hap, + device, + post=post, + channel=channel, + is_multi_channel=True, + feature_id="notification_light", + ) self._color_switcher: dict[str, tuple[float, float]] = { RGBColorState.WHITE: (0.0, 0.0), @@ -335,11 +352,6 @@ def extra_state_attributes(self) -> dict[str, Any]: return state_attr - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self.__class__.__name__}_{self._post}_{self._device.id}" - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" # Use hs_color from kwargs, @@ -513,6 +525,7 @@ def __init__( channel=channel_index, is_multi_channel=True, channel_real_index=channel_index, + feature_id="optical_signal_light", ) @property @@ -614,7 +627,13 @@ def __init__( self, hap: HomematicipHAP, device: CombinationSignallingDevice ) -> None: """Initialize the combination signalling light entity.""" - super().__init__(hap, device, channel=1, is_multi_channel=False) + super().__init__( + hap, + device, + channel=1, + is_multi_channel=False, + feature_id="combination_signalling_light", + ) @property def _func_channel(self) -> NotificationMp3SoundChannel: diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py index bae075e1a17143..03f26c99a34597 100644 --- a/homeassistant/components/homematicip_cloud/lock.py +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import HomematicipGenericEntity -from .hap import HomematicIPConfigEntry +from .hap import HomematicIPConfigEntry, HomematicipHAP from .helpers import handle_errors _LOGGER = logging.getLogger(__name__) @@ -53,6 +53,10 @@ class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity): _attr_supported_features = LockEntityFeature.OPEN + def __init__(self, hap: HomematicipHAP, device: DoorLockDrive) -> None: + """Initialize the door lock drive.""" + super().__init__(hap, device, feature_id="lock") + @property def is_locked(self) -> bool | None: """Return true if device is locked.""" diff --git a/homeassistant/components/homematicip_cloud/migration.py b/homeassistant/components/homematicip_cloud/migration.py new file mode 100644 index 00000000000000..632a830e9597b5 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/migration.py @@ -0,0 +1,233 @@ +"""Unique ID migration for HomematicIP Cloud entities.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +import re + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _MigrationConfig: + """Configuration for migrating a single entity class to the new unique_id format.""" + + feature_id: str + channel: int | None = None + is_group: bool = False + + +UNIQUE_ID_MIGRATION_MAP: dict[str, _MigrationConfig] = { + # binary_sensor + "HomematicipCloudConnectionSensor": _MigrationConfig( + "cloud_connection", is_group=True + ), + "HomematicipAccelerationSensor": _MigrationConfig("acceleration", channel=1), + "HomematicipTiltVibrationSensor": _MigrationConfig("tilt_vibration", channel=1), + "HomematicipMultiContactInterface": _MigrationConfig("contact"), + "HomematicipContactInterface": _MigrationConfig("contact", channel=1), + "HomematicipShutterContact": _MigrationConfig("shutter_contact", channel=1), + "HomematicipMotionDetector": _MigrationConfig("motion", channel=1), + "HomematicipPresenceDetector": _MigrationConfig("presence", channel=1), + "HomematicipSmokeDetector": _MigrationConfig("smoke", channel=1), + "HomematicipWaterDetector": _MigrationConfig("water", channel=1), + "HomematicipStormSensor": _MigrationConfig("storm", channel=1), + "HomematicipRainSensor": _MigrationConfig("rain", channel=1), + "HomematicipSunshineSensor": _MigrationConfig("sunshine", channel=1), + "HomematicipBatterySensor": _MigrationConfig("battery", channel=0), + "HomematicipPluggableMainsFailureSurveillanceSensor": _MigrationConfig( + "mains_failure", channel=1 + ), + "HomematicipSecurityZoneSensorGroup": _MigrationConfig( + "security_zone", is_group=True + ), + "HomematicipSecuritySensorGroup": _MigrationConfig("security", is_group=True), + "HomematicipFullFlushLockControllerLocked": _MigrationConfig( + "lock_locked", channel=1 + ), + "HomematicipFullFlushLockControllerGlassBreak": _MigrationConfig( + "glass_break", channel=1 + ), + "HomematicipSmokeDetectorChamberDegraded": _MigrationConfig( + "chamber_degraded", channel=1 + ), + # sensor + "HomematicipAccesspointDutyCycle": _MigrationConfig("duty_cycle", channel=0), + "HomematicipHeatingThermostat": _MigrationConfig("valve_position", channel=1), + "HomematicipHumiditySensor": _MigrationConfig("humidity", channel=1), + "HomematicipTemperatureSensor": _MigrationConfig("temperature", channel=1), + "HomematicipAbsoluteHumiditySensor": _MigrationConfig( + "absolute_humidity", channel=1 + ), + "HomematicipIlluminanceSensor": _MigrationConfig("illuminance", channel=1), + "HomematicipPowerSensor": _MigrationConfig("power", channel=1), + "HomematicipEnergySensor": _MigrationConfig("energy", channel=1), + "HomematicipWindspeedSensor": _MigrationConfig("wind_speed", channel=1), + "HomematicipTodayRainSensor": _MigrationConfig("today_rain", channel=1), + "HomematicipPassageDetectorDeltaCounter": _MigrationConfig( + "passage_counter", channel=1 + ), + "HomematicipWaterFlowSensor": _MigrationConfig("water_flow"), + "HomematicipWaterVolumeSensor": _MigrationConfig("water_volume"), + "HomematicipWaterVolumeSinceOpenSensor": _MigrationConfig( + "water_volume_since_open" + ), + "HomematicipTiltAngleSensor": _MigrationConfig("tilt_angle", channel=1), + "HomematicipTiltStateSensor": _MigrationConfig("tilt_state", channel=1), + "HomematicipFloorTerminalBlockMechanicChannelValve": _MigrationConfig( + "ftb_valve_position" + ), + "HomematicpTemperatureExternalSensorCh1": _MigrationConfig( + "temperature_external_ch1", channel=1 + ), + "HomematicpTemperatureExternalSensorCh2": _MigrationConfig( + "temperature_external_ch2", channel=1 + ), + "HomematicpTemperatureExternalSensorDelta": _MigrationConfig( + "temperature_external_delta", channel=1 + ), + "HmipEsiIecPowerConsumption": _MigrationConfig("esi_iec_power", channel=1), + "HmipEsiIecEnergyCounterHighTariff": _MigrationConfig( + "esi_iec_energy_high", channel=1 + ), + "HmipEsiIecEnergyCounterLowTariff": _MigrationConfig( + "esi_iec_energy_low", channel=1 + ), + "HmipEsiIecEnergyCounterInputSingleTariff": _MigrationConfig( + "esi_iec_energy_input", channel=1 + ), + "HmipEsiGasCurrentGasFlow": _MigrationConfig("esi_gas_flow", channel=1), + "HmipEsiGasGasVolume": _MigrationConfig("esi_gas_volume", channel=1), + "HmipEsiLedCurrentPowerConsumption": _MigrationConfig("esi_led_power", channel=1), + "HmipEsiLedEnergyCounterHighTariff": _MigrationConfig( + "esi_led_energy_high", channel=1 + ), + "HomematicipSoilMoistureSensor": _MigrationConfig("soil_moisture", channel=1), + "HomematicipSoilTemperatureSensor": _MigrationConfig("soil_temperature", channel=1), + # light + "HomematicipLight": _MigrationConfig("light", channel=1), + "HomematicipLightHS": _MigrationConfig("light"), + "HomematicipLightMeasuring": _MigrationConfig("light", channel=1), + "HomematicipMultiDimmer": _MigrationConfig("dimmer"), + "HomematicipDimmer": _MigrationConfig("dimmer", channel=1), + "HomematicipNotificationLight": _MigrationConfig("notification_light"), + "HomematicipNotificationLightV2": _MigrationConfig("notification_light"), + "HomematicipColorLight": _MigrationConfig("color_light", channel=1), + "HomematicipOpticalSignalLight": _MigrationConfig( + "optical_signal_light", channel=1 + ), + "HomematicipCombinationSignallingLight": _MigrationConfig( + "combination_signalling_light", channel=1 + ), + # switch + "HomematicipMultiSwitch": _MigrationConfig("switch"), + "HomematicipSwitch": _MigrationConfig("switch", channel=1), + "HomematicipGroupSwitch": _MigrationConfig("switch", is_group=True), + "HomematicipSwitchMeasuring": _MigrationConfig("switch", channel=1), + # cover + "HomematicipBlindModule": _MigrationConfig("blind", channel=1), + "HomematicipMultiCoverShutter": _MigrationConfig("shutter"), + "HomematicipCoverShutter": _MigrationConfig("shutter", channel=1), + "HomematicipMultiCoverSlats": _MigrationConfig("slats"), + "HomematicipCoverSlats": _MigrationConfig("slats", channel=1), + "HomematicipGarageDoorModule": _MigrationConfig("garage_door", channel=1), + "HomematicipCoverShutterGroup": _MigrationConfig("shutter", is_group=True), + # climate + "HomematicipHeatingGroup": _MigrationConfig("climate", is_group=True), + # weather + "HomematicipWeatherSensor": _MigrationConfig("weather", channel=1), + "HomematicipWeatherSensorPro": _MigrationConfig("weather", channel=1), + "HomematicipHomeWeather": _MigrationConfig("home_weather", is_group=True), + # valve + "HomematicipWateringValve": _MigrationConfig("watering"), + # lock + "HomematicipDoorLockDrive": _MigrationConfig("lock", channel=1), + # button + "HomematicipGarageDoorControllerButton": _MigrationConfig( + "garage_button", channel=1 + ), + "HomematicipFullFlushLockControllerButton": _MigrationConfig( + "lock_opener_button", channel=1 + ), + # event + "HomematicipDoorBellEvent": _MigrationConfig("doorbell", channel=1), + # alarm_control_panel + "HomematicipAlarmControlPanelEntity": _MigrationConfig("alarm", is_group=True), + # siren + "HomematicipMP3Siren": _MigrationConfig("siren", channel=1), +} + +# Sorted by length descending so longer class names match before shorter ones +# (e.g., "HomematicipSwitchMeasuring" before "HomematicipSwitch") +_SORTED_CLASS_NAMES = sorted(UNIQUE_ID_MIGRATION_MAP, key=len, reverse=True) + +_CHANNEL_RE = re.compile(r"^Channel(\d+)_(.+)$") +_NOTIFICATION_LIGHT_RE = re.compile(r"^(Top|Bottom)_(.+)$") + +_NOTIFICATION_LIGHT_CHANNEL_MAP = {"Top": 2, "Bottom": 3} + + +def _migrate_unique_id(old_unique_id: str) -> str | None: + """Convert an old-format unique_id to the new format. + + Old formats: + {ClassName}_{device_id} + {ClassName}_Channel{N}_{device_id} + {ClassName}_{Top|Bottom}_{device_id} (NotificationLight only) + + New format: + {device_id}_{channel}_{feature_id} (device entities) + {device_id}_{feature_id} (group/home entities) + """ + # Find the matching class name (longest first) + matched_class: str | None = None + for class_name in _SORTED_CLASS_NAMES: + prefix = class_name + "_" + if old_unique_id.startswith(prefix): + matched_class = class_name + break + + if matched_class is None: + return None + + config = UNIQUE_ID_MIGRATION_MAP[matched_class] + remainder = old_unique_id[len(matched_class) + 1 :] + + # Parse remainder to extract channel and device_id + channel: int | None = None + device_id: str + + # Check for Channel{N}_{rest} pattern + channel_match = _CHANNEL_RE.match(remainder) + if channel_match: + channel = int(channel_match.group(1)) + device_id = channel_match.group(2) + elif matched_class in ( + "HomematicipNotificationLight", + "HomematicipNotificationLightV2", + ): + # Check for Top/Bottom pattern + notif_match = _NOTIFICATION_LIGHT_RE.match(remainder) + if notif_match: + channel = _NOTIFICATION_LIGHT_CHANNEL_MAP[notif_match.group(1)] + device_id = notif_match.group(2) + else: + device_id = remainder + channel = config.channel + else: + device_id = remainder + channel = config.channel + + # Build new unique_id + if config.is_group: + return f"{device_id}_{config.feature_id}" + + if channel is not None: + return f"{device_id}_{channel}_{config.feature_id}" + + _LOGGER.warning( + "Cannot determine channel for unique_id: %s", + old_unique_id, + ) + return None diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 211dddd881147c..f941616bc22e55 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -383,7 +383,14 @@ def __init__( self, hap: HomematicipHAP, device: Device, channel: int, post: str ) -> None: """Initialize the watering flow sensor device.""" - super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + super().__init__( + hap, + device, + post=post, + channel=channel, + is_multi_channel=True, + feature_id="water_flow", + ) @property def native_value(self) -> float | None: @@ -405,9 +412,17 @@ def __init__( channel: int, post: str, attribute: str, + feature_id: str = "water_volume", ) -> None: """Initialize the watering volume sensor device.""" - super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + super().__init__( + hap, + device, + post=post, + channel=channel, + is_multi_channel=True, + feature_id=feature_id, + ) self._attribute_name = attribute @property @@ -430,6 +445,7 @@ def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: channel=channel, post="waterVolumeSinceOpen", attribute="waterVolumeSinceOpen", + feature_id="water_volume_since_open", ) @@ -441,7 +457,7 @@ class HomematicipTiltAngleSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the tilt angle sensor device.""" - super().__init__(hap, device, post="Tilt Angle") + super().__init__(hap, device, post="Tilt Angle", feature_id="tilt_angle") @property def native_value(self) -> int | None: @@ -458,7 +474,7 @@ class HomematicipTiltStateSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the tilt sensor device.""" - super().__init__(hap, device, post="Tilt State") + super().__init__(hap, device, post="Tilt State", feature_id="tilt_state") @property def native_value(self) -> str | None: @@ -502,6 +518,7 @@ def __init__( channel=channel, is_multi_channel=is_multi_channel, post="Valve Position", + feature_id="ftb_valve_position", ) @property @@ -540,7 +557,9 @@ class HomematicipAccesspointDutyCycle(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize access point status entity.""" - super().__init__(hap, device, post="Duty Cycle") + super().__init__( + hap, device, post="Duty Cycle", channel=0, feature_id="duty_cycle" + ) @property def native_value(self) -> float: @@ -555,7 +574,7 @@ class HomematicipHeatingThermostat(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize heating thermostat device.""" - super().__init__(hap, device, post="Heating") + super().__init__(hap, device, post="Heating", feature_id="valve_position") @property def icon(self) -> str | None: @@ -583,7 +602,7 @@ class HomematicipHumiditySensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the thermometer device.""" - super().__init__(hap, device, post="Humidity") + super().__init__(hap, device, post="Humidity", feature_id="humidity") @property def native_value(self) -> int: @@ -600,7 +619,7 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the thermometer device.""" - super().__init__(hap, device, post="Temperature") + super().__init__(hap, device, post="Temperature", feature_id="temperature") @property def native_value(self) -> float: @@ -633,7 +652,9 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the thermometer device.""" - super().__init__(hap, device, post="Absolute Humidity") + super().__init__( + hap, device, post="Absolute Humidity", feature_id="absolute_humidity" + ) @property def native_value(self) -> float | None: @@ -654,7 +675,7 @@ class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Illuminance") + super().__init__(hap, device, post="Illuminance", feature_id="illuminance") @property def native_value(self) -> float: @@ -685,7 +706,7 @@ class HomematicipPowerSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Power") + super().__init__(hap, device, post="Power", feature_id="power") @property def native_value(self) -> float: @@ -702,7 +723,7 @@ class HomematicipEnergySensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Energy") + super().__init__(hap, device, post="Energy", feature_id="energy") @property def native_value(self) -> float: @@ -719,7 +740,7 @@ class HomematicipWindspeedSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the windspeed sensor.""" - super().__init__(hap, device, post="Windspeed") + super().__init__(hap, device, post="Windspeed", feature_id="wind_speed") @property def native_value(self) -> float: @@ -751,7 +772,7 @@ class HomematicipTodayRainSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Today Rain") + super().__init__(hap, device, post="Today Rain", feature_id="today_rain") @property def native_value(self) -> float: @@ -768,7 +789,12 @@ class HomematicpTemperatureExternalSensorCh1(HomematicipGenericEntity, SensorEnt def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Channel 1 Temperature") + super().__init__( + hap, + device, + post="Channel 1 Temperature", + feature_id="temperature_external_ch1", + ) @property def native_value(self) -> float: @@ -785,7 +811,12 @@ class HomematicpTemperatureExternalSensorCh2(HomematicipGenericEntity, SensorEnt def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Channel 2 Temperature") + super().__init__( + hap, + device, + post="Channel 2 Temperature", + feature_id="temperature_external_ch2", + ) @property def native_value(self) -> float: @@ -802,7 +833,12 @@ class HomematicpTemperatureExternalSensorDelta(HomematicipGenericEntity, SensorE def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(hap, device, post="Delta Temperature") + super().__init__( + hap, + device, + post="Delta Temperature", + feature_id="temperature_external_delta", + ) @property def native_value(self) -> float: @@ -820,6 +856,7 @@ def __init__( key: str, value_fn: Callable[[FunctionalChannel], StateType], type_fn: Callable[[FunctionalChannel], str], + feature_id: str, ) -> None: """Initialize Sensor Entity.""" super().__init__( @@ -828,6 +865,7 @@ def __init__( channel=1, post=key, is_multi_channel=False, + feature_id=feature_id, ) self._value_fn = value_fn @@ -862,6 +900,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key="CurrentPowerConsumption", value_fn=lambda channel: channel.currentPowerConsumption, type_fn=lambda channel: "CurrentPowerConsumption", + feature_id="esi_iec_power", ) @@ -880,6 +919,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key=ESI_TYPE_ENERGY_COUNTER_USAGE_HIGH_TARIFF, value_fn=lambda channel: channel.energyCounterOne, type_fn=lambda channel: channel.energyCounterOneType, + feature_id="esi_iec_energy_high", ) @@ -898,6 +938,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key=ESI_TYPE_ENERGY_COUNTER_USAGE_LOW_TARIFF, value_fn=lambda channel: channel.energyCounterTwo, type_fn=lambda channel: channel.energyCounterTwoType, + feature_id="esi_iec_energy_low", ) @@ -916,6 +957,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key=ESI_TYPE_ENERGY_COUNTER_INPUT_SINGLE_TARIFF, value_fn=lambda channel: channel.energyCounterThree, type_fn=lambda channel: channel.energyCounterThreeType, + feature_id="esi_iec_energy_input", ) @@ -934,6 +976,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key="CurrentGasFlow", value_fn=lambda channel: channel.currentGasFlow, type_fn=lambda channel: "CurrentGasFlow", + feature_id="esi_gas_flow", ) @@ -952,6 +995,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key="GasVolume", value_fn=lambda channel: channel.gasVolume, type_fn=lambda channel: "GasVolume", + feature_id="esi_gas_volume", ) @@ -970,6 +1014,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key="CurrentPowerConsumption", value_fn=lambda channel: channel.currentPowerConsumption, type_fn=lambda channel: "CurrentPowerConsumption", + feature_id="esi_led_power", ) @@ -988,12 +1033,17 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key=ESI_TYPE_ENERGY_COUNTER_USAGE_HIGH_TARIFF, value_fn=lambda channel: channel.energyCounterOne, type_fn=lambda channel: ESI_TYPE_ENERGY_COUNTER_USAGE_HIGH_TARIFF, + feature_id="esi_led_energy_high", ) class HomematicipPassageDetectorDeltaCounter(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP passage detector delta counter.""" + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the passage detector delta counter.""" + super().__init__(hap, device, feature_id="passage_counter") + @property def native_value(self) -> int: """Return the passage detector delta counter value.""" @@ -1022,7 +1072,9 @@ def __init__( description: HmipSmokeDetectorSensorDescription, ) -> None: """Initialize the smoke detector sensor.""" - super().__init__(hap, device, post=description.key) + super().__init__( + hap, device, post=description.key, feature_id="smoke_detector_sensor" + ) self.entity_description = description self._sensor_unique_id = f"{device.id}_{description.key}" @@ -1047,7 +1099,12 @@ class HomematicipSoilMoistureSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the soil moisture sensor device.""" super().__init__( - hap, device, post="Soil Moisture", channel=1, is_multi_channel=True + hap, + device, + post="Soil Moisture", + channel=1, + is_multi_channel=True, + feature_id="soil_moisture", ) @property @@ -1068,7 +1125,12 @@ class HomematicipSoilTemperatureSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the soil temperature sensor device.""" super().__init__( - hap, device, post="Soil Temperature", channel=1, is_multi_channel=True + hap, + device, + post="Soil Temperature", + channel=1, + is_multi_channel=True, + feature_id="soil_temperature", ) @property diff --git a/homeassistant/components/homematicip_cloud/siren.py b/homeassistant/components/homematicip_cloud/siren.py index 5fb4d73a27b35b..d7ee8c577e1569 100644 --- a/homeassistant/components/homematicip_cloud/siren.py +++ b/homeassistant/components/homematicip_cloud/siren.py @@ -60,7 +60,14 @@ def __init__( self, hap: HomematicipHAP, device: CombinationSignallingDevice ) -> None: """Initialize the siren entity.""" - super().__init__(hap, device, post="Siren", channel=1, is_multi_channel=False) + super().__init__( + hap, + device, + post="Siren", + channel=1, + is_multi_channel=False, + feature_id="siren", + ) @property def _func_channel(self) -> NotificationMp3SoundChannel: diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 59216c904a4977..8ec993124c5af2 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -109,7 +109,11 @@ def __init__( ) -> None: """Initialize the multi switch device.""" super().__init__( - hap, device, channel=channel, is_multi_channel=is_multi_channel + hap, + device, + channel=channel, + is_multi_channel=is_multi_channel, + feature_id="switch", ) @property @@ -143,7 +147,7 @@ class HomematicipGroupSwitch(HomematicipGenericEntity, SwitchEntity): def __init__(self, hap: HomematicipHAP, device, post: str = "Group") -> None: """Initialize switching group.""" device.modelType = f"HmIP-{post}" - super().__init__(hap, device, post) + super().__init__(hap, device, post, feature_id="switch") @property def is_on(self) -> bool: diff --git a/homeassistant/components/homematicip_cloud/valve.py b/homeassistant/components/homematicip_cloud/valve.py index a97ec157d170de..d759b7cf242fba 100644 --- a/homeassistant/components/homematicip_cloud/valve.py +++ b/homeassistant/components/homematicip_cloud/valve.py @@ -42,7 +42,12 @@ class HomematicipWateringValve(HomematicipGenericEntity, ValveEntity): def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: """Initialize the valve.""" super().__init__( - hap, device=device, channel=channel, post="watering", is_multi_channel=True + hap, + device=device, + channel=channel, + post="watering", + is_multi_channel=True, + feature_id="watering", ) async def async_open_valve(self) -> None: diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index 061f6642bb221d..623491c0e46635 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -72,7 +72,7 @@ class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the weather sensor.""" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="weather") @property def name(self) -> str: @@ -125,7 +125,7 @@ class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity): def __init__(self, hap: HomematicipHAP) -> None: """Initialize the home weather.""" hap.home.modelType = "HmIP-Home-Weather" - super().__init__(hap, hap.home) + super().__init__(hap, hap.home, feature_id="home_weather") @property def available(self) -> bool: diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py index 5fd93badc9dace..e532eaaada4d75 100644 --- a/tests/components/homematicip_cloud/test_init.py +++ b/tests/components/homematicip_cloud/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, Mock, patch from homematicip.exceptions.connection_exceptions import HmipConnectionError +import pytest from homeassistant.components.homematicip_cloud.const import ( CONF_ACCESSPOINT, @@ -16,6 +17,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -202,3 +204,161 @@ async def test_setup_services(hass: HomeAssistant) -> None: assert len(config_entries) == 1 await hass.config_entries.async_unload(config_entries[0].entry_id) + + +# --- Unique ID migration tests --- + + +@pytest.fixture +def mock_config_entry_v1(hass: HomeAssistant) -> MockConfigEntry: + """Create a v1 config entry for migration testing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={HMIPC_HAPID: "ABC123", HMIPC_AUTHTOKEN: "token", HMIPC_NAME: ""}, + version=1, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.mark.parametrize( + ("platform", "old_unique_id", "new_unique_id"), + [ + ( + "binary_sensor", + "HomematicipMotionDetector_3014F711ABCD", + "3014F711ABCD_1_motion", + ), + ( + "switch", + "HomematicipMultiSwitch_Channel3_3014F711ABCD", + "3014F711ABCD_3_switch", + ), + ( + "light", + "HomematicipNotificationLight_Top_3014F711ABCD", + "3014F711ABCD_2_notification_light", + ), + ("climate", "HomematicipHeatingGroup_UUID-GROUP-123", "UUID-GROUP-123_climate"), + ], + ids=["single_channel", "multi_channel", "notification_light", "group"], +) +async def test_migrate_unique_id( + hass: HomeAssistant, + mock_config_entry_v1: MockConfigEntry, + entity_registry: er.EntityRegistry, + platform: str, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test unique_id migration for different entity types.""" + entity_registry.async_get_or_create( + platform, + DOMAIN, + old_unique_id, + config_entry=mock_config_entry_v1, + ) + + with patch("homeassistant.components.homematicip_cloud.HomematicipHAP") as mock_hap: + instance = mock_hap.return_value + instance.async_setup = AsyncMock(return_value=True) + instance.home.id = "1" + instance.home.modelType = "mock-type" + instance.home.name = "mock-name" + instance.home.label = "mock-label" + instance.home.currentAPVersion = "mock-ap-version" + instance.async_reset = AsyncMock(return_value=True) + + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v1.version == 2 + assert entity_registry.async_get_entity_id(platform, DOMAIN, new_unique_id) + + +async def test_migrate_stable_unique_id_skipped( + hass: HomeAssistant, + mock_config_entry_v1: MockConfigEntry, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a non-class-name unique_id is silently skipped and preserved.""" + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "HomematicipFutureEntity_3014F711ABCD", + config_entry=mock_config_entry_v1, + ) + + with patch("homeassistant.components.homematicip_cloud.HomematicipHAP") as mock_hap: + instance = mock_hap.return_value + instance.async_setup = AsyncMock(return_value=True) + instance.home.id = "1" + instance.home.modelType = "mock-type" + instance.home.name = "mock-name" + instance.home.label = "mock-label" + instance.home.currentAPVersion = "mock-ap-version" + instance.async_reset = AsyncMock(return_value=True) + + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v1.version == 2 + # Unknown prefix is not a known class name, so it's treated as already + # stable and skipped silently (no warning, just debug). + assert "already stable format" in caplog.text + # Old unique_id is preserved (not migrated) + assert entity_registry.async_get_entity_id( + "sensor", DOMAIN, "HomematicipFutureEntity_3014F711ABCD" + ) + + +async def test_migrate_battery_and_obsolete_access_point( + hass: HomeAssistant, + mock_config_entry_v1: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test battery migration and obsolete access point entity removal.""" + + # Obsolete access point battery entity: legacy unique_id, no linked device. + obsolete_entity_id = entity_registry.async_get_or_create( + "binary_sensor", + DOMAIN, + "HomematicipBatterySensor_ABC123", + config_entry=mock_config_entry_v1, + ).entity_id + + # Real device battery entity: same legacy class prefix, but attached to a device. + device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_v1.entry_id, + identifiers={(DOMAIN, "3014F711ABCD")}, + ) + entity_registry.async_get_or_create( + "binary_sensor", + DOMAIN, + "HomematicipBatterySensor_3014F711ABCD", + config_entry=mock_config_entry_v1, + device_id=device_entry.id, + ) + + with patch("homeassistant.components.homematicip_cloud.HomematicipHAP") as mock_hap: + instance = mock_hap.return_value + instance.async_setup = AsyncMock(return_value=True) + instance.home.id = "1" + instance.home.modelType = "mock-type" + instance.home.name = "mock-name" + instance.home.label = "mock-label" + instance.home.currentAPVersion = "mock-ap-version" + instance.async_reset = AsyncMock(return_value=True) + + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v1.version == 2 + # Obsolete access point battery entity removed + assert entity_registry.async_get(obsolete_entity_id) is None + # Real device battery entity migrated + assert entity_registry.async_get_entity_id( + "binary_sensor", DOMAIN, "3014F711ABCD_0_battery" + ) From 19ebb1da2ae6fb07a9e43508f2b9937b9be94339 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 25 Apr 2026 23:05:44 +0200 Subject: [PATCH 4/8] Update knx-frontend to 2026.4.25.155016: Add notes to UI expose (#169154) --- homeassistant/components/knx/manifest.json | 2 +- .../components/knx/storage/config_store.py | 20 ++++--- .../knx/storage/expose_controller.py | 30 +++++++---- homeassistant/components/knx/strings.json | 4 ++ homeassistant/components/knx/websocket.py | 6 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../knx/fixtures/config_store_expose.json | 31 +++++++++++ .../knx/snapshots/test_websocket.ambr | 40 ++++++++++++++ tests/components/knx/test_config_store.py | 14 ++--- tests/components/knx/test_expose.py | 52 ++++++++++++------- tests/components/knx/test_websocket.py | 37 +++++++++++++ 12 files changed, 191 insertions(+), 49 deletions(-) create mode 100644 tests/components/knx/fixtures/config_store_expose.json diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 3946c5ee12f101..7bfaf0d831df09 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.15.0", "xknxproject==3.8.2", - "knx-frontend==2026.4.22.141111" + "knx-frontend==2026.4.25.155016" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 3df90ed45fd417..7a51b01d9c9f61 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -14,7 +14,7 @@ from ..const import DOMAIN, KNX_MODULE_KEY from . import migration from .const import CONF_DATA -from .expose_controller import KNXExposeStoreModel, KNXExposeStoreOptionModel +from .expose_controller import KNXExposeStoreConfigModel, KNXExposeStoreModel from .time_server import KNXTimeServerStoreModel _LOGGER = logging.getLogger(__name__) @@ -201,20 +201,26 @@ def get_exposes(self) -> KNXExposeStoreModel: def get_expose_groups(self) -> dict[str, list[str]]: """Return KNX entity state exposes and their group addresses.""" return { - entity_id: [option["ga"]["write"] for option in config] + entity_id: [option["ga"]["write"] for option in config["options"]] for entity_id, config in self.data["expose"].items() } - def get_expose_config(self, entity_id: str) -> list[KNXExposeStoreOptionModel]: - """Return KNX entity state expose configuration for an entity.""" - return self.data["expose"].get(entity_id, []) + def get_expose_config(self, entity_id: str) -> KNXExposeStoreConfigModel: + """Return KNX entity state expose configuration and notes for an entity.""" + return self.data["expose"].get(entity_id, KNXExposeStoreConfigModel(options=[])) async def update_expose( - self, entity_id: str, expose_config: list[KNXExposeStoreOptionModel] + self, entity_id: str, expose_config: KNXExposeStoreConfigModel ) -> None: - """Update KNX expose configuration for an entity.""" + """Update KNX expose configuration for an entity. + + Args: + entity_id: The entity ID to configure. + expose_config: Expose configuration with options and optional notes. + """ knx_module = self.hass.data[KNX_MODULE_KEY] expose_controller = knx_module.ui_expose_controller + expose_controller.update_entity_expose( self.hass, knx_module.xknx, entity_id, expose_config ) diff --git a/homeassistant/components/knx/storage/expose_controller.py b/homeassistant/components/knx/storage/expose_controller.py index cd67cdd44f60dc..524ceabaab09c8 100644 --- a/homeassistant/components/knx/storage/expose_controller.py +++ b/homeassistant/components/knx/storage/expose_controller.py @@ -7,7 +7,6 @@ from xknx.dpt import DPTBase from xknx.telegram.address import parse_device_group_address -from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, @@ -19,10 +18,6 @@ from .entity_store_validation import validate_config_store_data from .knx_selector import GASelector -type KNXExposeStoreModel = dict[ - str, list[KNXExposeStoreOptionModel] # entity_id: configuration -] - class KNXExposeStoreOptionModel(TypedDict): """Represent KNX entity state expose configuration for an entity.""" @@ -36,11 +31,21 @@ class KNXExposeStoreOptionModel(TypedDict): value_template: NotRequired[str] +class KNXExposeStoreConfigModel(TypedDict): + """Represent stored KNX expose configuration with metadata.""" + + options: list[KNXExposeStoreOptionModel] + notes: NotRequired[str] + + +type KNXExposeStoreModel = dict[str, KNXExposeStoreConfigModel] # dict[entity_id: conf] + + class KNXExposeDataModel(TypedDict): """Represent a loaded KNX expose config for validation.""" entity_id: str - options: list[KNXExposeStoreOptionModel] + data: KNXExposeStoreConfigModel def validate_expose_template_no_coerce(value: str) -> str: @@ -72,8 +77,13 @@ def validate_expose_template_no_coerce(value: str) -> str: EXPOSE_CONFIG_SCHEMA = vol.Schema( { - vol.Required(CONF_ENTITY_ID): selector.EntitySelector(), - vol.Required("options"): [EXPOSE_OPTION_SCHEMA], + vol.Required("entity_id"): selector.EntitySelector(), + vol.Required("data"): vol.Schema( + { + vol.Required("options"): [EXPOSE_OPTION_SCHEMA], + vol.Optional("notes"): str, + } + ), }, extra=vol.REMOVE_EXTRA, ) @@ -135,13 +145,13 @@ def update_entity_expose( hass: HomeAssistant, xknx: XKNX, entity_id: str, - expose_config: list[KNXExposeStoreOptionModel], + expose_config: KNXExposeStoreConfigModel, ) -> None: """Update entity expose configuration for an entity.""" self.remove_entity_expose(entity_id) expose_options = [ - _store_to_expose_option(hass, config) for config in expose_config + _store_to_expose_option(hass, config) for config in expose_config["options"] ] expose = KnxExposeEntity(hass, xknx, entity_id, expose_options) self._entity_exposes[entity_id] = expose diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index f56dd3db254fac..3abb766c958e62 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -978,6 +978,10 @@ "ga": { "label": "[%key:component::knx::config_panel::common::group_address%]" }, + "notes": { + "label": "Notes", + "placeholder": "Add your notes here..." + }, "periodic_send": { "description": "Time interval to automatically resend the current value to the KNX bus, even if it hasn’t changed.", "label": "Periodic send interval" diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 9ad7e8023b4dab..c48aab486c07d0 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -644,7 +644,7 @@ def ws_get_expose_config( { vol.Required("type"): "knx/update_expose", vol.Required("entity_id"): str, - vol.Required("options"): list, # validation done in handler + vol.Required("data"): dict, # validation done in handler } ) @websocket_api.async_response @@ -663,7 +663,7 @@ async def ws_update_expose( return try: await knx.config_store.update_expose( - validated_data["entity_id"], validated_data["options"] + validated_data["entity_id"], validated_data["data"] ) except ConfigStoreException as err: connection.send_error( @@ -706,7 +706,7 @@ async def ws_delete_expose( { vol.Required("type"): "knx/validate_expose", vol.Required("entity_id"): str, - vol.Required("options"): list, # validation done in handler + vol.Required("data"): dict, # validation done in handler } ) @callback diff --git a/requirements_all.txt b/requirements_all.txt index a17214d75e641c..1889db0d43a228 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1402,7 +1402,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2026.4.22.141111 +knx-frontend==2026.4.25.155016 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ad956fb564e02a..8f9154f94e07b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1242,7 +1242,7 @@ kiosker-python-api==1.2.9 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2026.4.22.141111 +knx-frontend==2026.4.25.155016 # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/knx/fixtures/config_store_expose.json b/tests/components/knx/fixtures/config_store_expose.json new file mode 100644 index 00000000000000..478d12dede3812 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_expose.json @@ -0,0 +1,31 @@ +{ + "version": 2, + "minor_version": 4, + "key": "knx/config_store.json", + "data": { + "entities": {}, + "expose": { + "cover.test": { + "options": [ + { + "ga": { + "write": "1/1/1", + "dpt": "1.001" + } + }, + { + "ga": { + "write": "2/2/2", + "dpt": "5.001" + }, + "attribute": "current_position", + "value_template": "{{ 100 - value }}", + "cooldown": 5.0 + } + ], + "notes": "Invert cover position for KNX uses 0: open" + } + }, + "time_server": {} + } +} diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index 49299a9537a472..ee0ebce81ab227 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -1,4 +1,44 @@ # serializer version: 1 +# name: test_knx_get_expose_config + dict({ + 'id': 1, + 'result': dict({ + 'notes': 'Invert cover position for KNX uses 0: open', + 'options': list([ + dict({ + 'ga': dict({ + 'dpt': '1.001', + 'write': '1/1/1', + }), + }), + dict({ + 'attribute': 'current_position', + 'cooldown': 5.0, + 'ga': dict({ + 'dpt': '5.001', + 'write': '2/2/2', + }), + 'value_template': '{{ 100 - value }}', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_knx_get_expose_groups + dict({ + 'id': 1, + 'result': dict({ + 'cover.test': list([ + '1/1/1', + '2/2/2', + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_knx_get_schema[binary_sensor] dict({ 'id': 1, diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 75d9165ce8e6bc..891ccd5c766741 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -468,13 +468,13 @@ async def test_update_expose_error( { "type": "knx/update_expose", "entity_id": "switch.test", - "options": [{"ga": {"dpt": "1.001"}}], + "data": {"options": [{"ga": {"dpt": "1.001"}}]}, } ) res = await client.receive_json() assert res["success"], res assert res["result"]["success"] is False - assert res["result"]["errors"][0]["path"] == ["options", "0", "ga", "write"] + assert res["result"]["errors"][0]["path"] == ["data", "options", "0", "ga", "write"] assert res["result"]["errors"][0]["error_message"] == "required key not provided" @@ -491,7 +491,7 @@ async def test_validate_expose( { "type": "knx/validate_expose", "entity_id": "switch.test", - "options": [{"ga": {"write": "1/2/3", "dpt": "1.001"}}], + "data": {"options": [{"ga": {"write": "1/2/3", "dpt": "1.001"}}]}, } ) res = await client.receive_json() @@ -502,13 +502,13 @@ async def test_validate_expose( { "type": "knx/validate_expose", "entity_id": "switch.test", - "options": [{"ga": {"write": "1/2/3", "dpt": "invalid"}}], + "data": {"options": [{"ga": {"write": "1/2/3", "dpt": "invalid"}}]}, } ) res = await client.receive_json() assert res["success"], res assert res["result"]["success"] is False - assert res["result"]["errors"][0]["path"] == ["options", "0", "ga", "dpt"] + assert res["result"]["errors"][0]["path"] == ["data", "options", "0", "ga", "dpt"] async def test_delete_expose( @@ -523,13 +523,13 @@ async def test_delete_expose( await knx.setup_integration() client = await hass_ws_client(hass) - expose_options = [{"ga": {"write": "2/2/2", "dpt": "1.001"}}] + expose_options = {"options": [{"ga": {"write": "2/2/2", "dpt": "1.001"}}]} await client.send_json_auto_id( { "type": "knx/update_expose", "entity_id": ENTITY_ID, - "options": expose_options, + "data": expose_options, } ) res = await client.receive_json() diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index f0b2459349c149..3e28af8b6a36e3 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -401,7 +401,16 @@ async def test_ui_expose_create_and_update( { "type": "knx/update_expose", "entity_id": ENTITY_ID, - "options": [{"ga": {"write": GROUP_ADDRESS_1, "dpt": "1.001"}}], + "data": { + "options": [ + { + "ga": { + "write": GROUP_ADDRESS_1, + "dpt": "1.001", + } + } + ], + }, } ) res = await ws_client.receive_json() @@ -418,13 +427,16 @@ async def test_ui_expose_create_and_update( { "type": "knx/update_expose", "entity_id": ENTITY_ID, - "options": [ - {"ga": {"write": GROUP_ADDRESS_1, "dpt": "1.001"}}, - { - "ga": {"write": GROUP_ADDRESS_2, "dpt": "5.001"}, - "attribute": "brightness", - }, - ], + "data": { + "options": [ + {"ga": {"write": GROUP_ADDRESS_1, "dpt": "1.001"}}, + { + "ga": {"write": GROUP_ADDRESS_2, "dpt": "5.001"}, + "attribute": "brightness", + }, + ], + "notes": "This is a note", + }, } ) res = await ws_client.receive_json() @@ -455,17 +467,19 @@ async def test_ui_expose_with_options( { "type": "knx/update_expose", "entity_id": ENTITY_ID, - "options": [ - { - "ga": {"write": GROUP_ADDRESS_1, "dpt": "5.010"}, - "attribute": "brightness", - "cooldown": 2.5, - "default": 0, - "periodic_send": 60.0, - "respond_to_read": False, - "value_template": "{{ 50 if value >= 50 else 1 }}", - } - ], + "data": { + "options": [ + { + "ga": {"write": GROUP_ADDRESS_1, "dpt": "5.010"}, + "attribute": "brightness", + "cooldown": 2.5, + "default": 0, + "periodic_send": 60.0, + "respond_to_read": False, + "value_template": "{{ 50 if value >= 50 else 1 }}", + } + ], + }, } ) res = await ws_client.receive_json() diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 5bdcfc989dbd0f..372ff446f7cbf8 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -442,6 +442,43 @@ async def test_knx_get_schema( assert res == snapshot +async def test_knx_get_expose_groups( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test knx/get_expose_groups command returning proper expose groups data.""" + await knx.setup_integration( + config_store_fixture="config_store_expose.json", + ) + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "knx/get_expose_groups"}) + res = await client.receive_json() + assert res == snapshot + + +async def test_knx_get_expose_config( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test knx/get_expose_config command returning proper expose config data.""" + await knx.setup_integration( + config_store_fixture="config_store_expose.json", + ) + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "knx/get_expose_config", + "entity_id": "cover.test", + } + ) + res = await client.receive_json() + assert res == snapshot + + @pytest.mark.parametrize( "endpoint", [ From f06cd25f4a2c9ab425c90071b08c68558e1c7a3a Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 26 Apr 2026 00:11:19 +0300 Subject: [PATCH 5/8] Add GPT-5.5 support (#169112) --- homeassistant/components/openai_conversation/config_flow.py | 4 ++-- tests/components/openai_conversation/test_config_flow.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index ee64975f88eeef..8294cd5b51fcfb 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -579,8 +579,8 @@ def _get_reasoning_options(self, model: str) -> list[str]: return [] models_reasoning_map: dict[str | tuple[str, ...], list[str]] = { - ("gpt-5.2-pro", "gpt-5.4-pro"): ["medium", "high", "xhigh"], - ("gpt-5.2", "gpt-5.3", "gpt-5.4"): [ + ("gpt-5.2-pro", "gpt-5.4-pro", "gpt-5.5-pro"): ["medium", "high", "xhigh"], + ("gpt-5.2", "gpt-5.3", "gpt-5.4", "gpt-5.5"): [ "none", "low", "medium", diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index b674c4a0c8f0e2..6bdee46d720f5a 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -272,6 +272,8 @@ async def test_subentry_unsupported_model( ("gpt-5.3-codex", ["none", "low", "medium", "high", "xhigh"]), ("gpt-5.4", ["none", "low", "medium", "high", "xhigh"]), ("gpt-5.4-pro", ["medium", "high", "xhigh"]), + ("gpt-5.5", ["none", "low", "medium", "high", "xhigh"]), + ("gpt-5.5-pro", ["medium", "high", "xhigh"]), ], ) async def test_subentry_reasoning_effort_list( From 77df31fa838782f9d082f3d1d7117656e425ffaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Sat, 25 Apr 2026 23:11:44 +0200 Subject: [PATCH 6/8] Add climate platform tests for nobo_hub (#169010) --- homeassistant/components/nobo_hub/climate.py | 2 - tests/components/nobo_hub/conftest.py | 14 +- .../nobo_hub/snapshots/test_climate.ambr | 83 +++++++ tests/components/nobo_hub/test_climate.py | 207 ++++++++++++++++++ 4 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 tests/components/nobo_hub/snapshots/test_climate.ambr create mode 100644 tests/components/nobo_hub/test_climate.py diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index b418a1a6e15255..4246c8a8db0072 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -134,8 +134,6 @@ async def async_set_temperature(self, **kwargs: Any) -> None: if ATTR_TARGET_TEMP_LOW in kwargs: low = round(kwargs[ATTR_TARGET_TEMP_LOW]) high = round(kwargs[ATTR_TARGET_TEMP_HIGH]) - low = min(low, high) - high = max(low, high) await self._nobo.async_update_zone( self._id, temp_comfort_c=high, temp_eco_c=low ) diff --git a/tests/components/nobo_hub/conftest.py b/tests/components/nobo_hub/conftest.py index ba31d95400a0a2..d3df30ec001b24 100644 --- a/tests/components/nobo_hub/conftest.py +++ b/tests/components/nobo_hub/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the Nobø Ecohub tests.""" from collections.abc import Generator +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from pynobo import nobo as pynobo_nobo @@ -58,7 +59,17 @@ def connect_exc() -> BaseException | None: @pytest.fixture -def mock_config_entry(ip_address: str, auto_discovered: bool) -> MockConfigEntry: +def config_entry_options() -> dict[str, Any]: + """Return the options stored on the config entry.""" + return {} + + +@pytest.fixture +def mock_config_entry( + ip_address: str, + auto_discovered: bool, + config_entry_options: dict[str, Any], +) -> MockConfigEntry: """Return a mock Nobø Ecohub config entry.""" return MockConfigEntry( domain=DOMAIN, @@ -69,6 +80,7 @@ def mock_config_entry(ip_address: str, auto_discovered: bool) -> MockConfigEntry CONF_IP_ADDRESS: ip_address, CONF_AUTO_DISCOVERED: auto_discovered, }, + options=config_entry_options, ) diff --git a/tests/components/nobo_hub/snapshots/test_climate.ambr b/tests/components/nobo_hub/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..349e719ba207c6 --- /dev/null +++ b/tests/components/nobo_hub/snapshots/test_climate.ambr @@ -0,0 +1,83 @@ +# serializer version: 1 +# name: test_climate_entities[climate.living_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'none', + 'comfort', + 'eco', + 'away', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'nobo_hub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '102000013098:1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities[climate.living_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Living room', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_mode': 'comfort', + 'preset_modes': list([ + 'none', + 'comfort', + 'eco', + 'away', + ]), + 'supported_features': , + 'target_temp_high': 21, + 'target_temp_low': 17, + 'target_temp_step': 1, + }), + 'context': , + 'entity_id': 'climate.living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/nobo_hub/test_climate.py b/tests/components/nobo_hub/test_climate.py new file mode 100644 index 00000000000000..26f4329b51aedb --- /dev/null +++ b/tests/components/nobo_hub/test_climate.py @@ -0,0 +1,207 @@ +"""Tests for the Nobø Ecohub climate platform.""" + +from unittest.mock import MagicMock + +from pynobo import nobo +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN as CLIMATE_DOMAIN, + PRESET_AWAY, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.components.nobo_hub.const import ( + CONF_OVERRIDE_TYPE, + OVERRIDE_TYPE_NOW, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import fire_hub_update + +from tests.common import MockConfigEntry, snapshot_platform + +CLIMATE_ENTITY = "climate.living_room" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Only set up the climate platform for these tests.""" + return [Platform.CLIMATE] + + +@pytest.mark.usefixtures("init_integration") +async def test_climate_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """All climate entities match their snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("zone_mode", "expected_state", "expected_preset"), + [ + (nobo.API.NAME_OFF, HVACMode.OFF, PRESET_NONE), + (nobo.API.NAME_AWAY, HVACMode.AUTO, PRESET_AWAY), + (nobo.API.NAME_ECO, HVACMode.AUTO, PRESET_ECO), + (nobo.API.NAME_COMFORT, HVACMode.AUTO, PRESET_COMFORT), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_state_maps_zone_mode( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + zone_mode: str, + expected_state: HVACMode, + expected_preset: str, +) -> None: + """Zone modes map to the expected HVAC mode and preset.""" + mock_nobo_hub.get_current_zone_mode.return_value = zone_mode + await fire_hub_update(hass, mock_nobo_hub) + state = hass.states.get(CLIMATE_ENTITY) + assert state.state == expected_state + assert state.attributes[ATTR_PRESET_MODE] == expected_preset + + +@pytest.mark.usefixtures("init_integration") +async def test_state_override_forces_heat( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """A non-normal zone override maps to HVACMode.HEAT.""" + # Any non-NORMAL override value suffices; NAME_COMFORT is arbitrary. + mock_nobo_hub.get_zone_override_mode.return_value = nobo.API.NAME_COMFORT + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(CLIMATE_ENTITY).state == HVACMode.HEAT + + +@pytest.mark.usefixtures("init_integration") +async def test_current_temperature_unknown_when_missing( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """A missing current temperature surfaces as None.""" + mock_nobo_hub.get_current_zone_temperature.return_value = None + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(CLIMATE_ENTITY).attributes[ATTR_CURRENT_TEMPERATURE] is None + + +@pytest.mark.parametrize( + ("hvac_mode", "expected_override"), + [ + (HVACMode.AUTO, nobo.API.OVERRIDE_MODE_NORMAL), + (HVACMode.HEAT, nobo.API.OVERRIDE_MODE_COMFORT), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + hvac_mode: HVACMode, + expected_override: str, +) -> None: + """Each HVAC mode maps to the expected zone override.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: CLIMATE_ENTITY, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + mock_nobo_hub.async_create_override.assert_called_once_with( + expected_override, + nobo.API.OVERRIDE_TYPE_CONSTANT, + nobo.API.OVERRIDE_TARGET_ZONE, + "1", + ) + + +@pytest.mark.parametrize( + ("preset", "expected_mode"), + [ + (PRESET_NONE, nobo.API.OVERRIDE_MODE_NORMAL), + (PRESET_COMFORT, nobo.API.OVERRIDE_MODE_COMFORT), + (PRESET_ECO, nobo.API.OVERRIDE_MODE_ECO), + (PRESET_AWAY, nobo.API.OVERRIDE_MODE_AWAY), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_set_preset_mode( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + preset: str, + expected_mode: str, +) -> None: + """Each preset maps to the expected override mode.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: CLIMATE_ENTITY, ATTR_PRESET_MODE: preset}, + blocking=True, + ) + mock_nobo_hub.async_create_override.assert_called_once_with( + expected_mode, + nobo.API.OVERRIDE_TYPE_CONSTANT, + nobo.API.OVERRIDE_TARGET_ZONE, + "1", + ) + + +@pytest.mark.parametrize( + "config_entry_options", + [{CONF_OVERRIDE_TYPE: OVERRIDE_TYPE_NOW}], +) +@pytest.mark.usefixtures("init_integration") +async def test_set_preset_with_override_type_now( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """The override_type option flows into the zone override call.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: CLIMATE_ENTITY, ATTR_PRESET_MODE: PRESET_COMFORT}, + blocking=True, + ) + mock_nobo_hub.async_create_override.assert_called_once_with( + nobo.API.OVERRIDE_MODE_COMFORT, + nobo.API.OVERRIDE_TYPE_NOW, + nobo.API.OVERRIDE_TARGET_ZONE, + "1", + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_temperature_updates_zone( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Setting target temperatures updates the zone on the hub.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: CLIMATE_ENTITY, + ATTR_TARGET_TEMP_LOW: 16.4, + ATTR_TARGET_TEMP_HIGH: 21.6, + }, + blocking=True, + ) + mock_nobo_hub.async_update_zone.assert_called_once_with( + "1", temp_comfort_c=22, temp_eco_c=16 + ) From c48502afdac1675f2bffc2e1f08f64993e739887 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 25 Apr 2026 23:12:14 +0200 Subject: [PATCH 7/8] Remove `name` from AccuWeather config flow (#169142) Co-authored-by: Copilot --- homeassistant/components/accuweather/config_flow.py | 12 ++++++------ homeassistant/components/accuweather/coordinator.py | 4 ++-- tests/components/accuweather/__init__.py | 1 - tests/components/accuweather/conftest.py | 1 + .../accuweather/snapshots/test_diagnostics.ambr | 1 - tests/components/accuweather/test_config_flow.py | 6 ++---- 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index a56391e9c4f05e..a15dc9609ed302 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -4,7 +4,7 @@ from asyncio import timeout from collections.abc import Mapping -from typing import Any +from typing import TYPE_CHECKING, Any from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError from aiohttp import ClientError @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -55,8 +55,11 @@ async def async_step_user( ) self._abort_if_unique_id_configured() + if TYPE_CHECKING: + assert accuweather.location_name is not None + return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input + title=accuweather.location_name, data=user_input ) return self.async_show_form( @@ -70,9 +73,6 @@ async def async_step_user( vol.Optional( CONF_LONGITUDE, default=self.hass.config.longitude ): cv.longitude, - vol.Optional( - CONF_NAME, default=self.hass.config.location_name - ): str, } ), errors=errors, diff --git a/homeassistant/components/accuweather/coordinator.py b/homeassistant/components/accuweather/coordinator.py index 3c4991d2c59fbf..c8e37f45cf9d8b 100644 --- a/homeassistant/components/accuweather/coordinator.py +++ b/homeassistant/components/accuweather/coordinator.py @@ -64,7 +64,7 @@ def __init__( """Initialize.""" self.accuweather = accuweather self.location_key = accuweather.location_key - name = config_entry.data[CONF_NAME] + name = config_entry.data.get(CONF_NAME) or config_entry.title if TYPE_CHECKING: assert self.location_key is not None @@ -122,7 +122,7 @@ def __init__( self.accuweather = accuweather self.location_key = accuweather.location_key self._fetch_method = fetch_method - name = config_entry.data[CONF_NAME] + name = config_entry.data.get(CONF_NAME) or config_entry.title if TYPE_CHECKING: assert self.location_key is not None diff --git a/tests/components/accuweather/__init__.py b/tests/components/accuweather/__init__.py index 0e5313ceb94838..8c01630b29e42b 100644 --- a/tests/components/accuweather/__init__.py +++ b/tests/components/accuweather/__init__.py @@ -16,7 +16,6 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: "api_key": "32-character-string-1234567890qw", "latitude": 55.55, "longitude": 122.12, - "name": "Home", }, ) diff --git a/tests/components/accuweather/conftest.py b/tests/components/accuweather/conftest.py index abecc7cc198eea..d70f688010d9e1 100644 --- a/tests/components/accuweather/conftest.py +++ b/tests/components/accuweather/conftest.py @@ -33,6 +33,7 @@ def mock_accuweather_client() -> Generator[AsyncMock]: client.async_get_daily_forecast.return_value = daily_forecast client.async_get_hourly_forecast.return_value = hourly_forecast client.location_key = "0123456" + client.location_name = "Test location" client.requests_remaining = 10 yield client diff --git a/tests/components/accuweather/snapshots/test_diagnostics.ambr b/tests/components/accuweather/snapshots/test_diagnostics.ambr index 7477602f3a4c4d..29692aea368358 100644 --- a/tests/components/accuweather/snapshots/test_diagnostics.ambr +++ b/tests/components/accuweather/snapshots/test_diagnostics.ambr @@ -5,7 +5,6 @@ 'api_key': '**REDACTED**', 'latitude': '**REDACTED**', 'longitude': '**REDACTED**', - 'name': 'Home', }), 'observation_data': dict({ 'ApparentTemperature': dict({ diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index f17f4362aca329..62822db4d2e516 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -7,7 +7,7 @@ from homeassistant.components.accuweather.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -16,7 +16,6 @@ from tests.common import MockConfigEntry VALID_CONFIG = { - CONF_NAME: "abcd", CONF_API_KEY: "32-character-string-1234567890qw", CONF_LATITUDE: 55.55, CONF_LONGITUDE: 122.12, @@ -115,8 +114,7 @@ async def test_create_entry( ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "abcd" - assert result["data"][CONF_NAME] == "abcd" + assert result["title"] == "Test location" assert result["data"][CONF_LATITUDE] == 55.55 assert result["data"][CONF_LONGITUDE] == 122.12 assert result["data"][CONF_API_KEY] == "32-character-string-1234567890qw" From 8e3070afe1b8cb02c9cbc61a3470ec506288ac6a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Apr 2026 23:19:11 +0200 Subject: [PATCH 8/8] Add reconfiguration flow to PVOutput (#169123) --- .../components/pvoutput/config_flow.py | 39 ++++++++++++ .../components/pvoutput/strings.json | 9 ++- tests/components/pvoutput/test_config_flow.py | 63 +++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/pvoutput/config_flow.py b/homeassistant/components/pvoutput/config_flow.py index e1e544ef9ff082..2d9860c63e2a13 100644 --- a/homeassistant/components/pvoutput/config_flow.py +++ b/homeassistant/components/pvoutput/config_flow.py @@ -81,6 +81,45 @@ async def async_step_user( errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of a PVOutput entry.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + try: + await validate_input( + self.hass, + api_key=user_input[CONF_API_KEY], + system_id=reconfigure_entry.data[CONF_SYSTEM_ID], + ) + except PVOutputAuthenticationError: + errors["base"] = "invalid_auth" + except PVOutputError: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + description_placeholders={ + "account_url": "https://pvoutput.org/account.jsp" + }, + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/pvoutput/strings.json b/homeassistant/components/pvoutput/strings.json index 269f7c384edcb7..342ed952eb963e 100644 --- a/homeassistant/components/pvoutput/strings.json +++ b/homeassistant/components/pvoutput/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -15,6 +16,12 @@ }, "description": "To re-authenticate with PVOutput you'll need to get the API key at {account_url}." }, + "reconfigure": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "description": "Reconfigure your PVOutput integration. You can update your API key at {account_url}." + }, "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", diff --git a/tests/components/pvoutput/test_config_flow.py b/tests/components/pvoutput/test_config_flow.py index fc4335de00dfba..9333100634dbf4 100644 --- a/tests/components/pvoutput/test_config_flow.py +++ b/tests/components/pvoutput/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock from pvo import PVOutputAuthenticationError, PVOutputConnectionError +import pytest from homeassistant.components.pvoutput.const import CONF_SYSTEM_ID, DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -242,3 +243,65 @@ async def test_reauth_api_error( assert result2.get("type") is FlowResultType.FORM assert result2.get("step_id") == "reauth_confirm" assert result2.get("errors") == {"base": "cannot_connect"} + + +@pytest.mark.usefixtures("mock_pvoutput") +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring an existing PVOutput entry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new-api-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new-api-key" + assert mock_config_entry.data[CONF_SYSTEM_ID] == 12345 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (PVOutputAuthenticationError, {"base": "invalid_auth"}), + (PVOutputConnectionError, {"base": "cannot_connect"}), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_pvoutput: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: type[Exception], + expected_error: dict[str, str], +) -> None: + """Test reconfigure flow recovers from errors.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + mock_pvoutput.system.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new-api-key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == expected_error + + mock_pvoutput.system.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new-api-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful"