From d5132e8ea9f142b26e123bbe5ade87e7245a9c37 Mon Sep 17 00:00:00 2001 From: Imeon-Energy Date: Thu, 11 Sep 2025 17:55:55 +0200 Subject: [PATCH 01/25] Add select to Imeon inverter integration (#150889) Co-authored-by: TheBushBoy Co-authored-by: Joost Lekkerkerker Co-authored-by: Norbert Rittel --- .../components/imeon_inverter/const.py | 8 ++ .../components/imeon_inverter/icons.json | 5 + .../components/imeon_inverter/select.py | 72 ++++++++++++ .../components/imeon_inverter/strings.json | 13 ++- .../imeon_inverter/fixtures/entity_data.json | 109 +++++++++--------- .../imeon_inverter/snapshots/test_select.ambr | 62 ++++++++++ .../imeon_inverter/snapshots/test_sensor.ambr | 104 ++++++++--------- .../components/imeon_inverter/test_select.py | 55 +++++++++ .../components/imeon_inverter/test_sensor.py | 4 +- 9 files changed, 324 insertions(+), 108 deletions(-) create mode 100644 homeassistant/components/imeon_inverter/select.py create mode 100644 tests/components/imeon_inverter/snapshots/test_select.ambr create mode 100644 tests/components/imeon_inverter/test_select.py diff --git a/homeassistant/components/imeon_inverter/const.py b/homeassistant/components/imeon_inverter/const.py index 44413a4c3402ae..da4cd7d381e0b1 100644 --- a/homeassistant/components/imeon_inverter/const.py +++ b/homeassistant/components/imeon_inverter/const.py @@ -5,9 +5,17 @@ DOMAIN = "imeon_inverter" TIMEOUT = 30 PLATFORMS = [ + Platform.SELECT, Platform.SENSOR, ] ATTR_BATTERY_STATUS = ["charging", "discharging", "charged"] +ATTR_INVERTER_MODE = { + "smg": "smart_grid", + "bup": "backup", + "ong": "on_grid", + "ofg": "off_grid", +} +INVERTER_MODE_OPTIONS = {v: k for k, v in ATTR_INVERTER_MODE.items()} ATTR_INVERTER_STATE = [ "not_connected", "unsynchronized", diff --git a/homeassistant/components/imeon_inverter/icons.json b/homeassistant/components/imeon_inverter/icons.json index a4a7edf21a63a5..afd98d697c6a21 100644 --- a/homeassistant/components/imeon_inverter/icons.json +++ b/homeassistant/components/imeon_inverter/icons.json @@ -170,6 +170,11 @@ "energy_battery_consumed": { "default": "mdi:battery-arrow-down-outline" } + }, + "select": { + "manager_inverter_mode": { + "default": "mdi:view-grid" + } } } } diff --git a/homeassistant/components/imeon_inverter/select.py b/homeassistant/components/imeon_inverter/select.py new file mode 100644 index 00000000000000..def8b1b0e0aab9 --- /dev/null +++ b/homeassistant/components/imeon_inverter/select.py @@ -0,0 +1,72 @@ +"""Imeon inverter select support.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ATTR_INVERTER_MODE, INVERTER_MODE_OPTIONS +from .coordinator import Inverter, InverterCoordinator +from .entity import InverterEntity + +type InverterConfigEntry = ConfigEntry[InverterCoordinator] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class ImeonSelectEntityDescription(SelectEntityDescription): + """Class to describe an Imeon inverter select entity.""" + + set_value_fn: Callable[[Inverter, str], Awaitable[bool]] + values: dict[str, str] + + +SELECT_DESCRIPTIONS: tuple[ImeonSelectEntityDescription, ...] = ( + ImeonSelectEntityDescription( + key="manager_inverter_mode", + translation_key="manager_inverter_mode", + options=list(INVERTER_MODE_OPTIONS), + values=ATTR_INVERTER_MODE, + set_value_fn=lambda api, mode: api.set_inverter_mode( + INVERTER_MODE_OPTIONS[mode] + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: InverterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create each select for a given config entry.""" + coordinator = entry.runtime_data + async_add_entities( + InverterSelect(coordinator, entry, description) + for description in SELECT_DESCRIPTIONS + ) + + +class InverterSelect(InverterEntity, SelectEntity): + """Representation of an Imeon inverter select.""" + + entity_description: ImeonSelectEntityDescription + _attr_entity_category = EntityCategory.CONFIG + + @property + def current_option(self) -> str | None: + """Return the state of the select.""" + value = self.coordinator.data.get(self.data_key) + if not isinstance(value, str): + return None + return self.entity_description.values.get(value) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.set_value_fn(self.coordinator.api, option) diff --git a/homeassistant/components/imeon_inverter/strings.json b/homeassistant/components/imeon_inverter/strings.json index 6e1e3bb69ff92d..a84e5e6ef776c6 100644 --- a/homeassistant/components/imeon_inverter/strings.json +++ b/homeassistant/components/imeon_inverter/strings.json @@ -95,7 +95,7 @@ "unsynchronized": "Unsynchronized", "grid_consumption": "Grid consumption", "grid_injection": "Grid injection", - "grid_synchronised_but_not_used": "Grid unsynchronized but used" + "grid_synchronized_but_not_used": "Grid unsynchronized but used" } }, "meter_power": { @@ -214,6 +214,17 @@ "energy_battery_consumed": { "name": "Today battery-consumed energy" } + }, + "select": { + "manager_inverter_mode": { + "name": "Inverter mode", + "state": { + "smart_grid": "Smart grid", + "backup": "Backup", + "on_grid": "On-grid", + "off_grid": "Off-grid" + } + } } } } diff --git a/tests/components/imeon_inverter/fixtures/entity_data.json b/tests/components/imeon_inverter/fixtures/entity_data.json index a2717f093f4e30..aa254b6a625e05 100644 --- a/tests/components/imeon_inverter/fixtures/entity_data.json +++ b/tests/components/imeon_inverter/fixtures/entity_data.json @@ -1,79 +1,82 @@ { "battery": { - "battery_power": 2500.0, - "battery_soc": 78.0, - "battery_status": "charging", - "battery_stored": 10200.0, - "battery_consumed": 500.0 + "power": 2500.0, + "soc": 78.0, + "status": "charging", + "stored": 10200.0, + "consumed": 500.0 }, "grid": { - "grid_current_l1": 12.5, - "grid_current_l2": 10.8, - "grid_current_l3": 11.2, - "grid_frequency": 50.0, - "grid_voltage_l1": 230.0, - "grid_voltage_l2": 229.5, - "grid_voltage_l3": 230.1 + "current_l1": 12.5, + "current_l2": 10.8, + "current_l3": 11.2, + "frequency": 50.0, + "voltage_l1": 230.0, + "voltage_l2": 229.5, + "voltage_l3": 230.1 }, "input": { - "input_power_l1": 1000.0, - "input_power_l2": 950.0, - "input_power_l3": 980.0, - "input_power_total": 2930.0 + "power_l1": 1000.0, + "power_l2": 950.0, + "power_l3": 980.0, + "power_total": 2930.0 }, "inverter": { - "inverter_charging_current_limit": 50, - "inverter_injection_power_limit": 5000.0, - "manager_inverter_state": "grid_consumption" + "charging_current_limit": 50, + "injection_power_limit": 5000.0 + }, + "manager": { + "inverter_state": "grid_consumption", + "inverter_mode": "smg" }, "meter": { - "meter_power": 2000.0 + "power": 2000.0 }, "output": { - "output_current_l1": 15.0, - "output_current_l2": 14.5, - "output_current_l3": 15.2, - "output_frequency": 49.9, - "output_power_l1": 1100.0, - "output_power_l2": 1080.0, - "output_power_l3": 1120.0, - "output_power_total": 3300.0, - "output_voltage_l1": 231.0, - "output_voltage_l2": 229.8, - "output_voltage_l3": 230.2 + "current_l1": 15.0, + "current_l2": 14.5, + "current_l3": 15.2, + "frequency": 49.9, + "power_l1": 1100.0, + "power_l2": 1080.0, + "power_l3": 1120.0, + "power_total": 3300.0, + "voltage_l1": 231.0, + "voltage_l2": 229.8, + "voltage_l3": 230.2 }, "pv": { - "pv_consumed": 1500.0, - "pv_injected": 800.0, - "pv_power_1": 1200.0, - "pv_power_2": 1300.0, - "pv_power_total": 2500.0 + "consumed": 1500.0, + "injected": 800.0, + "power_1": 1200.0, + "power_2": 1300.0, + "power_total": 2500.0 }, "temp": { - "temp_air_temperature": 25.0, - "temp_component_temperature": 45.5 + "air_temperature": 25.0, + "component_temperature": 45.5 }, "monitoring": { - "monitoring_self_produced": 2600.0, - "monitoring_self_consumption": 85.0, - "monitoring_self_sufficiency": 90.0 + "self_produced": 2600.0, + "self_consumption": 85.0, + "self_sufficiency": 90.0 }, "monitoring_minute": { - "monitoring_minute_building_consumption": 50.0, - "monitoring_minute_grid_consumption": 8.3, - "monitoring_minute_grid_injection": 11.7, - "monitoring_minute_grid_power_flow": -3.4, - "monitoring_minute_solar_production": 43.3 + "building_consumption": 50.0, + "grid_consumption": 8.3, + "grid_injection": 11.7, + "grid_power_flow": -3.4, + "solar_production": 43.3 }, "timeline": { - "timeline_type_msg": "info_bat" + "type_msg": "info_bat" }, "energy": { - "energy_pv": 12000.0, - "energy_grid_injected": 5000.0, - "energy_grid_consumed": 6000.0, - "energy_building_consumption": 15000.0, - "energy_battery_stored": 8000.0, - "energy_battery_consumed": 2000.0 + "pv": 12000.0, + "grid_injected": 5000.0, + "grid_consumed": 6000.0, + "building_consumption": 15000.0, + "battery_stored": 8000.0, + "battery_consumed": 2000.0 } } diff --git a/tests/components/imeon_inverter/snapshots/test_select.ambr b/tests/components/imeon_inverter/snapshots/test_select.ambr new file mode 100644 index 00000000000000..550402407ac386 --- /dev/null +++ b/tests/components/imeon_inverter/snapshots/test_select.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_selects[select.imeon_inverter_inverter_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart_grid', + 'backup', + 'on_grid', + 'off_grid', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.imeon_inverter_inverter_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Inverter mode', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'manager_inverter_mode', + 'unique_id': '111111111111111_manager_inverter_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.imeon_inverter_inverter_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Imeon inverter Inverter mode', + 'options': list([ + 'smart_grid', + 'backup', + 'on_grid', + 'off_grid', + ]), + }), + 'context': , + 'entity_id': 'select.imeon_inverter_inverter_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'smart_grid', + }) +# --- diff --git a/tests/components/imeon_inverter/snapshots/test_sensor.ambr b/tests/components/imeon_inverter/snapshots/test_sensor.ambr index 5101880e7a5757..de8ef9cce19f68 100644 --- a/tests/components/imeon_inverter/snapshots/test_sensor.ambr +++ b/tests/components/imeon_inverter/snapshots/test_sensor.ambr @@ -52,7 +52,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '25.0', }) # --- # name: test_sensors[sensor.imeon_inverter_battery_consumed-entry] @@ -108,7 +108,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '500.0', }) # --- # name: test_sensors[sensor.imeon_inverter_battery_power-entry] @@ -164,7 +164,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2500.0', }) # --- # name: test_sensors[sensor.imeon_inverter_battery_state_of_charge-entry] @@ -217,7 +217,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '78.0', }) # --- # name: test_sensors[sensor.imeon_inverter_battery_status-entry] @@ -277,7 +277,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'charging', }) # --- # name: test_sensors[sensor.imeon_inverter_battery_stored-entry] @@ -333,7 +333,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '10200.0', }) # --- # name: test_sensors[sensor.imeon_inverter_building_consumption-entry] @@ -389,7 +389,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '50.0', }) # --- # name: test_sensors[sensor.imeon_inverter_charging_current_limit-entry] @@ -445,7 +445,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '50', }) # --- # name: test_sensors[sensor.imeon_inverter_component_temperature-entry] @@ -501,7 +501,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '45.5', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_consumption-entry] @@ -557,7 +557,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '8.3', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_current_l1-entry] @@ -613,7 +613,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '12.5', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_current_l2-entry] @@ -669,7 +669,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '10.8', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_current_l3-entry] @@ -725,7 +725,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '11.2', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_frequency-entry] @@ -781,7 +781,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '50.0', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_injection-entry] @@ -837,7 +837,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '11.7', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_power_flow-entry] @@ -893,7 +893,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '-3.4', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_voltage_l1-entry] @@ -949,7 +949,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '230.0', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_voltage_l2-entry] @@ -1005,7 +1005,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '229.5', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_voltage_l3-entry] @@ -1061,7 +1061,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '230.1', }) # --- # name: test_sensors[sensor.imeon_inverter_injection_power_limit-entry] @@ -1117,7 +1117,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '5000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_input_power_l1-entry] @@ -1173,7 +1173,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_input_power_l2-entry] @@ -1229,7 +1229,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '950.0', }) # --- # name: test_sensors[sensor.imeon_inverter_input_power_l3-entry] @@ -1285,7 +1285,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '980.0', }) # --- # name: test_sensors[sensor.imeon_inverter_input_power_total-entry] @@ -1341,7 +1341,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2930.0', }) # --- # name: test_sensors[sensor.imeon_inverter_inverter_state-entry] @@ -1405,7 +1405,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'grid_consumption', }) # --- # name: test_sensors[sensor.imeon_inverter_meter_power-entry] @@ -1461,7 +1461,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_current_l1-entry] @@ -1517,7 +1517,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '15.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_current_l2-entry] @@ -1573,7 +1573,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '14.5', }) # --- # name: test_sensors[sensor.imeon_inverter_output_current_l3-entry] @@ -1629,7 +1629,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '15.2', }) # --- # name: test_sensors[sensor.imeon_inverter_output_frequency-entry] @@ -1685,7 +1685,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '49.9', }) # --- # name: test_sensors[sensor.imeon_inverter_output_power_l1-entry] @@ -1741,7 +1741,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1100.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_power_l2-entry] @@ -1797,7 +1797,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1080.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_power_l3-entry] @@ -1853,7 +1853,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1120.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_power_total-entry] @@ -1909,7 +1909,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '3300.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_voltage_l1-entry] @@ -1965,7 +1965,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '231.0', }) # --- # name: test_sensors[sensor.imeon_inverter_output_voltage_l2-entry] @@ -2021,7 +2021,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '229.8', }) # --- # name: test_sensors[sensor.imeon_inverter_output_voltage_l3-entry] @@ -2077,7 +2077,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '230.2', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_consumed-entry] @@ -2133,7 +2133,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1500.0', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_injected-entry] @@ -2189,7 +2189,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '800.0', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_power_1-entry] @@ -2245,7 +2245,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1200.0', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_power_2-entry] @@ -2301,7 +2301,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '1300.0', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_power_total-entry] @@ -2357,7 +2357,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2500.0', }) # --- # name: test_sensors[sensor.imeon_inverter_self_consumption-entry] @@ -2412,7 +2412,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '85.0', }) # --- # name: test_sensors[sensor.imeon_inverter_self_sufficiency-entry] @@ -2467,7 +2467,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '90.0', }) # --- # name: test_sensors[sensor.imeon_inverter_solar_production-entry] @@ -2523,7 +2523,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '43.3', }) # --- # name: test_sensors[sensor.imeon_inverter_timeline_status-entry] @@ -2605,7 +2605,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'info_bat', }) # --- # name: test_sensors[sensor.imeon_inverter_today_battery_consumed_energy-entry] @@ -2661,7 +2661,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_today_battery_stored_energy-entry] @@ -2717,7 +2717,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '8000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_today_building_consumption-entry] @@ -2773,7 +2773,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '15000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_today_grid_consumed_energy-entry] @@ -2829,7 +2829,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '6000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_today_grid_injected_energy-entry] @@ -2885,7 +2885,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '5000.0', }) # --- # name: test_sensors[sensor.imeon_inverter_today_pv_energy-entry] @@ -2941,6 +2941,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '12000.0', }) # --- diff --git a/tests/components/imeon_inverter/test_select.py b/tests/components/imeon_inverter/test_select.py new file mode 100644 index 00000000000000..ca1f73ea0e08d9 --- /dev/null +++ b/tests/components/imeon_inverter/test_select.py @@ -0,0 +1,55 @@ +"""Test the Imeon Inverter selects.""" + +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_OPTION, + SERVICE_SELECT_OPTION, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_selects( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Imeon Inverter selects.""" + with patch("homeassistant.components.imeon_inverter.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_select_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_imeon_inverter: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test select mode updates entity state.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "sensor.imeon_inverter_inverter_mode" + assert entity_id + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "smart_grid"}, + blocking=True, + ) diff --git a/tests/components/imeon_inverter/test_sensor.py b/tests/components/imeon_inverter/test_sensor.py index ec50594f6ba980..9e69badea640bb 100644 --- a/tests/components/imeon_inverter/test_sensor.py +++ b/tests/components/imeon_inverter/test_sensor.py @@ -47,12 +47,12 @@ async def test_sensor_unavailable_on_update_error( exception: Exception, ) -> None: """Test that sensor becomes unavailable when update raises an error.""" - entity_id = "sensor.imeon_inverter_battery_power" - mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + entity_id = "sensor.imeon_inverter_battery_power" + state = hass.states.get(entity_id) assert state assert state.state != STATE_UNAVAILABLE From 1b99ffe61bd8c4a6845e6f61972f1a97df103678 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:13:23 +0200 Subject: [PATCH 02/25] Pass satellite id through assist pipeline (#151992) --- .../components/assist_pipeline/__init__.py | 2 ++ .../components/assist_pipeline/pipeline.py | 26 ++++++++++++++----- .../components/assist_satellite/entity.py | 1 + .../components/conversation/agent_manager.py | 2 ++ .../components/conversation/default_agent.py | 1 + homeassistant/components/conversation/http.py | 1 + .../components/conversation/models.py | 4 +++ .../components/conversation/trigger.py | 1 + homeassistant/helpers/intent.py | 5 ++++ .../assist_pipeline/snapshots/test_init.ambr | 4 +++ .../snapshots/test_pipeline.ambr | 6 +++++ .../snapshots/test_websocket.ambr | 9 +++++++ .../assist_pipeline/test_pipeline.py | 2 ++ tests/components/conversation/conftest.py | 1 + .../conversation/test_default_agent.py | 8 ++++++ tests/components/conversation/test_init.py | 3 +++ tests/components/conversation/test_trigger.py | 22 ++++++++++++++-- 17 files changed, 90 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 8f4c6efd355dd3..e9394a8ac5d57c 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -103,6 +103,7 @@ async def async_pipeline_from_audio_stream( wake_word_settings: WakeWordSettings | None = None, audio_settings: AudioSettings | None = None, device_id: str | None = None, + satellite_id: str | None = None, start_stage: PipelineStage = PipelineStage.STT, end_stage: PipelineStage = PipelineStage.TTS, conversation_extra_system_prompt: str | None = None, @@ -115,6 +116,7 @@ async def async_pipeline_from_audio_stream( pipeline_input = PipelineInput( session=session, device_id=device_id, + satellite_id=satellite_id, stt_metadata=stt_metadata, stt_stream=stt_stream, wake_word_phrase=wake_word_phrase, diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 0cd593e9666ca1..ad291b3427bdc8 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -583,6 +583,9 @@ class PipelineRun: _device_id: str | None = None """Optional device id set during run start.""" + _satellite_id: str | None = None + """Optional satellite id set during run start.""" + _conversation_data: PipelineConversationData | None = None """Data tied to the conversation ID.""" @@ -636,9 +639,12 @@ def process_event(self, event: PipelineEvent) -> None: return pipeline_data.pipeline_debug[self.pipeline.id][self.id].events.append(event) - def start(self, conversation_id: str, device_id: str | None) -> None: + def start( + self, conversation_id: str, device_id: str | None, satellite_id: str | None + ) -> None: """Emit run start event.""" self._device_id = device_id + self._satellite_id = satellite_id self._start_debug_recording_thread() data: dict[str, Any] = { @@ -646,6 +652,8 @@ def start(self, conversation_id: str, device_id: str | None) -> None: "language": self.language, "conversation_id": conversation_id, } + if satellite_id is not None: + data["satellite_id"] = satellite_id if self.runner_data is not None: data["runner_data"] = self.runner_data if self.tts_stream: @@ -1057,7 +1065,6 @@ async def recognize_intent( self, intent_input: str, conversation_id: str, - device_id: str | None, conversation_extra_system_prompt: str | None, ) -> str: """Run intent recognition portion of pipeline. Returns text to speak.""" @@ -1088,7 +1095,8 @@ async def recognize_intent( "language": input_language, "intent_input": intent_input, "conversation_id": conversation_id, - "device_id": device_id, + "device_id": self._device_id, + "satellite_id": self._satellite_id, "prefer_local_intents": self.pipeline.prefer_local_intents, }, ) @@ -1099,7 +1107,8 @@ async def recognize_intent( text=intent_input, context=self.context, conversation_id=conversation_id, - device_id=device_id, + device_id=self._device_id, + satellite_id=self._satellite_id, language=input_language, agent_id=self.intent_agent.id, extra_system_prompt=conversation_extra_system_prompt, @@ -1269,6 +1278,7 @@ async def tts_input_stream_generator() -> AsyncGenerator[str]: text=user_input.text, conversation_id=user_input.conversation_id, device_id=user_input.device_id, + satellite_id=user_input.satellite_id, context=user_input.context, language=user_input.language, agent_id=user_input.agent_id, @@ -1567,10 +1577,15 @@ class PipelineInput: device_id: str | None = None """Identifier of the device that is processing the input/output of the pipeline.""" + satellite_id: str | None = None + """Identifier of the satellite that is processing the input/output of the pipeline.""" + async def execute(self) -> None: """Run pipeline.""" self.run.start( - conversation_id=self.session.conversation_id, device_id=self.device_id + conversation_id=self.session.conversation_id, + device_id=self.device_id, + satellite_id=self.satellite_id, ) current_stage: PipelineStage | None = self.run.start_stage stt_audio_buffer: list[EnhancedAudioChunk] = [] @@ -1656,7 +1671,6 @@ async def buffer_then_audio_stream() -> AsyncGenerator[ tts_input = await self.run.recognize_intent( intent_input, self.session.conversation_id, - self.device_id, self.conversation_extra_system_prompt, ) if tts_input.strip(): diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 3d562544c6870d..05c0db776a3e00 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -522,6 +522,7 @@ async def async_accept_pipeline_from_satellite( pipeline_id=self._resolve_pipeline(), conversation_id=session.conversation_id, device_id=device_id, + satellite_id=self.entity_id, tts_audio_output=self.tts_options, wake_word_phrase=wake_word_phrase, audio_settings=AudioSettings( diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 6203525ac01af5..fb050397061008 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -71,6 +71,7 @@ async def async_converse( language: str | None = None, agent_id: str | None = None, device_id: str | None = None, + satellite_id: str | None = None, extra_system_prompt: str | None = None, ) -> ConversationResult: """Process text and get intent.""" @@ -97,6 +98,7 @@ async def async_converse( context=context, conversation_id=conversation_id, device_id=device_id, + satellite_id=satellite_id, language=language, agent_id=agent_id, extra_system_prompt=extra_system_prompt, diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 4e07fd0135fc0d..f7b3562fe81328 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -470,6 +470,7 @@ async def _async_process_intent_result( language, assistant=DOMAIN, device_id=user_input.device_id, + satellite_id=user_input.satellite_id, conversation_agent_id=user_input.agent_id, ) except intent.MatchFailedError as match_error: diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 290e3aab955f9d..077201fca6eaf4 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -201,6 +201,7 @@ async def websocket_hass_agent_debug( context=connection.context(msg), conversation_id=None, device_id=msg.get("device_id"), + satellite_id=None, language=msg.get("language", hass.config.language), agent_id=agent.entity_id, ) diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index dac1fb862ec406..96c245d4b27411 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -37,6 +37,9 @@ class ConversationInput: device_id: str | None """Unique identifier for the device.""" + satellite_id: str | None + """Unique identifier for the satellite.""" + language: str """Language of the request.""" @@ -53,6 +56,7 @@ def as_dict(self) -> dict[str, Any]: "context": self.context.as_dict(), "conversation_id": self.conversation_id, "device_id": self.device_id, + "satellite_id": self.satellite_id, "language": self.language, "agent_id": self.agent_id, "extra_system_prompt": self.extra_system_prompt, diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 752e294a8b307d..ccf3868c212ab3 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -100,6 +100,7 @@ async def call_action( entity_name: entity["value"] for entity_name, entity in details.items() }, "device_id": user_input.device_id, + "satellite_id": user_input.satellite_id, "user_input": user_input.as_dict(), } diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index de6f98527c59e9..a412d475acff88 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -114,6 +114,7 @@ async def async_handle( language: str | None = None, assistant: str | None = None, device_id: str | None = None, + satellite_id: str | None = None, conversation_agent_id: str | None = None, ) -> IntentResponse: """Handle an intent.""" @@ -138,6 +139,7 @@ async def async_handle( language=language, assistant=assistant, device_id=device_id, + satellite_id=satellite_id, conversation_agent_id=conversation_agent_id, ) @@ -1276,6 +1278,7 @@ class Intent: "intent_type", "language", "platform", + "satellite_id", "slots", "text_input", ] @@ -1291,6 +1294,7 @@ def __init__( language: str, assistant: str | None = None, device_id: str | None = None, + satellite_id: str | None = None, conversation_agent_id: str | None = None, ) -> None: """Initialize an intent.""" @@ -1303,6 +1307,7 @@ def __init__( self.language = language self.assistant = assistant self.device_id = device_id + self.satellite_id = satellite_id self.conversation_agent_id = conversation_agent_id @callback diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 4ae4b5dce4c7dd..56ca8bde0ba350 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -45,6 +45,7 @@ 'intent_input': 'test transcript', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }), 'type': , }), @@ -145,6 +146,7 @@ 'intent_input': 'test transcript', 'language': 'en-US', 'prefer_local_intents': False, + 'satellite_id': None, }), 'type': , }), @@ -245,6 +247,7 @@ 'intent_input': 'test transcript', 'language': 'en-US', 'prefer_local_intents': False, + 'satellite_id': None, }), 'type': , }), @@ -369,6 +372,7 @@ 'intent_input': 'test transcript', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }), 'type': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr index b6354b2342bf42..7a51eddf8d6735 100644 --- a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr +++ b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr @@ -23,6 +23,7 @@ 'intent_input': 'Set a timer', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }), 'type': , }), @@ -178,6 +179,7 @@ 'intent_input': 'Set a timer', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }), 'type': , }), @@ -411,6 +413,7 @@ 'intent_input': 'Set a timer', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }), 'type': , }), @@ -634,6 +637,7 @@ 'intent_input': 'test input', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }), 'type': , }), @@ -687,6 +691,7 @@ 'intent_input': 'test input', 'language': 'en-US', 'prefer_local_intents': False, + 'satellite_id': None, }), 'type': , }), @@ -740,6 +745,7 @@ 'intent_input': 'test input', 'language': 'en-us', 'prefer_local_intents': False, + 'satellite_id': None, }), 'type': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 4f29fd79568af5..5e0d915a77e19e 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -44,6 +44,7 @@ 'intent_input': 'test transcript', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }) # --- # name: test_audio_pipeline.4 @@ -136,6 +137,7 @@ 'intent_input': 'test transcript', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }) # --- # name: test_audio_pipeline_debug.4 @@ -240,6 +242,7 @@ 'intent_input': 'test transcript', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }) # --- # name: test_audio_pipeline_with_enhancements.4 @@ -354,6 +357,7 @@ 'intent_input': 'test transcript', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.6 @@ -575,6 +579,7 @@ 'intent_input': 'Are the lights on?', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }) # --- # name: test_intent_failed.2 @@ -599,6 +604,7 @@ 'intent_input': 'Are the lights on?', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }) # --- # name: test_intent_timeout.2 @@ -635,6 +641,7 @@ 'intent_input': 'never mind', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }) # --- # name: test_pipeline_empty_tts_output.2 @@ -785,6 +792,7 @@ 'intent_input': 'Are the lights on?', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }) # --- # name: test_text_only_pipeline[extra_msg0].2 @@ -833,6 +841,7 @@ 'intent_input': 'Are the lights on?', 'language': 'en', 'prefer_local_intents': False, + 'satellite_id': None, }) # --- # name: test_text_only_pipeline[extra_msg1].2 diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 0cb67302700b95..75234122368949 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1707,6 +1707,7 @@ async def mock_converse( language: str | None = None, agent_id: str | None = None, device_id: str | None = None, + satellite_id: str | None = None, extra_system_prompt: str | None = None, ): """Mock converse.""" @@ -1715,6 +1716,7 @@ async def mock_converse( context=context, conversation_id=conversation_id, device_id=device_id, + satellite_id=satellite_id, language=language, agent_id=agent_id, extra_system_prompt=extra_system_prompt, diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py index 19d8434fc5af0e..8fefcdf7f01d58 100644 --- a/tests/components/conversation/conftest.py +++ b/tests/components/conversation/conftest.py @@ -43,6 +43,7 @@ def mock_conversation_input(hass: HomeAssistant) -> conversation.ConversationInp conversation_id=None, agent_id="mock-agent-id", device_id=None, + satellite_id=None, language="en", ) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index a90cd1b55c188c..64457300dad2e2 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -2534,6 +2534,7 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non context=Context(), conversation_id=None, device_id=None, + satellite_id=None, language=hass.config.language, agent_id=None, ) @@ -2884,6 +2885,7 @@ async def test_intent_cache_exposed(hass: HomeAssistant) -> None: context=Context(), conversation_id=None, device_id=None, + satellite_id=None, language=hass.config.language, agent_id=None, ) @@ -2923,6 +2925,7 @@ async def test_intent_cache_all_entities(hass: HomeAssistant) -> None: context=Context(), conversation_id=None, device_id=None, + satellite_id=None, language=hass.config.language, agent_id=None, ) @@ -2958,6 +2961,7 @@ async def test_intent_cache_fuzzy(hass: HomeAssistant) -> None: context=Context(), conversation_id=None, device_id=None, + satellite_id=None, language=hass.config.language, agent_id=None, ) @@ -3000,6 +3004,7 @@ async def test_entities_filtered_by_input(hass: HomeAssistant) -> None: context=Context(), conversation_id=None, device_id=None, + satellite_id=None, language=hass.config.language, agent_id=None, ) @@ -3026,6 +3031,7 @@ async def test_entities_filtered_by_input(hass: HomeAssistant) -> None: context=Context(), conversation_id=None, device_id=None, + satellite_id=None, language=hass.config.language, agent_id=None, ) @@ -3166,6 +3172,7 @@ async def test_handle_intents_with_response_errors( context=Context(), conversation_id=None, device_id=None, + satellite_id=None, language=hass.config.language, agent_id=None, ) @@ -3203,6 +3210,7 @@ async def test_handle_intents_filters_results( context=Context(), conversation_id=None, device_id=None, + satellite_id=None, language=hass.config.language, agent_id=None, ) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index e757c56042bdae..7cec3543fabd9b 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -281,6 +281,7 @@ async def test_async_handle_sentence_triggers( conversation_id=None, agent_id=conversation.HOME_ASSISTANT_AGENT, device_id=device_id, + satellite_id=None, language=hass.config.language, ), ) @@ -318,6 +319,7 @@ async def async_handle( agent_id=conversation.HOME_ASSISTANT_AGENT, conversation_id=None, device_id=None, + satellite_id=None, language=hass.config.language, ), ) @@ -335,6 +337,7 @@ async def async_handle( context=Context(), conversation_id=None, device_id=None, + satellite_id=None, language=hass.config.language, ), ) diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index a01f4cd81126fb..4531e5857e2351 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -50,6 +50,7 @@ async def test_if_fires_on_event( "slots": "{{ trigger.slots }}", "details": "{{ trigger.details }}", "device_id": "{{ trigger.device_id }}", + "satellite_id": "{{ trigger.satellite_id }}", "user_input": "{{ trigger.user_input }}", } }, @@ -81,11 +82,13 @@ async def test_if_fires_on_event( "slots": {}, "details": {}, "device_id": None, + "satellite_id": None, "user_input": { "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, + "satellite_id": None, "language": "en", "text": "Ha ha ha", "extra_system_prompt": None, @@ -185,6 +188,7 @@ async def test_response_same_sentence( "slots": "{{ trigger.slots }}", "details": "{{ trigger.details }}", "device_id": "{{ trigger.device_id }}", + "satellite_id": "{{ trigger.satellite_id }}", "user_input": "{{ trigger.user_input }}", } }, @@ -230,11 +234,13 @@ async def test_response_same_sentence( "slots": {}, "details": {}, "device_id": None, + "satellite_id": None, "user_input": { "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, + "satellite_id": None, "language": "en", "text": "test sentence", "extra_system_prompt": None, @@ -376,6 +382,7 @@ async def test_same_trigger_multiple_sentences( "slots": "{{ trigger.slots }}", "details": "{{ trigger.details }}", "device_id": "{{ trigger.device_id }}", + "satellite_id": "{{ trigger.satellite_id }}", "user_input": "{{ trigger.user_input }}", } }, @@ -408,11 +415,13 @@ async def test_same_trigger_multiple_sentences( "slots": {}, "details": {}, "device_id": None, + "satellite_id": None, "user_input": { "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, + "satellite_id": None, "language": "en", "text": "hello", "extra_system_prompt": None, @@ -449,6 +458,7 @@ async def test_same_sentence_multiple_triggers( "slots": "{{ trigger.slots }}", "details": "{{ trigger.details }}", "device_id": "{{ trigger.device_id }}", + "satellite_id": "{{ trigger.satellite_id }}", "user_input": "{{ trigger.user_input }}", } }, @@ -474,6 +484,7 @@ async def test_same_sentence_multiple_triggers( "slots": "{{ trigger.slots }}", "details": "{{ trigger.details }}", "device_id": "{{ trigger.device_id }}", + "satellite_id": "{{ trigger.satellite_id }}", "user_input": "{{ trigger.user_input }}", } }, @@ -590,6 +601,7 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall]) "slots": "{{ trigger.slots }}", "details": "{{ trigger.details }}", "device_id": "{{ trigger.device_id }}", + "satellite_id": "{{ trigger.satellite_id }}", "user_input": "{{ trigger.user_input }}", } }, @@ -636,11 +648,13 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall]) }, }, "device_id": None, + "satellite_id": None, "user_input": { "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, + "satellite_id": None, "language": "en", "text": "play the white album by the beatles", "extra_system_prompt": None, @@ -660,7 +674,7 @@ async def test_trigger_with_device_id(hass: HomeAssistant) -> None: "command": ["test sentence"], }, "action": { - "set_conversation_response": "{{ trigger.device_id }}", + "set_conversation_response": "{{ trigger.device_id }} - {{ trigger.satellite_id }}", }, } }, @@ -675,8 +689,12 @@ async def test_trigger_with_device_id(hass: HomeAssistant) -> None: context=Context(), conversation_id=None, device_id="my_device", + satellite_id="assist_satellite.my_satellite", language=hass.config.language, agent_id=None, ) ) - assert result.response.speech["plain"]["speech"] == "my_device" + assert ( + result.response.speech["plain"]["speech"] + == "my_device - assist_satellite.my_satellite" + ) From 4762c64c25e962c7f5e8aa46dde4de853d31228d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:22:39 +0200 Subject: [PATCH 03/25] Add initial support for Tuya msp category (cat toilet) (#152035) --- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/sensor.py | 10 ++++ homeassistant/components/tuya/strings.json | 3 + .../components/tuya/snapshots/test_init.ambr | 2 +- .../tuya/snapshots/test_sensor.ambr | 56 +++++++++++++++++++ 5 files changed, 71 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 6a0c4abfa25c37..77dd8a2fefd9c2 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -140,6 +140,7 @@ class DPCode(StrEnum): BRIGHTNESS_MIN_2 = "brightness_min_2" BRIGHTNESS_MIN_3 = "brightness_min_3" C_F = "c_f" # Temperature unit switching + CAT_WEIGHT = "cat_weight" CH2O_STATE = "ch2o_state" CH2O_VALUE = "ch2o_value" CH4_SENSOR_STATE = "ch4_sensor_state" diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index b053f6cfcbfbb4..cac5d17e74dfae 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -796,6 +796,16 @@ class TuyaSensorEntityDescription(SensorEntityDescription): # Door Window Sensor # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m "mcs": BATTERY_SENSORS, + # Cat toilet + # https://developer.tuya.com/en/docs/iot/s?id=Kakg3srr4ora7 + "msp": ( + TuyaSensorEntityDescription( + key=DPCode.CAT_WEIGHT, + translation_key="cat_weight", + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + ), + ), # Sous Vide Cooker # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux "mzj": ( diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 989a4d6b342f22..44aec569017277 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -759,6 +759,9 @@ "water_time": { "name": "Water usage duration" }, + "cat_weight": { + "name": "Cat weight" + }, "odor_elimination_status": { "name": "Status", "state": { diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 25e612cc8c71f4..6fa5e0167fe508 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -7119,7 +7119,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'ZEDAR K1200 (unsupported)', + 'model': 'ZEDAR K1200', 'model_id': '3ddulzljdjjwkhoy', 'name': 'Kattenbak', 'name_by_user': None, diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 32a6c73b364d42..464bdd353ec7b5 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -8842,6 +8842,62 @@ 'state': '3.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.kattenbak_cat_weight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kattenbak_cat_weight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cat weight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cat_weight', + 'unique_id': 'tuya.yohkwjjdjlzludd3psmcat_weight', + 'unit_of_measurement': 'g', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kattenbak_cat_weight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'Kattenbak Cat weight', + 'state_class': , + 'unit_of_measurement': 'g', + }), + 'context': , + 'entity_id': 'sensor.kattenbak_cat_weight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.keller_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From ab7081d26a5c1a54b13607aaa6efa1b0c3318680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 11 Sep 2025 17:34:15 +0100 Subject: [PATCH 04/25] Include non-primary entities targeted directly by label (#149309) --- homeassistant/helpers/target.py | 5 +---- tests/helpers/test_service.py | 10 ++++++++++ tests/helpers/test_target.py | 14 +++++++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 5286daaeef0d4c..0ccc4e2cec3064 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -182,10 +182,7 @@ def async_extract_referenced_entity_ids( selected.missing_labels.add(label_id) for entity_entry in entities.get_entries_for_label(label_id): - if ( - entity_entry.entity_category is None - and entity_entry.hidden_by is None - ): + if entity_entry.hidden_by is None: selected.indirectly_referenced.add(entity_entry.entity_id) for device_entry in dev_reg.devices.get_devices_for_label(label_id): diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 2af35fa95ec655..73f4afc1f6d7bd 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -361,6 +361,13 @@ def label_mock(hass: HomeAssistant) -> None: labels={"my-label"}, entity_category=EntityCategory.CONFIG, ) + diag_entity_with_my_label = RegistryEntryWithDefaults( + entity_id="light.diag_with_my_label", + unique_id="diag_with_my_label", + platform="test", + labels={"my-label"}, + entity_category=EntityCategory.DIAGNOSTIC, + ) entity_with_label1_from_device = RegistryEntryWithDefaults( entity_id="light.with_label1_from_device", unique_id="with_label1_from_device", @@ -398,6 +405,7 @@ def label_mock(hass: HomeAssistant) -> None: hass, { config_entity_with_my_label.entity_id: config_entity_with_my_label, + diag_entity_with_my_label.entity_id: diag_entity_with_my_label, entity_with_label1_and_label2_from_device.entity_id: entity_with_label1_and_label2_from_device, entity_with_label1_from_device.entity_id: entity_with_label1_from_device, entity_with_label1_from_device_and_different_area.entity_id: entity_with_label1_from_device_and_different_area, @@ -781,6 +789,8 @@ async def test_extract_entity_ids_from_labels(hass: HomeAssistant) -> None: assert { "light.with_my_label", + "light.config_with_my_label", + "light.diag_with_my_label", } == await service.async_extract_entity_ids(hass, call) call = ServiceCall(hass, "light", "turn_on", {"label_id": "label1"}) diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index 09fb16cbe9ab55..3c19a9c9a43e65 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -245,6 +245,13 @@ def registries_mock(hass: HomeAssistant) -> None: labels={"my-label"}, entity_category=EntityCategory.CONFIG, ) + diag_entity_with_my_label = RegistryEntryWithDefaults( + entity_id="light.diag_with_my_label", + unique_id="diag_with_my_label", + platform="test", + labels={"my-label"}, + entity_category=EntityCategory.DIAGNOSTIC, + ) entity_with_label1_from_device = RegistryEntryWithDefaults( entity_id="light.with_label1_from_device", unique_id="with_label1_from_device", @@ -289,6 +296,7 @@ def registries_mock(hass: HomeAssistant) -> None: entity_in_area_a.entity_id: entity_in_area_a, entity_in_area_b.entity_id: entity_in_area_b, config_entity_with_my_label.entity_id: config_entity_with_my_label, + diag_entity_with_my_label.entity_id: diag_entity_with_my_label, entity_with_label1_and_label2_from_device.entity_id: entity_with_label1_and_label2_from_device, entity_with_label1_from_device.entity_id: entity_with_label1_from_device, entity_with_label1_from_device_and_different_area.entity_id: entity_with_label1_from_device_and_different_area, @@ -407,7 +415,11 @@ def registries_mock(hass: HomeAssistant) -> None: {ATTR_LABEL_ID: "my-label"}, False, target.SelectedEntities( - indirectly_referenced={"light.with_my_label"}, + indirectly_referenced={ + "light.with_my_label", + "light.config_with_my_label", + "light.diag_with_my_label", + }, missing_labels={"my-label"}, ), ), From bcfa7a73839c4ebdd10887fcf43460b98e186f87 Mon Sep 17 00:00:00 2001 From: Sam Reed Date: Thu, 11 Sep 2025 17:36:22 +0100 Subject: [PATCH 05/25] squeezebox: Improve update notification string (#151003) --- homeassistant/components/squeezebox/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/squeezebox/update.py b/homeassistant/components/squeezebox/update.py index 62579424d25428..db2357868171a0 100644 --- a/homeassistant/components/squeezebox/update.py +++ b/homeassistant/components/squeezebox/update.py @@ -118,7 +118,7 @@ def release_summary(self) -> None | str: rs = self.coordinator.data[UPDATE_PLUGINS_RELEASE_SUMMARY] return ( (rs or "") - + "The Plugins will be updated on the next restart triggred by selecting the Install button. Allow enough time for the service to restart. It will become briefly unavailable." + + "The Plugins will be updated on the next restart triggered by selecting the Update button. Allow enough time for the service to restart. It will become briefly unavailable." if self.coordinator.can_server_restart else rs ) From 254694b02471faa760dca0676d839191e76d9e1a Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:37:21 +0100 Subject: [PATCH 06/25] Fix track icons for Apps and Radios in Squeezebox (#151001) --- homeassistant/components/squeezebox/browse_media.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 82b6f4b98cdaa6..f71cc9b22d3f50 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -248,11 +248,16 @@ def _get_item_thumbnail( item_type: str | MediaType | None, search_type: str, internal_request: bool, + known_apps_radios: set[str], ) -> str | None: """Construct path to thumbnail image.""" item_thumbnail: str | None = None + track_id = item.get("artwork_track_id") or ( - item.get("id") if item_type == "track" else None + item.get("id") + if item_type == "track" + and search_type not in known_apps_radios | {"apps", "radios"} + else None ) if track_id: @@ -357,6 +362,7 @@ async def build_item_response( item_type=item_type, search_type=search_type, internal_request=internal_request, + known_apps_radios=browse_data.known_apps_radios, ) children.append(child_media) From a368ad4ab50264b0ada0cedc082935a4a16b4ff5 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:38:33 +0200 Subject: [PATCH 07/25] Set sensors to unknown when no next alarm is set in Sleep as Android (#150558) --- .../components/sleep_as_android/sensor.py | 6 +++ .../sleep_as_android/test_sensor.py | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/homeassistant/components/sleep_as_android/sensor.py b/homeassistant/components/sleep_as_android/sensor.py index 966e851f633941..67b52ae9153e84 100644 --- a/homeassistant/components/sleep_as_android/sensor.py +++ b/homeassistant/components/sleep_as_android/sensor.py @@ -85,6 +85,12 @@ def _async_handle_event(self, webhook_id: str, data: dict[str, str]) -> None: ): self._attr_native_value = label + if ( + data[ATTR_EVENT] == "alarm_rescheduled" + and data.get(ATTR_VALUE1) is None + ): + self._attr_native_value = None + self.async_write_ha_state() async def async_added_to_hass(self) -> None: diff --git a/tests/components/sleep_as_android/test_sensor.py b/tests/components/sleep_as_android/test_sensor.py index 760df1e0181350..f8706b29f6b618 100644 --- a/tests/components/sleep_as_android/test_sensor.py +++ b/tests/components/sleep_as_android/test_sensor.py @@ -122,3 +122,45 @@ async def test_webhook_sensor( assert (state := hass.states.get("sensor.sleep_as_android_alarm_label")) assert state.state == "label" + + +async def test_webhook_sensor_alarm_unset( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test unsetting sensors if there is no next alarm.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client_no_auth() + + response = await client.post( + "/api/webhook/webhook_id", + json={ + "event": "alarm_rescheduled", + "value1": "1582719660934", + "value2": "label", + }, + ) + assert response.status == HTTPStatus.NO_CONTENT + + assert (state := hass.states.get("sensor.sleep_as_android_next_alarm")) + assert state.state == "2020-02-26T12:21:00+00:00" + + assert (state := hass.states.get("sensor.sleep_as_android_alarm_label")) + assert state.state == "label" + + response = await client.post( + "/api/webhook/webhook_id", + json={"event": "alarm_rescheduled"}, + ) + assert (state := hass.states.get("sensor.sleep_as_android_next_alarm")) + assert state.state == STATE_UNKNOWN + + assert (state := hass.states.get("sensor.sleep_as_android_alarm_label")) + assert state.state == STATE_UNKNOWN From 937d3e4a9651e4177b75c1ff486805e6b6bcdf88 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Fri, 12 Sep 2025 00:39:29 +0800 Subject: [PATCH 08/25] Add more light models to SwitchBot Cloud (#150986) --- homeassistant/components/switchbot_cloud/__init__.py | 2 ++ homeassistant/components/switchbot_cloud/light.py | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 44d1f8f30e5123..0beba5dfc14d50 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -247,6 +247,8 @@ async def make_device_data( "Strip Light 3", "Floor Lamp", "Color Bulb", + "RGBICWW Floor Lamp", + "RGBICWW Strip Light", ]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id diff --git a/homeassistant/components/switchbot_cloud/light.py b/homeassistant/components/switchbot_cloud/light.py index 645c6b4c62be7a..77062702831ae4 100644 --- a/homeassistant/components/switchbot_cloud/light.py +++ b/homeassistant/components/switchbot_cloud/light.py @@ -27,6 +27,11 @@ def value_map_brightness(value: int) -> int: return int(value / 255 * 100) +def brightness_map_value(value: int) -> int: + """Return brightness from map value.""" + return int(value * 255 / 100) + + async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, @@ -52,13 +57,14 @@ def _set_attributes(self) -> None: """Set attributes from coordinator data.""" if self.coordinator.data is None: return - power: str | None = self.coordinator.data.get("power") brightness: int | None = self.coordinator.data.get("brightness") color: str | None = self.coordinator.data.get("color") color_temperature: int | None = self.coordinator.data.get("colorTemperature") self._attr_is_on = power == "on" if power else None - self._attr_brightness: int | None = brightness if brightness else None + self._attr_brightness: int | None = ( + brightness_map_value(brightness) if brightness else None + ) self._attr_rgb_color: tuple | None = ( (tuple(int(i) for i in color.split(":"))) if color else None ) From b1a6e403fb60bcd36910a3f1bc5b60d6733e32ad Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:44:43 +0200 Subject: [PATCH 09/25] Cache apt install [ci] (#152113) --- .github/workflows/ci.yaml | 126 +++++++++++++++++++++++++++++++++++--- 1 file changed, 118 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2c510af307af62..0d465f428a6219 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ on: type: boolean env: - CACHE_VERSION: 7 + CACHE_VERSION: 8 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 HA_SHORT_VERSION: "2025.10" @@ -61,6 +61,9 @@ env: POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']" PRE_COMMIT_CACHE: ~/.cache/pre-commit UV_CACHE_DIR: /tmp/uv-cache + APT_CACHE_BASE: /home/runner/work/apt + APT_CACHE_DIR: /home/runner/work/apt/cache + APT_LIST_CACHE_DIR: /home/runner/work/apt/lists SQLALCHEMY_WARN_20: 1 PYTHONASYNCIODEBUG: 1 HASS_CI: 1 @@ -78,6 +81,7 @@ jobs: core: ${{ steps.core.outputs.changes }} integrations_glob: ${{ steps.info.outputs.integrations_glob }} integrations: ${{ steps.integrations.outputs.changes }} + apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }} pre-commit_cache_key: ${{ steps.generate_pre-commit_cache_key.outputs.key }} python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }} requirements: ${{ steps.core.outputs.requirements }} @@ -111,6 +115,10 @@ jobs: run: >- echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{ hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT + - name: Generate partial apt restore key + id: generate_apt_cache_key + run: | + echo "key=$(lsb_release -rs)-apt-${{ env.CACHE_VERSION }}-${{ env.HA_SHORT_VERSION }}" >> $GITHUB_OUTPUT - name: Filter for core changes uses: dorny/paths-filter@v3.0.2 id: core @@ -515,16 +523,36 @@ jobs: ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{ env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{ env.HA_SHORT_VERSION }}- + - name: Restore apt cache + if: steps.cache-venv.outputs.cache-hit != 'true' + id: cache-apt + uses: actions/cache@v4.2.4 + with: + path: | + ${{ env.APT_CACHE_DIR }} + ${{ env.APT_LIST_CACHE_DIR }} + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }} - name: Install additional OS dependencies if: steps.cache-venv.outputs.cache-hit != 'true' timeout-minutes: 10 run: | sudo rm /etc/apt/sources.list.d/microsoft-prod.list - sudo apt-get update + if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then + mkdir -p ${{ env.APT_CACHE_DIR }} + mkdir -p ${{ env.APT_LIST_CACHE_DIR }} + fi + + sudo apt-get update \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} sudo apt-get -y install \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \ bluez \ ffmpeg \ libturbojpeg \ + libxml2-utils \ libavcodec-dev \ libavdevice-dev \ libavfilter-dev \ @@ -534,6 +562,10 @@ jobs: libswresample-dev \ libswscale-dev \ libudev-dev + + if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then + sudo chmod -R 755 ${{ env.APT_CACHE_BASE }} + fi - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -578,12 +610,25 @@ jobs: - info - base steps: + - name: Restore apt cache + uses: actions/cache/restore@v4.2.4 + with: + path: | + ${{ env.APT_CACHE_DIR }} + ${{ env.APT_LIST_CACHE_DIR }} + fail-on-cache-miss: true + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }} - name: Install additional OS dependencies timeout-minutes: 10 run: | sudo rm /etc/apt/sources.list.d/microsoft-prod.list - sudo apt-get update + sudo apt-get update \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} sudo apt-get -y install \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \ libturbojpeg - name: Check out code from GitHub uses: actions/checkout@v5.0.0 @@ -878,12 +923,25 @@ jobs: - mypy name: Split tests for full run steps: + - name: Restore apt cache + uses: actions/cache/restore@v4.2.4 + with: + path: | + ${{ env.APT_CACHE_DIR }} + ${{ env.APT_LIST_CACHE_DIR }} + fail-on-cache-miss: true + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }} - name: Install additional OS dependencies timeout-minutes: 10 run: | sudo rm /etc/apt/sources.list.d/microsoft-prod.list - sudo apt-get update + sudo apt-get update \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} sudo apt-get -y install \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \ bluez \ ffmpeg \ libturbojpeg \ @@ -939,12 +997,25 @@ jobs: name: >- Run tests Python ${{ matrix.python-version }} (${{ matrix.group }}) steps: + - name: Restore apt cache + uses: actions/cache/restore@v4.2.4 + with: + path: | + ${{ env.APT_CACHE_DIR }} + ${{ env.APT_LIST_CACHE_DIR }} + fail-on-cache-miss: true + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }} - name: Install additional OS dependencies timeout-minutes: 10 run: | sudo rm /etc/apt/sources.list.d/microsoft-prod.list - sudo apt-get update + sudo apt-get update \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} sudo apt-get -y install \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \ bluez \ ffmpeg \ libturbojpeg \ @@ -1073,12 +1144,25 @@ jobs: name: >- Run ${{ matrix.mariadb-group }} tests Python ${{ matrix.python-version }} steps: + - name: Restore apt cache + uses: actions/cache/restore@v4.2.4 + with: + path: | + ${{ env.APT_CACHE_DIR }} + ${{ env.APT_LIST_CACHE_DIR }} + fail-on-cache-miss: true + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }} - name: Install additional OS dependencies timeout-minutes: 10 run: | sudo rm /etc/apt/sources.list.d/microsoft-prod.list - sudo apt-get update + sudo apt-get update \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} sudo apt-get -y install \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \ bluez \ ffmpeg \ libturbojpeg \ @@ -1214,12 +1298,25 @@ jobs: name: >- Run ${{ matrix.postgresql-group }} tests Python ${{ matrix.python-version }} steps: + - name: Restore apt cache + uses: actions/cache/restore@v4.2.4 + with: + path: | + ${{ env.APT_CACHE_DIR }} + ${{ env.APT_LIST_CACHE_DIR }} + fail-on-cache-miss: true + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }} - name: Install additional OS dependencies timeout-minutes: 10 run: | sudo rm /etc/apt/sources.list.d/microsoft-prod.list - sudo apt-get update + sudo apt-get update \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} sudo apt-get -y install \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \ bluez \ ffmpeg \ libturbojpeg \ @@ -1376,12 +1473,25 @@ jobs: name: >- Run tests Python ${{ matrix.python-version }} (${{ matrix.group }}) steps: + - name: Restore apt cache + uses: actions/cache/restore@v4.2.4 + with: + path: | + ${{ env.APT_CACHE_DIR }} + ${{ env.APT_LIST_CACHE_DIR }} + fail-on-cache-miss: true + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }} - name: Install additional OS dependencies timeout-minutes: 10 run: | sudo rm /etc/apt/sources.list.d/microsoft-prod.list - sudo apt-get update + sudo apt-get update \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} sudo apt-get -y install \ + -o Dir::Cache=${{ env.APT_CACHE_DIR }} \ + -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \ bluez \ ffmpeg \ libturbojpeg \ From 9cfdb99e7667e852c6efb5e1a121feef497fa50c Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:52:12 +0200 Subject: [PATCH 10/25] Add repair to unsubscribe protected topic in ntfy integration (#152009) --- homeassistant/components/ntfy/event.py | 20 ++++++- .../components/ntfy/quality_scale.yaml | 4 +- homeassistant/components/ntfy/repairs.py | 56 +++++++++++++++++++ homeassistant/components/ntfy/strings.json | 13 +++++ tests/components/ntfy/test_event.py | 55 +++++++++++++++++- 5 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/ntfy/repairs.py diff --git a/homeassistant/components/ntfy/event.py b/homeassistant/components/ntfy/event.py index d961b67dcb8578..8075e051ba4e8c 100644 --- a/homeassistant/components/ntfy/event.py +++ b/homeassistant/components/ntfy/event.py @@ -17,9 +17,17 @@ from homeassistant.components.event import EventEntity, EventEntityDescription from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_MESSAGE, CONF_PRIORITY, CONF_TAGS, CONF_TITLE +from .const import ( + CONF_MESSAGE, + CONF_PRIORITY, + CONF_TAGS, + CONF_TITLE, + CONF_TOPIC, + DOMAIN, +) from .coordinator import NtfyConfigEntry from .entity import NtfyBaseEntity @@ -100,6 +108,16 @@ async def ws_connect(self) -> None: if self._attr_available: _LOGGER.error("Failed to subscribe to topic. Topic is protected") self._attr_available = False + ir.async_create_issue( + self.hass, + DOMAIN, + f"topic_protected_{self.topic}", + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="topic_protected", + translation_placeholders={CONF_TOPIC: self.topic}, + data={"entity_id": self.entity_id, "topic": self.topic}, + ) return except NtfyHTTPError as e: if self._attr_available: diff --git a/homeassistant/components/ntfy/quality_scale.yaml b/homeassistant/components/ntfy/quality_scale.yaml index 2e2a7910bba0da..b00cdb93c97040 100644 --- a/homeassistant/components/ntfy/quality_scale.yaml +++ b/homeassistant/components/ntfy/quality_scale.yaml @@ -63,9 +63,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: - status: exempt - comment: the integration has no repairs + repair-issues: done stale-devices: status: exempt comment: only one device per entry, is deleted with the entry. diff --git a/homeassistant/components/ntfy/repairs.py b/homeassistant/components/ntfy/repairs.py new file mode 100644 index 00000000000000..e87ca3ddcad76e --- /dev/null +++ b/homeassistant/components/ntfy/repairs.py @@ -0,0 +1,56 @@ +"""Repairs for ntfy integration.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import CONF_TOPIC + + +class TopicProtectedRepairFlow(RepairsFlow): + """Handler for protected topic issue fixing flow.""" + + def __init__(self, data: dict[str, str]) -> None: + """Initialize.""" + self.entity_id = data["entity_id"] + self.topic = data["topic"] + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Init repair flow.""" + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Confirm repair flow.""" + if user_input is not None: + er.async_get(self.hass).async_update_entity( + self.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders={CONF_TOPIC: self.topic}, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str], +) -> RepairsFlow: + """Create flow.""" + if issue_id.startswith("topic_protected"): + return TopicProtectedRepairFlow(data) + return ConfirmRepairFlow() diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index 5066ce849d17fa..6bdcd1e0f9d73f 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -354,5 +354,18 @@ "5": "Maximum" } } + }, + "issues": { + "topic_protected": { + "title": "Subscription failed: Topic {topic} is protected", + "fix_flow": { + "step": { + "confirm": { + "title": "Topic {topic} is protected", + "description": "The topic **{topic}** is protected and requires authentication to subscribe.\n\nTo resolve this issue, you have two options:\n\n1. **Reconfigure the ntfy integration**\nAdd a username and password that has permission to access this topic.\n\n2. **Deactivate the event entity**\nThis will stop Home Assistant from subscribing to the topic.\nClick **Submit** to deactivate the entity." + } + } + } + } } } diff --git a/tests/components/ntfy/test_event.py b/tests/components/ntfy/test_event.py index 92e01b1ba2c3b3..a71f45375d9a00 100644 --- a/tests/components/ntfy/test_event.py +++ b/tests/components/ntfy/test_event.py @@ -17,12 +17,20 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.ntfy.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) @@ -156,3 +164,48 @@ async def test_event_exceptions( assert (state := hass.states.get("event.mytopic")) assert state.state == expected_state + + +async def test_event_topic_protected( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test ntfy events cannot subscribe to protected topic.""" + mock_aiontfy.subscribe.side_effect = NtfyForbiddenError(403, 403, "forbidden") + + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, "repairs", {}) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("event.mytopic")) + assert state.state == STATE_UNAVAILABLE + + assert issue_registry.async_get_issue( + domain=DOMAIN, issue_id="topic_protected_mytopic" + ) + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, "topic_protected_mytopic") + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "create_entry" + + assert (entity := entity_registry.async_get("event.mytopic")) + assert entity.disabled + assert entity.disabled_by is er.RegistryEntryDisabler.USER From 0e8295604ea0ab58a7bc1cc46133b078bd8b574f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 11 Sep 2025 18:56:56 +0200 Subject: [PATCH 11/25] Fail hassfest if translation key is obsolete (#151924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- script/hassfest/services.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 844a8955470de5..b47fa90d8bbbe5 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -158,6 +158,31 @@ def _service_schema(targeted: bool, custom: bool) -> vol.Schema: } +def check_extraneous_translation_fields( + integration: Integration, + service_name: str, + strings: dict[str, Any], + service_schema: dict[str, Any], +) -> None: + """Check for extraneous translation fields.""" + if integration.core and "services" in strings: + section_fields = set() + for field in service_schema.get("fields", {}).values(): + if "fields" in field: + # This is a section + section_fields.update(field["fields"].keys()) + translation_fields = { + field + for field in strings["services"][service_name].get("fields", {}) + if field not in service_schema.get("fields", {}) + } + for field in translation_fields - section_fields: + integration.add_error( + "services", + f"Service {service_name} has a field {field} in the translations file that is not in the schema", + ) + + def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool: """Recursively go through a dir and it's children and find the regex.""" pattern = re.compile(search_pattern) @@ -262,6 +287,10 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa f"Service {service_name} has no description {error_msg_suffix}", ) + check_extraneous_translation_fields( + integration, service_name, strings, service_schema + ) + # The same check is done for the description in each of the fields of the # service schema. for field_name, field_schema in service_schema.get("fields", {}).items(): From 596a3fc879fcccecd9c3d41f9b85d95cff9424aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 12:06:51 -0500 Subject: [PATCH 12/25] Add async_current_scanners API to Bluetooth integration (#152122) --- .../components/bluetooth/__init__.py | 2 + homeassistant/components/bluetooth/api.py | 16 +++++ tests/components/bluetooth/test_api.py | 67 +++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 8568724c0b137a..3559adfd976912 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -57,6 +57,7 @@ _get_manager, async_address_present, async_ble_device_from_address, + async_current_scanners, async_discovered_service_info, async_get_advertisement_callback, async_get_fallback_availability_interval, @@ -114,6 +115,7 @@ "HomeAssistantRemoteScanner", "async_address_present", "async_ble_device_from_address", + "async_current_scanners", "async_discovered_service_info", "async_get_advertisement_callback", "async_get_fallback_availability_interval", diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 00e585fa266c3c..f12d22cc8b528d 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -66,6 +66,22 @@ def async_scanner_count(hass: HomeAssistant, connectable: bool = True) -> int: return _get_manager(hass).async_scanner_count(connectable) +@hass_callback +def async_current_scanners(hass: HomeAssistant) -> list[BaseHaScanner]: + """Return the list of currently active scanners. + + This method returns a list of all active Bluetooth scanners registered + with Home Assistant, including both connectable and non-connectable scanners. + + Args: + hass: Home Assistant instance + + Returns: + List of all active scanner instances + """ + return _get_manager(hass).async_current_scanners() + + @hass_callback def async_discovered_service_info( hass: HomeAssistant, connectable: bool = True diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 74373da68659a4..2afd59e83cffc4 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -9,6 +9,7 @@ from homeassistant.components.bluetooth import ( MONOTONIC_TIME, BaseHaRemoteScanner, + BluetoothScanningMode, HaBluetoothConnector, async_scanner_by_source, async_scanner_devices_by_address, @@ -16,6 +17,7 @@ from homeassistant.core import HomeAssistant from . import ( + FakeRemoteScanner, FakeScanner, MockBleakClient, _get_manager, @@ -161,3 +163,68 @@ def discovered_devices_and_advertisement_data( assert devices[0].ble_device.name == switchbot_device.name assert devices[0].advertisement.local_name == switchbot_device_adv.local_name cancel() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_current_scanners(hass: HomeAssistant) -> None: + """Test getting the list of current scanners.""" + # The enable_bluetooth fixture registers one scanner + initial_scanners = bluetooth.async_current_scanners(hass) + assert len(initial_scanners) == 1 + initial_scanner_count = len(initial_scanners) + + # Verify current_mode is accessible on the initial scanner + for scanner in initial_scanners: + assert hasattr(scanner, "current_mode") + # The mode might be None or a BluetoothScanningMode enum value + + # Register additional connectable scanners + hci0_scanner = FakeScanner("hci0", "hci0") + hci1_scanner = FakeScanner("hci1", "hci1") + cancel_hci0 = bluetooth.async_register_scanner(hass, hci0_scanner) + cancel_hci1 = bluetooth.async_register_scanner(hass, hci1_scanner) + + # Test that the new scanners are added + scanners = bluetooth.async_current_scanners(hass) + assert len(scanners) == initial_scanner_count + 2 + assert hci0_scanner in scanners + assert hci1_scanner in scanners + + # Verify current_mode is accessible on all scanners + for scanner in scanners: + assert hasattr(scanner, "current_mode") + # Verify it's None or the correct type (BluetoothScanningMode) + assert scanner.current_mode is None or isinstance( + scanner.current_mode, BluetoothScanningMode + ) + + # Register non-connectable scanner + connector = HaBluetoothConnector( + MockBleakClient, "mock_bleak_client", lambda: False + ) + hci2_scanner = FakeRemoteScanner("hci2", "hci2", connector, False) + cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner) + + # Test that all scanners are returned (both connectable and non-connectable) + all_scanners = bluetooth.async_current_scanners(hass) + assert len(all_scanners) == initial_scanner_count + 3 + assert hci0_scanner in all_scanners + assert hci1_scanner in all_scanners + assert hci2_scanner in all_scanners + + # Verify current_mode is accessible on all scanners including non-connectable + for scanner in all_scanners: + assert hasattr(scanner, "current_mode") + # The mode should be None or a BluetoothScanningMode instance + assert scanner.current_mode is None or isinstance( + scanner.current_mode, BluetoothScanningMode + ) + + # Clean up our scanners + cancel_hci0() + cancel_hci1() + cancel_hci2() + + # Verify we're back to the initial scanner + final_scanners = bluetooth.async_current_scanners(hass) + assert len(final_scanners) == initial_scanner_count From 531b67101ddaf79446e8fab869101bbde70dfa46 Mon Sep 17 00:00:00 2001 From: wollew Date: Thu, 11 Sep 2025 19:14:59 +0200 Subject: [PATCH 13/25] fix rain sensor for Velux GPU windows (#151857) --- homeassistant/components/velux/binary_sensor.py | 5 +++-- tests/components/velux/test_binary_sensor.py | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py index 15d5d2c89add34..de89005fa67def 100644 --- a/homeassistant/components/velux/binary_sensor.py +++ b/homeassistant/components/velux/binary_sensor.py @@ -59,5 +59,6 @@ async def async_update(self) -> None: LOGGER.error("Error fetching limitation data for cover %s", self.name) return - # Velux windows with rain sensors report an opening limitation of 93 when rain is detected. - self._attr_is_on = limitation.min_value == 93 + # Velux windows with rain sensors report an opening limitation of 93 or 100 (Velux GPU) when rain is detected. + # So far, only 93 and 100 have been observed in practice, documentation on this is non-existent AFAIK. + self._attr_is_on = limitation.min_value in {93, 100} diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py index ecb94d5f58db54..b7048173a65790 100644 --- a/tests/components/velux/test_binary_sensor.py +++ b/tests/components/velux/test_binary_sensor.py @@ -39,13 +39,27 @@ async def test_rain_sensor_state( assert state is not None assert state.state == STATE_OFF - # simulate rain detected + # simulate rain detected (Velux GPU reports 100) + mock_window.get_limitation.return_value.min_value = 100 + await update_polled_entities(hass, freezer) + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_ON + + # simulate rain detected (other Velux models report 93) mock_window.get_limitation.return_value.min_value = 93 await update_polled_entities(hass, freezer) state = hass.states.get(test_entity_id) assert state is not None assert state.state == STATE_ON + # simulate no rain detected again + mock_window.get_limitation.return_value.min_value = 95 + await update_polled_entities(hass, freezer) + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_OFF + @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.usefixtures("mock_module") From 442b6e9cca4f3547e9142bbe7f7c86eb65fb28b0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 11 Sep 2025 19:35:24 +0200 Subject: [PATCH 14/25] Add initial support for Tuya sjz category (electric desk) (#152036) --- homeassistant/components/tuya/select.py | 8 +++ homeassistant/components/tuya/strings.json | 9 +++ homeassistant/components/tuya/switch.py | 8 +++ .../components/tuya/snapshots/test_init.ambr | 2 +- .../tuya/snapshots/test_select.ambr | 61 +++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 +++++++++++++++ 6 files changed, 135 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 3db45631455cd1..1452a15b688f3d 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -248,6 +248,14 @@ entity_category=EntityCategory.CONFIG, ), ), + # Electric desk + "sjz": ( + SelectEntityDescription( + key=DPCode.LEVEL, + translation_key="desk_level", + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Camera # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 "sp": ( diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 44aec569017277..0d0609ba25057e 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -509,6 +509,15 @@ "interim": "Interim" } }, + "desk_level": { + "name": "Level", + "state": { + "level_1": "Level 1", + "level_2": "Level 2", + "level_3": "Level 3", + "level_4": "Level 4" + } + }, "inverter_work_mode": { "name": "Inverter work mode", "state": { diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index bc1da9ec1fb987..62ea4d86b3d0c4 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -662,6 +662,14 @@ entity_category=EntityCategory.CONFIG, ), ), + # Electric desk + "sjz": ( + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Camera # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 "sp": ( diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 6fa5e0167fe508..a3b0b0b10c8d0f 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -6716,7 +6716,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'geniodesk (unsupported)', + 'model': 'geniodesk', 'model_id': 'ftbc8rp8ipksdfpv', 'name': 'mesa', 'name_by_user': None, diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index fa568136a59997..431dbd153d8ea8 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -3136,6 +3136,67 @@ 'state': 'power_on', }) # --- +# name: test_platform_setup_and_discovery[select.mesa_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mesa_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'desk_level', + 'unique_id': 'tuya.vpfdskpi8pr8cbtfzjslevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.mesa_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mesa Level', + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + ]), + }), + 'context': , + 'entity_id': 'select.mesa_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level_1', + }) +# --- # name: test_platform_setup_and_discovery[select.mirilla_puerta_anti_flicker-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index e35d53b38a9da5..1b481daa945728 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -5513,6 +5513,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.mesa_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mesa_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.vpfdskpi8pr8cbtfzjschild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.mesa_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mesa Child lock', + }), + 'context': , + 'entity_id': 'switch.mesa_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.mesh_gateway_mute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From fea7f537a8f55e8ae20933d9fca1fad72cf89b81 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Fri, 12 Sep 2025 02:39:01 +0900 Subject: [PATCH 15/25] Bump thinqconnect to 1.0.8 (#152100) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lg_thinq/snapshots/test_climate.ambr | 6 ++---- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index 0abc74d19a4f2b..c1e620b1f860f9 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==1.0.7"] + "requirements": ["thinqconnect==1.0.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7be491a2b029d4..e80cd8deaf461b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2952,7 +2952,7 @@ thermopro-ble==0.13.1 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.7 +thinqconnect==1.0.8 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 13d55c4810b7f4..2bc9868bf482bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2435,7 +2435,7 @@ thermobeacon-ble==0.10.0 thermopro-ble==0.13.1 # homeassistant.components.lg_thinq -thinqconnect==1.0.7 +thinqconnect==1.0.8 # homeassistant.components.tilt_ble tilt-ble==0.3.1 diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index 5c05244b3139ce..513405d1005d7d 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -54,7 +54,7 @@ 'platform': 'lg_thinq', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner', 'unit_of_measurement': None, @@ -84,7 +84,7 @@ 'none', 'air_clean', ]), - 'supported_features': , + 'supported_features': , 'swing_horizontal_mode': 'off', 'swing_horizontal_modes': list([ 'on', @@ -95,8 +95,6 @@ 'on', 'off', ]), - 'target_temp_high': None, - 'target_temp_low': None, 'target_temp_step': 2, 'temperature': 66, }), From 4ad29161bdfee0b1a329a2dce3bebf18fb691e22 Mon Sep 17 00:00:00 2001 From: markhannon Date: Fri, 12 Sep 2025 03:42:24 +1000 Subject: [PATCH 16/25] Bump zcc-helper to 3.7 (#151807) --- homeassistant/components/zimi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zimi/manifest.json b/homeassistant/components/zimi/manifest.json index 58a56c978305a4..718857c4518ac9 100644 --- a/homeassistant/components/zimi/manifest.json +++ b/homeassistant/components/zimi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/zimi", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["zcc-helper==3.6"] + "requirements": ["zcc-helper==3.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index e80cd8deaf461b..ac5102e60184d1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3205,7 +3205,7 @@ zabbix-utils==2.0.3 zamg==0.3.6 # homeassistant.components.zimi -zcc-helper==3.6 +zcc-helper==3.7 # homeassistant.components.zeroconf zeroconf==0.147.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2bc9868bf482bf..e9f0735703e80e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2649,7 +2649,7 @@ yt-dlp[default]==2025.09.05 zamg==0.3.6 # homeassistant.components.zimi -zcc-helper==3.6 +zcc-helper==3.7 # homeassistant.components.zeroconf zeroconf==0.147.2 From 4f045b45ac18f6851f65b8aede0cc9a31deab6d8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 11 Sep 2025 19:43:44 +0200 Subject: [PATCH 17/25] Fix supported _color_modes attribute not set for on/off MQTT JSON light (#152126) --- homeassistant/components/mqtt/light/schema_json.py | 2 ++ tests/components/mqtt/test_light_json.py | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index fc76d4bcf6c619..f71a333dbe1085 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -223,6 +223,8 @@ def _setup_from_config(self, config: ConfigType) -> None: # Brightness is supported and no supported_color_modes are set, # so set brightness as the supported color mode. self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + else: + self._attr_supported_color_modes = {ColorMode.ONOFF} def _update_color(self, values: dict[str, Any]) -> None: color_mode: str = values["color_mode"] diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 7f7f32c4e43aba..8c32926e08e877 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -182,6 +182,19 @@ def __eq__(self, other: bytes | str) -> bool: # type:ignore[override] return json_loads(self.jsondata) == json_loads(other) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_simple_on_off_light( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test if setup fails with no command topic.""" + assert await mqtt_mock_entry() + state = hass.states.get("light.test") + assert state and state.state == STATE_UNKNOWN + assert state.attributes["supported_color_modes"] == ["onoff"] + + @pytest.mark.parametrize( "hass_config", [{mqtt.DOMAIN: {light.DOMAIN: {"schema": "json", "name": "test"}}}] ) From c5d552dc4ae8ec309c8de7bd50ea1ae0df977595 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 11 Sep 2025 19:45:21 +0200 Subject: [PATCH 18/25] Use translation_key in Tuya dr category (electric blanket) (#152099) --- homeassistant/components/tuya/select.py | 9 +++--- homeassistant/components/tuya/strings.json | 16 +++++++++++ .../tuya/snapshots/test_select.ambr | 28 +++++++++---------- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 1452a15b688f3d..8b62ed36a52f7c 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -78,21 +78,20 @@ "dr": ( SelectEntityDescription( key=DPCode.LEVEL, - name="Level", icon="mdi:thermometer-lines", translation_key="blanket_level", ), SelectEntityDescription( key=DPCode.LEVEL_1, - name="Side A Level", icon="mdi:thermometer-lines", - translation_key="blanket_level", + translation_key="indexed_blanket_level", + translation_placeholders={"index": "1"}, ), SelectEntityDescription( key=DPCode.LEVEL_2, - name="Side B Level", icon="mdi:thermometer-lines", - translation_key="blanket_level", + translation_key="indexed_blanket_level", + translation_placeholders={"index": "2"}, ), ), # Fan diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 0d0609ba25057e..d5d9bdaeeed15d 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -489,6 +489,7 @@ } }, "blanket_level": { + "name": "Level", "state": { "level_1": "[%key:common::state::low%]", "level_2": "Level 2", @@ -502,6 +503,21 @@ "level_10": "[%key:common::state::high%]" } }, + "indexed_blanket_level": { + "name": "Level {index}", + "state": { + "level_1": "[%key:common::state::low%]", + "level_2": "[%key:component::tuya::entity::select::blanket_level::state::level_2%]", + "level_3": "[%key:component::tuya::entity::select::blanket_level::state::level_3%]", + "level_4": "[%key:component::tuya::entity::select::blanket_level::state::level_4%]", + "level_5": "[%key:component::tuya::entity::select::blanket_level::state::level_5%]", + "level_6": "[%key:component::tuya::entity::select::blanket_level::state::level_6%]", + "level_7": "[%key:component::tuya::entity::select::blanket_level::state::level_7%]", + "level_8": "[%key:component::tuya::entity::select::blanket_level::state::level_8%]", + "level_9": "[%key:component::tuya::entity::select::blanket_level::state::level_9%]", + "level_10": "[%key:common::state::high%]" + } + }, "odor_elimination_mode": { "name": "Odor elimination mode", "state": { diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 431dbd153d8ea8..1a5061f3b1a0fb 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -4333,7 +4333,7 @@ 'state': 'level_5', }) # --- -# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_a_level-entry] +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_level_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4359,7 +4359,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': None, - 'entity_id': 'select.sunbeam_bedding_side_a_level', + 'entity_id': 'select.sunbeam_bedding_level_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4371,20 +4371,20 @@ }), 'original_device_class': None, 'original_icon': 'mdi:thermometer-lines', - 'original_name': 'Side A Level', + 'original_name': 'Level 1', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'blanket_level', + 'translation_key': 'indexed_blanket_level', 'unique_id': 'tuya.fasvixqysw1lxvjprdlevel_1', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_a_level-state] +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_level_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sunbeam Bedding Side A Level', + 'friendly_name': 'Sunbeam Bedding Level 1', 'icon': 'mdi:thermometer-lines', 'options': list([ 'level_1', @@ -4400,14 +4400,14 @@ ]), }), 'context': , - 'entity_id': 'select.sunbeam_bedding_side_a_level', + 'entity_id': 'select.sunbeam_bedding_level_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'level_5', }) # --- -# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_b_level-entry] +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_level_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4433,7 +4433,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': None, - 'entity_id': 'select.sunbeam_bedding_side_b_level', + 'entity_id': 'select.sunbeam_bedding_level_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4445,20 +4445,20 @@ }), 'original_device_class': None, 'original_icon': 'mdi:thermometer-lines', - 'original_name': 'Side B Level', + 'original_name': 'Level 2', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'blanket_level', + 'translation_key': 'indexed_blanket_level', 'unique_id': 'tuya.fasvixqysw1lxvjprdlevel_2', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_b_level-state] +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_level_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sunbeam Bedding Side B Level', + 'friendly_name': 'Sunbeam Bedding Level 2', 'icon': 'mdi:thermometer-lines', 'options': list([ 'level_1', @@ -4474,7 +4474,7 @@ ]), }), 'context': , - 'entity_id': 'select.sunbeam_bedding_side_b_level', + 'entity_id': 'select.sunbeam_bedding_level_2', 'last_changed': , 'last_reported': , 'last_updated': , From 0acd77e60ad1efae4ed472fe25ff3f45e5360076 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 11 Sep 2025 12:46:36 -0500 Subject: [PATCH 19/25] Only use media path for TTS stream override (#152084) --- homeassistant/components/tts/__init__.py | 56 +++++++------- tests/components/tts/test_init.py | 99 ++++++------------------ 2 files changed, 54 insertions(+), 101 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index f05b98a3467557..f1ffc7e0aada18 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -18,6 +18,7 @@ from time import monotonic from typing import Any, Final, Generic, Protocol, TypeVar +import aiofiles from aiohttp import web import mutagen from mutagen.id3 import ID3, TextFrame as ID3Text @@ -27,7 +28,6 @@ from homeassistant.components import ffmpeg, websocket_api from homeassistant.components.http import HomeAssistantView from homeassistant.components.media_source import ( - async_resolve_media, generate_media_source_id as ms_generate_media_source_id, ) from homeassistant.config_entries import ConfigEntry @@ -43,7 +43,6 @@ ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url @@ -503,7 +502,7 @@ class ResultStream: _manager: SpeechManager # Override - _override_media_id: str | None = None + _override_media_path: Path | None = None @cached_property def url(self) -> str: @@ -556,7 +555,7 @@ def async_set_message_stream(self, message_stream: AsyncGenerator[str]) -> None: async def async_stream_result(self) -> AsyncGenerator[bytes]: """Get the stream of this result.""" - if self._override_media_id is not None: + if self._override_media_path is not None: # Overridden async for chunk in self._async_stream_override_result(): yield chunk @@ -570,46 +569,49 @@ async def async_stream_result(self) -> AsyncGenerator[bytes]: self.last_used = monotonic() - def async_override_result(self, media_id: str) -> None: - """Override the TTS stream with a different media id.""" - self._override_media_id = media_id + def async_override_result(self, media_path: str | Path) -> None: + """Override the TTS stream with a different media path.""" + self._override_media_path = Path(media_path) async def _async_stream_override_result(self) -> AsyncGenerator[bytes]: """Get the stream of the overridden result.""" - assert self._override_media_id is not None - media = await async_resolve_media(self.hass, self._override_media_id) + assert self._override_media_path is not None - # Determine if we need to do audio conversion - preferred_extension: str | None = self.options.get(ATTR_PREFERRED_FORMAT) - sample_rate: int | None = self.options.get(ATTR_PREFERRED_SAMPLE_RATE) - sample_channels: int | None = self.options.get(ATTR_PREFERRED_SAMPLE_CHANNELS) - sample_bytes: int | None = self.options.get(ATTR_PREFERRED_SAMPLE_BYTES) + preferred_format = self.options.get(ATTR_PREFERRED_FORMAT) + to_sample_rate = self.options.get(ATTR_PREFERRED_SAMPLE_RATE) + to_sample_channels = self.options.get(ATTR_PREFERRED_SAMPLE_CHANNELS) + to_sample_bytes = self.options.get(ATTR_PREFERRED_SAMPLE_BYTES) needs_conversion = ( - preferred_extension - or (sample_rate is not None) - or (sample_channels is not None) - or (sample_bytes is not None) + (preferred_format is not None) + or (to_sample_rate is not None) + or (to_sample_channels is not None) + or (to_sample_bytes is not None) ) if not needs_conversion: - # Stream directly from URL (no conversion) - session = async_get_clientsession(self.hass) - async with session.get(media.url) as response: - async for chunk in response.content: + # Read file directly (no conversion) + async with aiofiles.open(self._override_media_path, "rb") as media_file: + while True: + chunk = await media_file.read(FFMPEG_CHUNK_SIZE) + if not chunk: + break yield chunk return # Use ffmpeg to convert audio to preferred format + if not preferred_format: + preferred_format = self._override_media_path.suffix[1:] # strip . + converted_audio = _async_convert_audio( self.hass, from_extension=None, - audio_input=media.path or media.url, - to_extension=preferred_extension, - to_sample_rate=sample_rate, - to_sample_channels=sample_channels, - to_sample_bytes=sample_bytes, + audio_input=self._override_media_path, + to_extension=preferred_format, + to_sample_rate=self.options.get(ATTR_PREFERRED_SAMPLE_RATE), + to_sample_channels=self.options.get(ATTR_PREFERRED_SAMPLE_CHANNELS), + to_sample_bytes=self.options.get(ATTR_PREFERRED_SAMPLE_BYTES), ) async for chunk in converted_audio: yield chunk diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 21cb6528480b13..dc50f18d5e1912 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -12,7 +12,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components import ffmpeg, media_source, tts +from homeassistant.components import ffmpeg, tts from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, @@ -43,7 +43,6 @@ ) from tests.common import MockModule, async_mock_service, mock_integration, mock_platform -from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator ORIG_WRITE_TAGS = tts.SpeechManager.write_tags @@ -2070,49 +2069,14 @@ async def test_async_internal_get_tts_audio_called( async def test_stream_override( - hass: HomeAssistant, - mock_tts_entity: MockTTSEntity, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test overriding streams with a media id.""" - await mock_config_entry_setup(hass, mock_tts_entity) - - stream = tts.async_create_stream(hass, mock_tts_entity.entity_id) - stream.async_set_message("beer") - stream.async_override_result("test-media-id") - - url = "http://www.home-assistant.io/resolved.mp3" - test_data = b"override-data" - aioclient_mock.get(url, content=test_data) - - with patch( - "homeassistant.components.tts.async_resolve_media", - return_value=media_source.PlayMedia(url=url, mime_type="audio/mp3"), - ): - result_data = b"".join([chunk async for chunk in stream.async_stream_result()]) - assert result_data == test_data - - -async def test_stream_override_with_conversion( hass: HomeAssistant, mock_tts_entity: MockTTSEntity ) -> None: - """Test overriding streams with a media id that requires conversion.""" + """Test overriding streams with a media path.""" await mock_config_entry_setup(hass, mock_tts_entity) - stream = tts.async_create_stream( - hass, - mock_tts_entity.entity_id, - options={ - tts.ATTR_PREFERRED_FORMAT: "wav", - tts.ATTR_PREFERRED_SAMPLE_RATE: 22050, - tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, - tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 2, - }, - ) + stream = tts.async_create_stream(hass, mock_tts_entity.entity_id) stream.async_set_message("beer") - stream.async_override_result("test-media-id") - # Use a temp file here since ffmpeg will read it directly with tempfile.NamedTemporaryFile(mode="wb+", suffix=".wav") as wav_file: with wave.open(wav_file, "wb") as wav_writer: wav_writer.setframerate(16000) @@ -2122,38 +2086,36 @@ async def test_stream_override_with_conversion( wav_file.seek(0) - url = f"file://{wav_file.name}" - with patch( - "homeassistant.components.tts.async_resolve_media", - return_value=media_source.PlayMedia(url=url, mime_type="audio/wav"), - ): - result_data = b"".join( - [chunk async for chunk in stream.async_stream_result()] - ) + stream.async_override_result(wav_file.name) + result_data = b"".join([chunk async for chunk in stream.async_stream_result()]) - # Verify the preferred format + # Verify the result with io.BytesIO(result_data) as wav_io, wave.open(wav_io, "rb") as wav_reader: - assert wav_reader.getframerate() == 22050 + assert wav_reader.getframerate() == 16000 assert wav_reader.getsampwidth() == 2 - assert wav_reader.getnchannels() == 2 + assert wav_reader.getnchannels() == 1 assert wav_reader.readframes(wav_reader.getnframes()) == bytes( - 22050 * 2 * 2 - ) # 1 second @ 22.5Khz/stereo + 16000 * 2 + ) # 1 second @ 16Khz/mono -async def test_stream_override_with_conversion_path_preferred( +async def test_stream_override_with_conversion( hass: HomeAssistant, mock_tts_entity: MockTTSEntity ) -> None: - """Test overriding streams with a media id that requires conversion and has a path.""" + """Test overriding streams with a media path that requires conversion.""" await mock_config_entry_setup(hass, mock_tts_entity) stream = tts.async_create_stream( hass, mock_tts_entity.entity_id, - options={tts.ATTR_PREFERRED_FORMAT: "wav"}, + options={ + tts.ATTR_PREFERRED_FORMAT: "wav", + tts.ATTR_PREFERRED_SAMPLE_RATE: 22050, + tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 2, + }, ) stream.async_set_message("beer") - stream.async_override_result("test-media-id") # Use a temp file here since ffmpeg will read it directly with tempfile.NamedTemporaryFile(mode="wb+", suffix=".wav") as wav_file: @@ -2164,25 +2126,14 @@ async def test_stream_override_with_conversion_path_preferred( wav_writer.writeframes(bytes(16000 * 2)) # 1 second @ 16Khz/mono wav_file.seek(0) + stream.async_override_result(wav_file.name) + result_data = b"".join([chunk async for chunk in stream.async_stream_result()]) - # Path is preferred over URL - with patch( - "homeassistant.components.tts.async_resolve_media", - return_value=media_source.PlayMedia( - path=Path(wav_file.name), - url="http://bad-url.com", - mime_type="audio/wav", - ), - ): - result_data = b"".join( - [chunk async for chunk in stream.async_stream_result()] - ) - - # Verify the preferred format + # Verify the result has the preferred format with io.BytesIO(result_data) as wav_io, wave.open(wav_io, "rb") as wav_reader: - assert wav_reader.getframerate() == 16000 + assert wav_reader.getframerate() == 22050 assert wav_reader.getsampwidth() == 2 - assert wav_reader.getnchannels() == 1 + assert wav_reader.getnchannels() == 2 assert wav_reader.readframes(wav_reader.getnframes()) == bytes( - 16000 * 2 - ) # 1 second @ 16Khz/mono + 22050 * 2 * 2 + ) # 1 second @ 22.5Khz/stereo From 5413131885d4ac1b2a60f93aa06f370a37f8547e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 11 Sep 2025 19:49:13 +0200 Subject: [PATCH 20/25] Replace "cook time" with correct "cooking time" in `matter` (#152110) --- homeassistant/components/matter/strings.json | 2 +- tests/components/matter/snapshots/test_number.ambr | 12 ++++++------ tests/components/matter/test_number.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index e1ec444004eaaf..7dae7638d8d623 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -193,7 +193,7 @@ "name": "Altitude above sea level" }, "cook_time": { - "name": "Cook time" + "name": "Cooking time" }, "pump_setpoint": { "name": "Setpoint" diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 36f7d0d3ca9aa9..605ec6c1649d8b 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -751,7 +751,7 @@ 'state': '255', }) # --- -# name: test_numbers[microwave_oven][number.microwave_oven_cook_time-entry] +# name: test_numbers[microwave_oven][number.microwave_oven_cooking_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -769,7 +769,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': None, - 'entity_id': 'number.microwave_oven_cook_time', + 'entity_id': 'number.microwave_oven_cooking_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -781,7 +781,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Cook time', + 'original_name': 'Cooking time', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -791,11 +791,11 @@ 'unit_of_measurement': , }) # --- -# name: test_numbers[microwave_oven][number.microwave_oven_cook_time-state] +# name: test_numbers[microwave_oven][number.microwave_oven_cooking_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'Microwave Oven Cook time', + 'friendly_name': 'Microwave Oven Cooking time', 'max': 86400, 'min': 1, 'mode': , @@ -803,7 +803,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.microwave_oven_cook_time', + 'entity_id': 'number.microwave_oven_cooking_time', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index d35a889a436dbe..d544562afecec1 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -212,7 +212,7 @@ async def test_microwave_oven( """Test Cooktime for microwave oven.""" # Cooktime on MicrowaveOvenControl cluster (1/96/2) - state = hass.states.get("number.microwave_oven_cook_time") + state = hass.states.get("number.microwave_oven_cooking_time") assert state assert state.state == "30" @@ -221,7 +221,7 @@ async def test_microwave_oven( "number", "set_value", { - "entity_id": "number.microwave_oven_cook_time", + "entity_id": "number.microwave_oven_cooking_time", "value": 60, # 60 seconds }, blocking=True, From 27c0df3da88bf0e2a580fbce272b4bfcd65195d0 Mon Sep 17 00:00:00 2001 From: Roland Moers Date: Thu, 11 Sep 2025 21:05:59 +0200 Subject: [PATCH 21/25] Add CPU temperature sensor to AVM FRITZ!Box Tools (#151328) --- homeassistant/components/fritz/sensor.py | 18 ++++++ homeassistant/components/fritz/strings.json | 3 + tests/components/fritz/conftest.py | 21 +++++++ .../fritz/snapshots/test_sensor.ambr | 56 +++++++++++++++++++ 4 files changed, 98 insertions(+) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index e2df5dc6e8be19..8aa48b216cb367 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -20,6 +20,7 @@ EntityCategory, UnitOfDataRate, UnitOfInformation, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -142,6 +143,13 @@ def _retrieve_link_attenuation_received_state( return status.attenuation[1] / 10 # type: ignore[no-any-return] +def _retrieve_cpu_temperature_state( + status: FritzStatus, last_value: float | None +) -> float: + """Return the first CPU temperature value.""" + return status.get_cpu_temperatures()[0] # type: ignore[no-any-return] + + @dataclass(frozen=True, kw_only=True) class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescription): """Describes Fritz sensor entity.""" @@ -274,6 +282,16 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti value_fn=_retrieve_link_attenuation_received_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), + FritzSensorEntityDescription( + key="cpu_temperature", + translation_key="cpu_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=_retrieve_cpu_temperature_state, + is_suitable=lambda info: True, + ), ) diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 75ce6800aab255..87ee28196ad1eb 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -174,6 +174,9 @@ }, "max_kb_s_sent": { "name": "Max connection upload throughput" + }, + "cpu_temperature": { + "name": "CPU Temperature" } } }, diff --git a/tests/components/fritz/conftest.py b/tests/components/fritz/conftest.py index fa92fa37c04f9d..017328ea0eb175 100644 --- a/tests/components/fritz/conftest.py +++ b/tests/components/fritz/conftest.py @@ -27,6 +27,26 @@ def __init__(self, serviceId: str, actions: dict) -> None: self.serviceId = serviceId +class FritzResponseMock: + """Response mocking.""" + + def json(self): + """Mock json method.""" + return {"CPUTEMP": "69,68,67"} + + +class FritzHttpMock: + """FritzHttp mocking.""" + + def __init__(self) -> None: + """Init Mocking class.""" + self.router_url = "http://fritz.box" + + def call_url(self, *args, **kwargs): + """Mock call_url method.""" + return FritzResponseMock() + + class FritzConnectionMock: """FritzConnection mocking.""" @@ -39,6 +59,7 @@ def __init__(self, services) -> None: srv: FritzServiceMock(serviceId=srv, actions=actions) for srv, actions in services.items() } + self.http_interface = FritzHttpMock() LOGGER.debug("-" * 80) LOGGER.debug("FritzConnectionMock - services: %s", self.services) diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index 4efae5951e894d..ac437b28d9959a 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -825,3 +825,59 @@ 'state': '3.4', }) # --- +# name: test_sensor_setup[sensor.mock_title_cpu_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_cpu_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CPU Temperature', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cpu_temperature', + 'unique_id': '1C:ED:6F:12:34:11-cpu_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup[sensor.mock_title_cpu_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Title CPU Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_cpu_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '69', + }) +# --- From 66d1cf8af7cd07f2f4b721faba56c3ca9a569da9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 11 Sep 2025 21:12:02 +0200 Subject: [PATCH 22/25] Add missing period in "H.264" standard name in `onvif` (#152132) --- homeassistant/components/onvif/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index 7988c50b1ace02..b9b9150a887ff6 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -4,7 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "no_h264": "There were no H264 streams available. Check the profile configuration on your device.", + "no_h264": "There were no H.264 streams available. Check the profile configuration on your device.", "no_mac": "Could not configure unique ID for ONVIF device.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, @@ -43,7 +43,7 @@ }, "configure_profile": { "description": "Create camera entity for {profile} at {resolution} resolution?", - "title": "Configure Profiles", + "title": "Configure profiles", "data": { "include": "Create camera entity" } From ae70ca7cba4318c2e432351ea64260f8e28bcdbf Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 11 Sep 2025 21:50:03 +0200 Subject: [PATCH 23/25] Add `sw_version` to Shelly BLU TRV device info (#152129) --- .../components/shelly/binary_sensor.py | 3 ++- homeassistant/components/shelly/button.py | 7 +++++-- homeassistant/components/shelly/climate.py | 5 +++-- homeassistant/components/shelly/number.py | 3 ++- homeassistant/components/shelly/sensor.py | 3 ++- homeassistant/components/shelly/utils.py | 3 ++- tests/components/shelly/conftest.py | 1 + tests/components/shelly/test_devices.py | 21 ++++++++++++++++++- 8 files changed, 37 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index e1261411da3e63..24093ee1562d15 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -98,8 +98,9 @@ def __init__( super().__init__(coordinator, key, attribute, description) ble_addr: str = coordinator.device.config[key]["addr"] + fw_ver = coordinator.device.status[key].get("fw_ver") self._attr_device_info = get_blu_trv_device_info( - coordinator.device.config[key], ble_addr, coordinator.mac + coordinator.device.config[key], ble_addr, coordinator.mac, fw_ver ) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 209fa4af54a2e7..bb8c9971433a20 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -254,11 +254,14 @@ def __init__( """Initialize.""" super().__init__(coordinator, description) - config = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"] + key = f"{BLU_TRV_IDENTIFIER}:{id_}" + config = coordinator.device.config[key] ble_addr: str = config["addr"] + fw_ver = coordinator.device.status[key].get("fw_ver") + self._attr_unique_id = f"{ble_addr}_{description.key}" self._attr_device_info = get_blu_trv_device_info( - config, ble_addr, coordinator.mac + config, ble_addr, coordinator.mac, fw_ver ) self._id = id_ diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 3a495c9f4ac965..8918c5863cfef4 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -557,11 +557,12 @@ def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: super().__init__(coordinator, f"{BLU_TRV_IDENTIFIER}:{id_}") self._id = id_ - self._config = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"] + self._config = coordinator.device.config[self.key] ble_addr: str = self._config["addr"] self._attr_unique_id = f"{ble_addr}-{self.key}" + fw_ver = coordinator.device.status[self.key].get("fw_ver") self._attr_device_info = get_blu_trv_device_info( - self._config, ble_addr, self.coordinator.mac + self._config, ble_addr, self.coordinator.mac, fw_ver ) @property diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 989b30af3992c9..0f3080d53c37d5 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -125,8 +125,9 @@ def __init__( super().__init__(coordinator, key, attribute, description) ble_addr: str = coordinator.device.config[key]["addr"] + fw_ver = coordinator.device.status[key].get("fw_ver") self._attr_device_info = get_blu_trv_device_info( - coordinator.device.config[key], ble_addr, coordinator.mac + coordinator.device.config[key], ble_addr, coordinator.mac, fw_ver ) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index a357ebdbd44e6b..e69e2e76b3dd9f 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -169,8 +169,9 @@ def __init__( super().__init__(coordinator, key, attribute, description) ble_addr: str = coordinator.device.config[key]["addr"] + fw_ver = coordinator.device.status[key].get("fw_ver") self._attr_device_info = get_blu_trv_device_info( - coordinator.device.config[key], ble_addr, coordinator.mac + coordinator.device.config[key], ble_addr, coordinator.mac, fw_ver ) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index a76c27f0eb934e..f1f7ac2a963f4c 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -811,7 +811,7 @@ def get_rpc_device_info( def get_blu_trv_device_info( - config: dict[str, Any], ble_addr: str, parent_mac: str + config: dict[str, Any], ble_addr: str, parent_mac: str, fw_ver: str | None ) -> DeviceInfo: """Return device info for RPC device.""" model_id = config.get("local_name") @@ -823,6 +823,7 @@ def get_blu_trv_device_info( model=BLU_TRV_MODEL_NAME.get(model_id) if model_id else None, model_id=config.get("local_name"), name=config["name"] or f"shellyblutrv-{ble_addr.replace(':', '')}", + sw_version=fw_ver, ) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 47ff723bddcc64..a801caafdba900 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -324,6 +324,7 @@ def mock_white_light_set_state( "rssi": -60, "battery": 100, "errors": [], + "fw_ver": "v1.2.10", }, "blutrv:201": { "id": 0, diff --git a/tests/components/shelly/test_devices.py b/tests/components/shelly/test_devices.py index b1703ea03e9210..71eaeb2a333f57 100644 --- a/tests/components/shelly/test_devices.py +++ b/tests/components/shelly/test_devices.py @@ -2,7 +2,7 @@ from unittest.mock import Mock -from aioshelly.const import MODEL_2PM_G3, MODEL_PRO_EM3 +from aioshelly.const import MODEL_2PM_G3, MODEL_BLU_GATEWAY_G3, MODEL_PRO_EM3 from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -510,3 +510,22 @@ async def test_block_channel_with_name( device_entry = device_registry.async_get(entry.device_id) assert device_entry assert device_entry.name == "Test name" + + +async def test_blu_trv_device_info( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, +) -> None: + """Test BLU TRV device info.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + entry = entity_registry.async_get("climate.trv_name") + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "TRV-Name" + assert device_entry.model_id == "SBTR-001AEU" + assert device_entry.sw_version == "v1.2.10" From 4985f9a5a10117f5ceb009a9c33efd13132733fa Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 11 Sep 2025 22:26:47 +0200 Subject: [PATCH 24/25] Fix reauth for Alexa Devices (#152128) --- .../components/alexa_devices/config_flow.py | 7 +++- tests/components/alexa_devices/conftest.py | 1 + .../alexa_devices/test_config_flow.py | 38 +++++++++++++++++-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index f266a8688547e5..a3bcce1965b272 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -107,7 +107,9 @@ async def async_step_reauth_confirm( if user_input is not None: try: - await validate_input(self.hass, {**reauth_entry.data, **user_input}) + data = await validate_input( + self.hass, {**reauth_entry.data, **user_input} + ) except CannotConnect: errors["base"] = "cannot_connect" except (CannotAuthenticate, TypeError): @@ -119,8 +121,9 @@ async def async_step_reauth_confirm( reauth_entry, data={ CONF_USERNAME: entry_data[CONF_USERNAME], - CONF_PASSWORD: entry_data[CONF_PASSWORD], + CONF_PASSWORD: user_input[CONF_PASSWORD], CONF_CODE: user_input[CONF_CODE], + CONF_LOGIN_DATA: data, }, ) diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index bf35d87cb90a39..d9864fdeb31318 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -45,6 +45,7 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: client = mock_client.return_value client.login_mode_interactive.return_value = { "customer_info": {"user_id": TEST_USERNAME}, + CONF_SITE: "https://www.amazon.com", } client.get_devices_data.return_value = { TEST_SERIAL_NUMBER: deepcopy(TEST_DEVICE) diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py index 9aea6fe4c44fd7..4722f9c0c5f26b 100644 --- a/tests/components/alexa_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -9,7 +9,11 @@ ) import pytest -from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.components.alexa_devices.const import ( + CONF_LOGIN_DATA, + CONF_SITE, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -48,6 +52,7 @@ async def test_full_flow( CONF_PASSWORD: TEST_PASSWORD, CONF_LOGIN_DATA: { "customer_info": {"user_id": TEST_USERNAME}, + CONF_SITE: "https://www.amazon.com", }, } assert result["result"].unique_id == TEST_USERNAME @@ -158,6 +163,16 @@ async def test_reauth_successful( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_CODE: "000000", + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: "other_fake_password", + CONF_LOGIN_DATA: { + "customer_info": {"user_id": TEST_USERNAME}, + CONF_SITE: "https://www.amazon.com", + }, + } + @pytest.mark.parametrize( ("side_effect", "error"), @@ -206,8 +221,15 @@ async def test_reauth_not_successful( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" - assert mock_config_entry.data[CONF_PASSWORD] == "fake_password" - assert mock_config_entry.data[CONF_CODE] == "111111" + assert mock_config_entry.data == { + CONF_CODE: "111111", + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: "fake_password", + CONF_LOGIN_DATA: { + "customer_info": {"user_id": TEST_USERNAME}, + CONF_SITE: "https://www.amazon.com", + }, + } async def test_reconfigure_successful( @@ -240,7 +262,14 @@ async def test_reconfigure_successful( assert reconfigure_result["reason"] == "reconfigure_successful" # changed entry - assert mock_config_entry.data[CONF_PASSWORD] == new_password + assert mock_config_entry.data == { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: new_password, + CONF_LOGIN_DATA: { + "customer_info": {"user_id": TEST_USERNAME}, + CONF_SITE: "https://www.amazon.com", + }, + } @pytest.mark.parametrize( @@ -297,5 +326,6 @@ async def test_reconfigure_fails( CONF_PASSWORD: TEST_PASSWORD, CONF_LOGIN_DATA: { "customer_info": {"user_id": TEST_USERNAME}, + CONF_SITE: "https://www.amazon.com", }, } From b12c45818816184c7c7b56735b26cd9fc99bab8f Mon Sep 17 00:00:00 2001 From: HarvsG <11440490+HarvsG@users.noreply.github.com> Date: Thu, 11 Sep 2025 22:14:51 +0100 Subject: [PATCH 25/25] Log bayesian sensor name for unavailable observations (#152039) --- homeassistant/components/bayesian/binary_sensor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 691576d6b31905..d09e55de77db31 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -497,16 +497,18 @@ def _calculate_new_probability(self) -> float: _LOGGER.debug( ( "Observation for entity '%s' returned None, it will not be used" - " for Bayesian updating" + " for updating Bayesian sensor '%s'" ), observation.entity_id, + self.entity_id, ) continue _LOGGER.debug( ( "Observation for template entity returned None rather than a valid" - " boolean, it will not be used for Bayesian updating" + " boolean, it will not be used for updating Bayesian sensor '%s'" ), + self.entity_id, ) # the prior has been updated and is now the posterior return prior