From 94f906b34cc75a07d3d9963db1a70dff41863e71 Mon Sep 17 00:00:00 2001 From: Aurore <74768535+AuroreVgn@users.noreply.github.com> Date: Wed, 30 Oct 2024 18:41:10 +0100 Subject: [PATCH 0001/1070] Fix timeout issue on Roomba integration when adding a new device (#129230) * Update const.py DEFAULT_DELAY = 1 to DEFAULT_DELAY = 100 to fix timeout when adding a new device * Update config_flow.py continuous=False to continuous=True to fix timeout when adding a new device * Update homeassistant/components/roomba/const.py Co-authored-by: Jan Bouwhuis * Update test_config_flow.py Change CONF_DELAY to match DEFAULT_DELAY (30 sec instead of 1) * Update tests/components/roomba/test_config_flow.py Co-authored-by: Jan Bouwhuis * Use constant for DEFAULT_DELAY in tests --------- Co-authored-by: Jan Bouwhuis Co-authored-by: jbouwh --- .../components/roomba/config_flow.py | 2 +- homeassistant/components/roomba/const.py | 2 +- tests/components/roomba/test_config_flow.py | 29 +++++++++++-------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index d690bcce97818..d0c29faca69d3 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -57,7 +57,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, address=data[CONF_HOST], blid=data[CONF_BLID], password=data[CONF_PASSWORD], - continuous=False, + continuous=True, delay=data[CONF_DELAY], ) ) diff --git a/homeassistant/components/roomba/const.py b/homeassistant/components/roomba/const.py index 331c09006825f..7f1e3b8e1eed6 100644 --- a/homeassistant/components/roomba/const.py +++ b/homeassistant/components/roomba/const.py @@ -9,5 +9,5 @@ CONF_BLID = "blid" DEFAULT_CERT = "/etc/ssl/certs/ca-certificates.crt" DEFAULT_CONTINUOUS = True -DEFAULT_DELAY = 1 +DEFAULT_DELAY = 30 ROOMBA_SESSION = "roomba_session" diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index 8139e42d43d4f..dedccc1424958 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -8,7 +8,12 @@ from homeassistant.components import dhcp, zeroconf from homeassistant.components.roomba import config_flow -from homeassistant.components.roomba.const import CONF_BLID, CONF_CONTINUOUS, DOMAIN +from homeassistant.components.roomba.const import ( + CONF_BLID, + CONF_CONTINUOUS, + DEFAULT_DELAY, + DOMAIN, +) from homeassistant.config_entries import ( SOURCE_DHCP, SOURCE_IGNORE, @@ -206,7 +211,7 @@ async def test_form_user_discovery_and_password_fetch(hass: HomeAssistant) -> No assert result3["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -331,7 +336,7 @@ async def test_form_user_discovery_manual_and_auto_password_fetch( assert result4["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -468,7 +473,7 @@ async def test_form_user_discovery_no_devices_found_and_auto_password_fetch( assert result3["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -541,7 +546,7 @@ async def test_form_user_discovery_no_devices_found_and_password_fetch_fails( assert result4["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -677,7 +682,7 @@ async def test_form_user_discovery_and_password_fetch_gets_connection_refused( assert result4["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -738,7 +743,7 @@ async def test_dhcp_discovery_and_roomba_discovery_finds( assert result2["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -816,7 +821,7 @@ async def test_dhcp_discovery_falls_back_to_manual( assert result4["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -886,7 +891,7 @@ async def test_dhcp_discovery_no_devices_falls_back_to_manual( assert result3["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -1119,10 +1124,10 @@ async def test_options_flow( result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_CONTINUOUS: True, CONF_DELAY: 1}, + user_input={CONF_CONTINUOUS: True, CONF_DELAY: DEFAULT_DELAY}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_CONTINUOUS: True, CONF_DELAY: 1} - assert config_entry.options == {CONF_CONTINUOUS: True, CONF_DELAY: 1} + assert result["data"] == {CONF_CONTINUOUS: True, CONF_DELAY: DEFAULT_DELAY} + assert config_entry.options == {CONF_CONTINUOUS: True, CONF_DELAY: DEFAULT_DELAY} From fa2bfc5d9d1ddec34013f92363a53d5defbc98fe Mon Sep 17 00:00:00 2001 From: cryptk <421501+cryptk@users.noreply.github.com> Date: Wed, 30 Oct 2024 12:43:34 -0500 Subject: [PATCH 0002/1070] Bump uiprotect to 6.3.2 (#129513) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index ae7b2d94f2190..4617a8aae80f6 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==6.3.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==6.3.2", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 4be98eea7358e..e92bd6fe2c618 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2888,7 +2888,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.3.1 +uiprotect==6.3.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7596dd5e23b12..2dfa564b982bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2301,7 +2301,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.3.1 +uiprotect==6.3.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 602ec545798b5f8b3d976160481bc0be8f3fa3d5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 30 Oct 2024 19:32:10 +0100 Subject: [PATCH 0003/1070] Set config_entry explicitly to None in relevant components (#129427) Set config_entry explicitly to None in components --- homeassistant/components/esphome/coordinator.py | 1 + homeassistant/components/evohome/__init__.py | 1 + homeassistant/components/iron_os/coordinator.py | 1 + homeassistant/components/london_underground/coordinator.py | 1 + homeassistant/components/modbus/binary_sensor.py | 1 + homeassistant/components/modbus/sensor.py | 1 + homeassistant/components/nsw_fuel_station/__init__.py | 1 + homeassistant/components/rest/__init__.py | 1 + homeassistant/components/template/coordinator.py | 4 +++- 9 files changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/coordinator.py b/homeassistant/components/esphome/coordinator.py index 284e17fd1831a..b31a74dcf3f01 100644 --- a/homeassistant/components/esphome/coordinator.py +++ b/homeassistant/components/esphome/coordinator.py @@ -31,6 +31,7 @@ def __init__( super().__init__( hass, _LOGGER, + config_entry=None, name="ESPHome Dashboard", update_interval=timedelta(minutes=5), always_update=False, diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 1097f19f47c11..612131919d4af 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -240,6 +240,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: coordinator = DataUpdateCoordinator( hass, _LOGGER, + config_entry=None, name=f"{DOMAIN}_coordinator", update_interval=config[DOMAIN][CONF_SCAN_INTERVAL], update_method=broker.async_update, diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index da82b76f92e6c..32b6da13b57b2 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -60,6 +60,7 @@ def __init__(self, hass: HomeAssistant, github: GitHubAPI) -> None: super().__init__( hass, _LOGGER, + config_entry=None, name=DOMAIN, update_interval=SCAN_INTERVAL_GITHUB, ) diff --git a/homeassistant/components/london_underground/coordinator.py b/homeassistant/components/london_underground/coordinator.py index cf14ad14b4354..29d1e8e2f54a3 100644 --- a/homeassistant/components/london_underground/coordinator.py +++ b/homeassistant/components/london_underground/coordinator.py @@ -24,6 +24,7 @@ def __init__(self, hass: HomeAssistant, data: TubeData) -> None: super().__init__( hass, _LOGGER, + config_entry=None, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 54ee49ed6a276..b50d21faf424c 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -90,6 +90,7 @@ async def async_setup_slaves( self._coordinator = DataUpdateCoordinator( hass, _LOGGER, + config_entry=None, name=name, ) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 4b4fd5bd51a40..d5a16c95cc4de 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -91,6 +91,7 @@ async def async_setup_slaves( self._coordinator = DataUpdateCoordinator( hass, _LOGGER, + config_entry=None, name=name, ) diff --git a/homeassistant/components/nsw_fuel_station/__init__.py b/homeassistant/components/nsw_fuel_station/__init__.py index 76dc9d4c6ffab..85e204b6f5145 100644 --- a/homeassistant/components/nsw_fuel_station/__init__.py +++ b/homeassistant/components/nsw_fuel_station/__init__.py @@ -33,6 +33,7 @@ async def async_update_data(): coordinator = DataUpdateCoordinator( hass, _LOGGER, + config_entry=None, name="sensor", update_interval=SCAN_INTERVAL, update_method=async_update_data, diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 59239ad674483..5695e51933e89 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -180,6 +180,7 @@ async def _async_refresh_with_templates() -> None: return DataUpdateCoordinator( hass, _LOGGER, + config_entry=None, name="rest data", update_method=update_method, update_interval=update_interval, diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index b9bbd3625af4d..4d8fe78f2b51a 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -24,7 +24,9 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: """Instantiate trigger data.""" - super().__init__(hass, _LOGGER, name="Trigger Update Coordinator") + super().__init__( + hass, _LOGGER, config_entry=None, name="Trigger Update Coordinator" + ) self.config = config self._cond_func: Callable[[Mapping[str, Any] | None], bool] | None = None self._unsub_start: Callable[[], None] | None = None From c958cce7697a3dd5ce2d2f965506c37f03d712a1 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 30 Oct 2024 19:34:43 +0100 Subject: [PATCH 0004/1070] Bump Music Assistant Client library to 1.0.5 (#129518) --- homeassistant/components/music_assistant/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index c3e05d7a55f3d..23401f30abc3e 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/music-assistant/hass-music-assistant/issues", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.0.3"], + "requirements": ["music-assistant-client==1.0.5"], "zeroconf": ["_mass._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e92bd6fe2c618..b684846a66a3c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1406,7 +1406,7 @@ mozart-api==4.1.1.116.0 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.3 +music-assistant-client==1.0.5 # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2dfa564b982bc..f06860ab66ece 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1175,7 +1175,7 @@ mozart-api==4.1.1.116.0 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.3 +music-assistant-client==1.0.5 # homeassistant.components.tts mutagen==1.47.0 From 208b15637aa781b590174d357b90f440841f86c2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 30 Oct 2024 20:59:56 +0100 Subject: [PATCH 0005/1070] Bump version to 2024.12 (#129525) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 263f9ed5d6dfb..02e8b4f180d9f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 11 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2024.11" + HA_SHORT_VERSION: "2024.12" DEFAULT_PYTHON: "3.12" ALL_PYTHON_VERSIONS: "['3.12']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 76185b829ca97..1da3b819f9f82 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 -MINOR_VERSION: Final = 11 +MINOR_VERSION: Final = 12 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index ad0bb5fca494f..72a706c09abeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.0.dev0" +version = "2024.12.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 3e32c5093679d4131f16c2452f2bc9f0ddfcb49f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 30 Oct 2024 21:17:03 +0100 Subject: [PATCH 0006/1070] Fix async_config_entry_first_refresh used after config entry is loaded in speedtestdotcom (#129527) * Fix async_config_entry_first_refresh used after config entry is loaded in speedtestdotcom * is --- homeassistant/components/speedtestdotnet/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index aed1cce33dbea..e4c51ab7aa0d2 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -6,7 +6,7 @@ import speedtest -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -35,7 +35,10 @@ async def async_setup_entry( async def _async_finish_startup(hass: HomeAssistant) -> None: """Run this only when HA has finished its startup.""" - await coordinator.async_config_entry_first_refresh() + if config_entry.state is ConfigEntryState.LOADED: + await coordinator.async_refresh() + else: + await coordinator.async_config_entry_first_refresh() # Don't start a speedtest during startup async_at_started(hass, _async_finish_startup) From b451bfed81cc536ae55392ebfc964dc1bdfe9f97 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Wed, 30 Oct 2024 22:22:17 +0100 Subject: [PATCH 0007/1070] Fix bthome UnitOfConductivity (#129535) Fix unit --- homeassistant/components/bthome/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 64e6d61cefb8a..417df9f5068ea 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -364,7 +364,7 @@ ): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}", device_class=SensorDeviceClass.CONDUCTIVITY, - native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS, + native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM, state_class=SensorStateClass.MEASUREMENT, ), } From af144e1b77bfe71427da3675202578d118f2d6e3 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 30 Oct 2024 22:24:07 +0100 Subject: [PATCH 0008/1070] Bump reolink_aio to 0.10.2 (#129528) --- homeassistant/components/reolink/light.py | 1 + homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index d545a87806807..0f239a3081385 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -57,6 +57,7 @@ class ReolinkHostLightEntityDescription( ReolinkLightEntityDescription( key="floodlight", cmd_key="GetWhiteLed", + cmd_id=291, translation_key="floodlight", supported=lambda api, ch: api.supported(ch, "floodLight"), is_on_fn=lambda api, ch: api.whiteled_state(ch), diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 8262c395d3b35..282fe908e4c92 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.10.1"] + "requirements": ["reolink-aio==0.10.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index b684846a66a3c..44b25bf802f0d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2550,7 +2550,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.1 +reolink-aio==0.10.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f06860ab66ece..15330d225e1dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.1 +reolink-aio==0.10.2 # homeassistant.components.rflink rflink==0.0.66 From 1c6ad2fa66942192f77d8544dfc31b37b74cd2c8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 30 Oct 2024 22:56:59 +0100 Subject: [PATCH 0009/1070] Allow importing homeassistant.core.Config until 2025.11 (#129537) --- homeassistant/core.py | 14 ++++++++++++++ tests/test_core.py | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/homeassistant/core.py b/homeassistant/core.py index 6c18da3bcdda8..ab852056353aa 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -83,6 +83,7 @@ Unauthorized, ) from .helpers.deprecation import ( + DeferredDeprecatedAlias, DeprecatedConstantEnum, EnumWithDeprecatedMembers, all_with_deprecated_constants, @@ -184,6 +185,19 @@ class EventStateReportedData(EventStateEventData): _DEPRECATED_SOURCE_YAML = DeprecatedConstantEnum(ConfigSource.YAML, "2025.1") +def _deprecated_core_config() -> Any: + # pylint: disable-next=import-outside-toplevel + from . import core_config + + return core_config.Config + + +# The Config class was moved to core_config in Home Assistant 2024.11 +_DEPRECATED_Config = DeferredDeprecatedAlias( + _deprecated_core_config, "homeassistant.core_config.Config", "2025.11" +) + + # How long to wait until things that run on startup have to finish. TIMEOUT_EVENT_START = 15 diff --git a/tests/test_core.py b/tests/test_core.py index bd5fa62048d2a..67ed99daa09ac 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -48,6 +48,7 @@ callback, get_release_channel, ) +from homeassistant.core_config import Config from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError, @@ -66,6 +67,7 @@ async_capture_events, async_mock_service, help_test_all, + import_and_test_deprecated_alias, import_and_test_deprecated_constant_enum, ) @@ -2994,6 +2996,11 @@ def test_deprecated_constants( import_and_test_deprecated_constant_enum(caplog, ha, enum, "SOURCE_", "2025.1") +def test_deprecated_config(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated Config class.""" + import_and_test_deprecated_alias(caplog, ha, "Config", Config, "2025.11") + + def test_one_time_listener_repr(hass: HomeAssistant) -> None: """Test one time listener repr.""" From efa5838be45d45502cbfd6b6746d619cacd86375 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 30 Oct 2024 23:25:30 +0100 Subject: [PATCH 0010/1070] Add last alert timestamp for tplink waterleak (#128644) * Add last alert timestamp for tplink waterleak * Fix snapshot --- homeassistant/components/tplink/icons.json | 3 ++ homeassistant/components/tplink/sensor.py | 4 ++ homeassistant/components/tplink/strings.json | 3 ++ .../components/tplink/fixtures/features.json | 5 ++ .../tplink/snapshots/test_sensor.ambr | 47 +++++++++++++++++++ 5 files changed, 62 insertions(+) diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index 96ea8f41bb7f0..75d1537320236 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -88,6 +88,9 @@ }, "alarm_source": { "default": "mdi:bell" + }, + "water_alert_timestamp": { + "default": "mdi:clock-alert-outline" } }, "number": { diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index f3d3b1c7b31bd..809d900276800 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -97,6 +97,10 @@ class TPLinkSensorEntityDescription( key="device_time", device_class=SensorDeviceClass.TIMESTAMP, ), + TPLinkSensorEntityDescription( + key="water_alert_timestamp", + device_class=SensorDeviceClass.TIMESTAMP, + ), TPLinkSensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index e4eb484aec9f0..66380434d3215 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -159,6 +159,9 @@ "device_time": { "name": "Device time" }, + "water_alert_timestamp": { + "name": "Last water leak alert" + }, "auto_off_at": { "name": "Auto off at" }, diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index 550592d3f485a..d3526adec8adb 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -303,5 +303,10 @@ "type": "Choice", "category": "Config", "choices": ["low", "normal", "high"] + }, + "water_alert_timestamp": { + "type": "Sensor", + "category": "Info", + "value": "2024-06-24 10:03:11.046643+01:00" } } diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 39682cd4a17a2..739f02e51f0f9 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -358,6 +358,53 @@ 'state': '12', }) # --- +# name: test_states[sensor.my_device_last_water_leak_alert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_last_water_leak_alert', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last water leak alert', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_alert_timestamp', + 'unique_id': '123456789ABCDEFGH_water_alert_timestamp', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_last_water_leak_alert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'my_device Last water leak alert', + }), + 'context': , + 'entity_id': 'sensor.my_device_last_water_leak_alert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-06-24T09:03:11+00:00', + }) +# --- # name: test_states[sensor.my_device_on_since-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 39093fc2bc28c2e09158d5754cfbecbc058800e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Oct 2024 17:56:29 -0500 Subject: [PATCH 0011/1070] Bump yarl to 1.17.1 (#129539) changelog: https://github.com/aio-libs/yarl/compare/v1.17.0...v1.17.1 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index de10176b5f083..acdae25ccdc34 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -66,7 +66,7 @@ voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.1.0 -yarl==1.17.0 +yarl==1.17.1 zeroconf==0.136.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 72a706c09abeb..a745d7732aca0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.17.0", + "yarl==1.17.1", "webrtc-models==0.1.0", ] diff --git a/requirements.txt b/requirements.txt index 281062214aee0..ce6fad44332f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,5 +43,5 @@ uv==0.4.28 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.17.0 +yarl==1.17.1 webrtc-models==0.1.0 From 3656bcf75220dda6c00277fe477322392c396f34 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 31 Oct 2024 17:56:03 +1000 Subject: [PATCH 0012/1070] Fix "home" route in Tesla Fleet & Teslemetry (#129546) * translate Home to home * refactor for mypy * Fix home state * Revert key change * Add testing --- homeassistant/components/tesla_fleet/device_tracker.py | 6 +++++- homeassistant/components/teslemetry/device_tracker.py | 6 +++++- tests/components/tesla_fleet/fixtures/vehicle_data.json | 1 + .../tesla_fleet/snapshots/test_device_tracker.ambr | 2 +- .../components/tesla_fleet/snapshots/test_diagnostics.ambr | 1 + tests/components/teslemetry/fixtures/vehicle_data.json | 1 + .../teslemetry/snapshots/test_device_tracker.ambr | 2 +- tests/components/teslemetry/snapshots/test_diagnostics.ambr | 1 + 8 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py index 62c084c9fe516..d6dcef895a68e 100644 --- a/homeassistant/components/tesla_fleet/device_tracker.py +++ b/homeassistant/components/tesla_fleet/device_tracker.py @@ -4,6 +4,7 @@ from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -84,4 +85,7 @@ def _async_update_attrs(self) -> None: @property def location_name(self) -> str | None: """Return a location name for the current location of the device.""" - return self.get("drive_state_active_route_destination") + location = self.get("drive_state_active_route_destination") + if location == "Home": + return STATE_HOME + return location diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 6577bcf88d641..2b0ffd88cc6da 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -80,4 +81,7 @@ class TeslemetryDeviceTrackerRouteEntity(TeslemetryDeviceTrackerEntity): @property def location_name(self) -> str | None: """Return a location name for the current location of the device.""" - return self.get("drive_state_active_route_destination") + location = self.get("drive_state_active_route_destination") + if location == "Home": + return STATE_HOME + return location diff --git a/tests/components/tesla_fleet/fixtures/vehicle_data.json b/tests/components/tesla_fleet/fixtures/vehicle_data.json index 3845ae4855981..d99bc8de5a809 100644 --- a/tests/components/tesla_fleet/fixtures/vehicle_data.json +++ b/tests/components/tesla_fleet/fixtures/vehicle_data.json @@ -112,6 +112,7 @@ "wiper_blade_heater": false }, "drive_state": { + "active_route_destination": "Home", "active_route_latitude": 30.2226265, "active_route_longitude": -97.6236871, "active_route_miles_to_arrival": 0.039491, diff --git a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr index 194eda6fcff2d..02ad4b0100268 100644 --- a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr +++ b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr @@ -96,6 +96,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'not_home', + 'state': 'home', }) # --- diff --git a/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr b/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr index 902c7af131ef6..eb8c57910a490 100644 --- a/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr +++ b/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr @@ -269,6 +269,7 @@ 'climate_state_timestamp': 1705707520649, 'climate_state_wiper_blade_heater': False, 'color': None, + 'drive_state_active_route_destination': 'Home', 'drive_state_active_route_latitude': '**REDACTED**', 'drive_state_active_route_longitude': '**REDACTED**', 'drive_state_active_route_miles_to_arrival': 0.039491, diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index 3845ae4855981..d99bc8de5a809 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -112,6 +112,7 @@ "wiper_blade_heater": false }, "drive_state": { + "active_route_destination": "Home", "active_route_latitude": 30.2226265, "active_route_longitude": -97.6236871, "active_route_miles_to_arrival": 0.039491, diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr index 9859d9db36019..6c18cdf75c6b2 100644 --- a/tests/components/teslemetry/snapshots/test_device_tracker.ambr +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -96,6 +96,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'not_home', + 'state': 'home', }) # --- diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 11f8a91c1aac9..3b96d6f70c0d8 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -270,6 +270,7 @@ 'climate_state_timestamp': 1705707520649, 'climate_state_wiper_blade_heater': False, 'color': None, + 'drive_state_active_route_destination': 'Home', 'drive_state_active_route_latitude': '**REDACTED**', 'drive_state_active_route_longitude': '**REDACTED**', 'drive_state_active_route_miles_to_arrival': 0.039491, From 5e674ce1d0191dfdd8268d2cddd3bb8fd5beea2c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 31 Oct 2024 09:49:27 +0100 Subject: [PATCH 0013/1070] Log Reolink select value KeyError only once (#129559) --- homeassistant/components/reolink/select.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index b4175d4106966..1306c881059c5 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -272,7 +272,7 @@ def current_option(self) -> str | None: try: option = self.entity_description.value(self._host.api, self._channel) - except ValueError: + except (ValueError, KeyError): if self._log_error: _LOGGER.exception("Reolink '%s' has an unknown value", self.name) self._log_error = False @@ -314,7 +314,7 @@ def current_option(self) -> str | None: """Return the current option.""" try: option = self.entity_description.value(self._chime) - except ValueError: + except (ValueError, KeyError): if self._log_error: _LOGGER.exception("Reolink '%s' has an unknown value", self.name) self._log_error = False From 8b1b14a704e753a6b1164432cfa887d688dfc3c4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 31 Oct 2024 09:50:32 +0100 Subject: [PATCH 0014/1070] Missing config_flow in manifest for local_file (#129529) --- homeassistant/components/local_file/manifest.json | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/local_file/manifest.json b/homeassistant/components/local_file/manifest.json index 46268ff2a77f3..0e6e64d17e5cf 100644 --- a/homeassistant/components/local_file/manifest.json +++ b/homeassistant/components/local_file/manifest.json @@ -2,6 +2,7 @@ "domain": "local_file", "name": "Local File", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_file", "iot_class": "local_polling" } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9814095555270..923b2ec1606df 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -336,6 +336,7 @@ "litterrobot", "livisi", "local_calendar", + "local_file", "local_ip", "local_todo", "locative", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7d8383c90cd27..449d36da4749a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3398,7 +3398,7 @@ "local_file": { "name": "Local File", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "local_ip": { From 2bd5039f28e639439dfd6da216f51921072395f3 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 31 Oct 2024 10:04:51 +0100 Subject: [PATCH 0015/1070] Fix capitalization in Philips Hue strings (#129552) --- homeassistant/components/hue/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index ab1d0fb58ad18..2f7f2e555615a 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -137,15 +137,15 @@ "services": { "hue_activate_scene": { "name": "Activate scene", - "description": "Activates a hue scene stored in the hue hub.", + "description": "Activates a Hue scene stored in the Hue hub.", "fields": { "group_name": { "name": "Group", - "description": "Name of hue group/room from the hue app." + "description": "Name of Hue group/room from the Hue app." }, "scene_name": { "name": "Scene", - "description": "Name of hue scene from the hue app." + "description": "Name of Hue scene from the Hue app." }, "dynamic": { "name": "Dynamic", From 60d3c9342d12e759dd5d14272a1b084a0cb05580 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Thu, 31 Oct 2024 13:20:59 +0100 Subject: [PATCH 0016/1070] Fix flakey test in Husqvarna Automower (#129571) --- tests/components/husqvarna_automower/test_init.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index b2127145372a3..ca0c2a04af1a7 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -255,6 +255,7 @@ async def test_add_and_remove_work_area( del values[TEST_MOWER_ID].work_area_dict[123456] del values[TEST_MOWER_ID].work_areas[123456] del values[TEST_MOWER_ID].calendar.tasks[:2] + values[TEST_MOWER_ID].mower.work_area_id = 654321 mock_automower_client.get_status.return_value = values freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) From 8eaec56c6b4171c10833987d3995fc4cb5da3cf4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 31 Oct 2024 13:54:27 +0100 Subject: [PATCH 0017/1070] Stringify discovered hassio uuid (#129572) * Stringify discovered hassio uuid * Correct DiscoveryKey * Adjust tests --- homeassistant/components/hassio/discovery.py | 4 ++-- tests/components/hassio/test_discovery.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 802f2f56b77ae..8166b0f2c7ef5 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -130,11 +130,11 @@ async def async_process_new(self, data: Discovery) -> None: config=data.config, name=addon_info.name, slug=data.addon, - uuid=data.uuid, + uuid=str(data.uuid), ), discovery_key=discovery_flow.DiscoveryKey( domain=DOMAIN, - key=data.uuid, + key=str(data.uuid), version=1, ), ) diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index df84fbd6ec921..09bcc251e6f50 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -91,7 +91,7 @@ async def test_hassio_discovery_startup( }, name="Mosquitto Test", slug="mosquitto", - uuid=uuid, + uuid=str(uuid), ) ) @@ -153,7 +153,7 @@ async def test_hassio_discovery_startup_done( }, name="Mosquitto Test", slug="mosquitto", - uuid=uuid, + uuid=str(uuid), ) ) @@ -203,7 +203,7 @@ async def test_hassio_discovery_webhook( }, name="Mosquitto Test", slug="mosquitto", - uuid=uuid, + uuid=str(uuid), ) ) @@ -283,7 +283,7 @@ async def test_hassio_rediscover( ) expected_context = { - "discovery_key": DiscoveryKey(domain="hassio", key=uuid, version=1), + "discovery_key": DiscoveryKey(domain="hassio", key=str(uuid), version=1), "source": config_entries.SOURCE_HASSIO, } From 6a32722acc861823df85042652fa319abe50ec9a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 31 Oct 2024 14:57:09 +0100 Subject: [PATCH 0018/1070] Fix current temperature calculation for incomfort boiler (#129496) --- .../components/incomfort/water_heater.py | 6 ++- .../components/incomfort/test_water_heater.py | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 28424069d1cb3..e7620ac2a1a37 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -54,12 +54,16 @@ def extra_state_attributes(self) -> dict[str, Any]: return {k: v for k, v in self._heater.status.items() if k in HEATER_ATTRS} @property - def current_temperature(self) -> float: + def current_temperature(self) -> float | None: """Return the current temperature.""" if self._heater.is_tapping: return self._heater.tap_temp if self._heater.is_pumping: return self._heater.heater_temp + if self._heater.heater_temp is None: + return self._heater.tap_temp + if self._heater.tap_temp is None: + return self._heater.heater_temp return max(self._heater.heater_temp, self._heater.tap_temp) @property diff --git a/tests/components/incomfort/test_water_heater.py b/tests/components/incomfort/test_water_heater.py index 5b7aebc50a801..082aecf6d497c 100644 --- a/tests/components/incomfort/test_water_heater.py +++ b/tests/components/incomfort/test_water_heater.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import pytest from syrupy import SnapshotAssertion from homeassistant.config_entries import ConfigEntry @@ -9,6 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import MOCK_HEATER_STATUS + from tests.common import snapshot_platform @@ -23,3 +26,44 @@ async def test_setup_platform( """Test the incomfort entities are set up correctly.""" await hass.config_entries.async_setup(mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("mock_heater_status", "current_temperature"), + [ + (MOCK_HEATER_STATUS, 35.3), + (MOCK_HEATER_STATUS | {"is_tapping": True}, 30.2), + (MOCK_HEATER_STATUS | {"is_pumping": True}, 35.3), + (MOCK_HEATER_STATUS | {"heater_temp": None}, 30.2), + (MOCK_HEATER_STATUS | {"tap_temp": None}, 35.3), + (MOCK_HEATER_STATUS | {"heater_temp": None, "tap_temp": None}, None), + ], + ids=[ + "both_temps_available_choose_highest", + "is_tapping_choose_tapping_temp", + "is_pumping_choose_heater_temp", + "heater_temp_not_available_choose_tapping_temp", + "tapping_temp_not_available_choose_heater_temp", + "tapping_and_heater_temp_not_available_unknown", + ], +) +@patch("homeassistant.components.incomfort.PLATFORMS", [Platform.WATER_HEATER]) +async def test_current_temperature_cases( + hass: HomeAssistant, + mock_incomfort: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: ConfigEntry, + current_temperature: float | None, +) -> None: + """Test incomfort entities with alternate current temperature calculation. + + The boilers current temperature is calculated from the testdata: + heater_temp: 35.34 + tap_temp: 30.21 + + It is based on the operating mode as the boiler can heat tap water or + the house. + """ + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert (state := hass.states.get("water_heater.boiler")) is not None + assert state.attributes.get("current_temperature") == current_temperature From 696efe349e5ec8a4cd5ac3ba01daac2540d910ea Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 31 Oct 2024 15:10:27 +0100 Subject: [PATCH 0019/1070] Log type as well as value for unique_id checks (#129575) --- homeassistant/config_entries.py | 3 ++- tests/test_config_entries.py | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ebd460d3cdbd8..e99c730145e1e 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1638,11 +1638,12 @@ def check_unique_id(self, entry: ConfigEntry) -> None: _LOGGER.error( ( "Config entry '%s' from integration %s has an invalid unique_id" - " '%s', please %s" + " '%s' of type %s when a string is expected, please %s" ), entry.title, entry.domain, entry.unique_id, + type(entry.unique_id).__name__, report_issue, ) else: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index cc762f8c1de91..e0135657c2b39 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5447,16 +5447,17 @@ async def test_string_unique_id_no_warning( @pytest.mark.parametrize( - "unique_id", + ("unique_id", "type_name"), [ - (123), - (2.3), + (123, "int"), + (2.3, "float"), ], ) async def test_hashable_unique_id( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, unique_id: Any, + type_name: str, ) -> None: """Test the ConfigEntryItems user dict handles hashable non string unique_id.""" entries = config_entries.ConfigEntryItems(hass) @@ -5477,6 +5478,7 @@ async def test_hashable_unique_id( assert ( "Config entry 'title' from integration test has an invalid unique_id" + f" '{unique_id}' of type {type_name} when a string is expected" ) in caplog.text assert entry.entry_id in entries From b1dfc3cd23d49ea05d2a09abd59805e056835d80 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 31 Oct 2024 16:35:36 +0100 Subject: [PATCH 0020/1070] Update frontend to 20241031.0 (#129583) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index dfe86d7493317..52eee7db199f8 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241030.0"] + "requirements": ["home-assistant-frontend==20241031.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index acdae25ccdc34..52c1439106ae1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.83.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241030.0 +home-assistant-frontend==20241031.0 home-assistant-intents==2024.10.30 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 44b25bf802f0d..53c4812c574a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241030.0 +home-assistant-frontend==20241031.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 15330d225e1dc..6b0a64c8faa26 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241030.0 +home-assistant-frontend==20241031.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 From b1d48fe9a2e54a050d7ba3a0a83b1376d83c766a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 31 Oct 2024 17:37:33 +0100 Subject: [PATCH 0021/1070] Use class attributes in Times of Day (#129543) * mypy ignore assignment in Times of Day so we can drop all type checking * class attributes --- homeassistant/components/tod/binary_sensor.py | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index 907df849ea116..3ac90b5578ccd 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from datetime import datetime, time, timedelta import logging -from typing import TYPE_CHECKING, Any, Literal, TypeGuard +from typing import Any, Literal, TypeGuard import voluptuous as vol @@ -109,6 +109,9 @@ class TodSensor(BinarySensorEntity): """Time of the Day Sensor.""" _attr_should_poll = False + _time_before: datetime + _time_after: datetime + _next_update: datetime def __init__( self, @@ -122,9 +125,6 @@ def __init__( """Init the ToD Sensor...""" self._attr_unique_id = unique_id self._attr_name = name - self._time_before: datetime | None = None - self._time_after: datetime | None = None - self._next_update: datetime | None = None self._after_offset = after_offset self._before_offset = before_offset self._before = before @@ -134,9 +134,6 @@ def __init__( @property def is_on(self) -> bool: """Return True is sensor is on.""" - if TYPE_CHECKING: - assert self._time_after is not None - assert self._time_before is not None if self._time_after < self._time_before: return self._time_after <= dt_util.utcnow() < self._time_before return False @@ -144,10 +141,6 @@ def is_on(self) -> bool: @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the sensor.""" - if TYPE_CHECKING: - assert self._time_after is not None - assert self._time_before is not None - assert self._next_update is not None if time_zone := dt_util.get_default_time_zone(): return { ATTR_AFTER: self._time_after.astimezone(time_zone).isoformat(), @@ -244,9 +237,6 @@ def _add_one_dst_aware_day(self, a_date: datetime, target_time: time) -> datetim def _turn_to_next_day(self) -> None: """Turn to to the next day.""" - if TYPE_CHECKING: - assert self._time_after is not None - assert self._time_before is not None if _is_sun_event(self._after): self._time_after = get_astral_event_next( self.hass, self._after, self._time_after - self._after_offset @@ -282,17 +272,12 @@ def _clean_up_listener() -> None: self.async_on_remove(_clean_up_listener) - if TYPE_CHECKING: - assert self._next_update is not None self._unsub_update = event.async_track_point_in_utc_time( self.hass, self._point_in_time_listener, self._next_update ) def _calculate_next_update(self) -> None: """Datetime when the next update to the state.""" - if TYPE_CHECKING: - assert self._time_after is not None - assert self._time_before is not None now = dt_util.utcnow() if now < self._time_after: self._next_update = self._time_after @@ -309,9 +294,6 @@ def _point_in_time_listener(self, now: datetime) -> None: self._calculate_next_update() self.async_write_ha_state() - if TYPE_CHECKING: - assert self._next_update is not None - self._unsub_update = event.async_track_point_in_utc_time( self.hass, self._point_in_time_listener, self._next_update ) From 4c2c01b4f63bc89f1fbff5b73d8cf0222900daf7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 31 Oct 2024 17:40:14 +0100 Subject: [PATCH 0022/1070] Use shorthand attribute for native_value in mold_indicator (#129538) --- .../components/mold_indicator/sensor.py | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index eb4c0bf7284aa..8b0230e80931e 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -37,7 +37,7 @@ from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.util.unit_system import METRIC_SYSTEM @@ -150,7 +150,6 @@ def __init__( unique_id: str | None, ) -> None: """Initialize the sensor.""" - self._state: str | None = None self._attr_name = name self._attr_unique_id = unique_id self._indoor_temp_sensor = indoor_temp_sensor @@ -272,7 +271,7 @@ def mold_indicator_startup() -> None: # re-calculate dewpoint and mold indicator self._calc_dewpoint() self._calc_moldindicator() - if self._state is None: + if self._attr_native_value is None: self._attr_available = False else: self._attr_available = True @@ -401,7 +400,7 @@ async def async_update(self) -> None: # re-calculate dewpoint and mold indicator self._calc_dewpoint() self._calc_moldindicator() - if self._state is None: + if self._attr_native_value is None: self._attr_available = False self._dewpoint = None self._crit_temp = None @@ -437,7 +436,7 @@ def _calc_moldindicator(self) -> None: self._dewpoint, self._calib_factor, ) - self._state = None + self._attr_native_value = None self._attr_available = False self._crit_temp = None return @@ -468,18 +467,13 @@ def _calc_moldindicator(self) -> None: # check bounds and format if crit_humidity > 100: - self._state = "100" + self._attr_native_value = "100" elif crit_humidity < 0: - self._state = "0" + self._attr_native_value = "0" else: - self._state = f"{int(crit_humidity):d}" + self._attr_native_value = f"{int(crit_humidity):d}" - _LOGGER.debug("Mold indicator humidity: %s", self._state) - - @property - def native_value(self) -> StateType: - """Return the state of the entity.""" - return self._state + _LOGGER.debug("Mold indicator humidity: %s", self.native_value) @property def extra_state_attributes(self) -> dict[str, Any]: From 0f535e979fd77d7b52ab0036248a1ec0d6a18eba Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 31 Oct 2024 18:28:53 +0100 Subject: [PATCH 0023/1070] Bump aiowithings to 3.1.1 (#129586) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index e0d85f207a329..a0a86be5da380 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==3.1.0"] + "requirements": ["aiowithings==3.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 53c4812c574a9..05e583f1a6097 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -414,7 +414,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.0 +aiowithings==3.1.1 # homeassistant.components.yandex_transport aioymaps==1.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b0a64c8faa26..3030b009e3204 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -396,7 +396,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.0 +aiowithings==3.1.1 # homeassistant.components.yandex_transport aioymaps==1.2.5 From f44b7e202a91d41c3d3f99fffb7646745d447b35 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 31 Oct 2024 17:57:40 +0000 Subject: [PATCH 0024/1070] Check for async web offer overrides in camera capabilities (#129519) --- homeassistant/components/camera/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index aa6cfc1c891af..58826eb07ce4b 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -867,6 +867,8 @@ def camera_capabilities(self) -> CameraCapabilities: if ( type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer + or type(self).async_handle_async_webrtc_offer + != Camera.async_handle_async_webrtc_offer ): # The camera has a native WebRTC implementation frontend_stream_types.add(StreamType.WEB_RTC) From b09e54c961db279785b75b5c3d192624b3d65664 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 31 Oct 2024 19:37:31 +0100 Subject: [PATCH 0025/1070] Bump aiohasupervisor to version 0.2.1 (#129574) --- homeassistant/components/hassio/discovery.py | 7 ++++--- homeassistant/components/hassio/handler.py | 2 +- homeassistant/components/hassio/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hassio/test_discovery.py | 13 ++++++++----- 9 files changed, 19 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 8166b0f2c7ef5..6181fe4624ca4 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -5,6 +5,7 @@ import asyncio import logging from typing import Any +from uuid import UUID from aiohasupervisor import SupervisorError from aiohasupervisor.models import Discovery @@ -86,7 +87,7 @@ async def post(self, request: web.Request, uuid: str) -> web.Response: """Handle new discovery requests.""" # Fetch discovery data and prevent injections try: - data = await self._supervisor_client.discovery.get(uuid) + data = await self._supervisor_client.discovery.get(UUID(uuid)) except SupervisorError as err: _LOGGER.error("Can't read discovery data: %s", err) raise HTTPServiceUnavailable from None @@ -104,7 +105,7 @@ async def delete(self, request: web.Request, uuid: str) -> web.Response: async def async_rediscover(self, uuid: str) -> None: """Rediscover add-on when config entry is removed.""" try: - data = await self._supervisor_client.discovery.get(uuid) + data = await self._supervisor_client.discovery.get(UUID(uuid)) except SupervisorError as err: _LOGGER.debug("Can't read discovery data: %s", err) else: @@ -146,7 +147,7 @@ async def async_process_del(self, data: dict[str, Any]) -> None: # Check if really deletet / prevent injections try: - data = await self._supervisor_client.discovery.get(uuid) + await self._supervisor_client.discovery.get(UUID(uuid)) except SupervisorError: pass else: diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index d96c3f49e95be..f69ee40293b87 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -382,7 +382,7 @@ def get_supervisor_client(hass: HomeAssistant) -> SupervisorClient: """Return supervisor client.""" hassio: HassIO = hass.data[DOMAIN] return SupervisorClient( - hassio.base_url, + str(hassio.base_url), os.environ.get("SUPERVISOR_TOKEN", ""), session=hassio.websession, ) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index fb9ad8fdb314e..31fa27a92c435 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.2.0"], + "requirements": ["aiohasupervisor==0.2.1"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 52c1439106ae1..aa9e614acef3c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 -aiohasupervisor==0.2.0 +aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 aiohttp==3.10.10 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index a745d7732aca0..2d5b0da46cc75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.2.0", + "aiohasupervisor==0.2.1", "aiohttp==3.10.10", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", diff --git a/requirements.txt b/requirements.txt index ce6fad44332f4..ecca136e1a748 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.2.0 +aiohasupervisor==0.2.1 aiohttp==3.10.10 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 diff --git a/requirements_all.txt b/requirements_all.txt index 05e583f1a6097..d28b9e4caeba8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -259,7 +259,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.hassio -aiohasupervisor==0.2.0 +aiohasupervisor==0.2.1 # homeassistant.components.homekit_controller aiohomekit==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3030b009e3204..6ced98f9f8f96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -244,7 +244,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.hassio -aiohasupervisor==0.2.0 +aiohasupervisor==0.2.1 # homeassistant.components.homekit_controller aiohomekit==3.2.5 diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index 09bcc251e6f50..bb3a101d1f97b 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -181,8 +181,8 @@ async def test_hassio_discovery_webhook( addon_installed.return_value.name = "Mosquitto Test" resp = await hassio_client.post( - "/api/hassio_push/discovery/testuuid", - json={"addon": "mosquitto", "service": "mqtt", "uuid": "testuuid"}, + f"/api/hassio_push/discovery/{uuid!s}", + json={"addon": "mosquitto", "service": "mqtt", "uuid": str(uuid)}, ) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -208,6 +208,9 @@ async def test_hassio_discovery_webhook( ) +TEST_UUID = str(uuid4()) + + @pytest.mark.parametrize( ( "entry_domain", @@ -217,13 +220,13 @@ async def test_hassio_discovery_webhook( # Matching discovery key ( "mock-domain", - {"hassio": (DiscoveryKey(domain="hassio", key="test", version=1),)}, + {"hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),)}, ), # Matching discovery key ( "mock-domain", { - "hassio": (DiscoveryKey(domain="hassio", key="test", version=1),), + "hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),), "other": (DiscoveryKey(domain="other", key="blah", version=1),), }, ), @@ -232,7 +235,7 @@ async def test_hassio_discovery_webhook( # entry. Such a check can be added if needed. ( "comp", - {"hassio": (DiscoveryKey(domain="hassio", key="test", version=1),)}, + {"hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),)}, ), ], ) From 9c8a15cb6420ee98e30f63864d26dcbebf5bf348 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 31 Oct 2024 20:56:53 +0100 Subject: [PATCH 0026/1070] Add go2rtc debug_ui yaml key to enable go2rtc ui (#129587) * Add go2rtc debug_ui yaml key to enable go2rtc ui * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Order imports --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/go2rtc/__init__.py | 16 +++++++++--- homeassistant/components/go2rtc/const.py | 3 ++- homeassistant/components/go2rtc/server.py | 28 ++++++++++++-------- tests/components/go2rtc/test_init.py | 29 ++++++++++++++++++--- tests/components/go2rtc/test_server.py | 26 ++++++++++++++---- 5 files changed, 77 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 9501bee776b67..0bf01490a47f5 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -37,7 +37,7 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import DOMAIN +from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN from .server import Server _LOGGER = logging.getLogger(__name__) @@ -72,9 +72,15 @@ ) ) - CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema({vol.Optional(CONF_URL): cv.url})}, + { + DOMAIN: vol.Schema( + { + vol.Exclusive(CONF_URL, DOMAIN, DEBUG_UI_URL_MESSAGE): cv.url, + vol.Exclusive(CONF_DEBUG_UI, DOMAIN, DEBUG_UI_URL_MESSAGE): cv.boolean, + } + ) + }, extra=vol.ALLOW_EXTRA, ) @@ -104,7 +110,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return False # HA will manage the binary - server = Server(hass, binary) + server = Server( + hass, binary, enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False) + ) await server.start() async def on_stop(event: Event) -> None: diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index af8266e0d723d..b0d52e4fd3906 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -2,4 +2,5 @@ DOMAIN = "go2rtc" -CONF_BINARY = "binary" +CONF_DEBUG_UI = "debug_ui" +DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index febb6b2680eba..df4b5b7f13eed 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -10,15 +10,15 @@ _LOGGER = logging.getLogger(__name__) _TERMINATE_TIMEOUT = 5 _SETUP_TIMEOUT = 30 -_SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr=127.0.0.1:1984" - +_SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr=" +_LOCALHOST_IP = "127.0.0.1" # Default configuration for HA # - Api is listening only on localhost # - Disable rtsp listener # - Clear default ice servers -_GO2RTC_CONFIG = """ +_GO2RTC_CONFIG_FORMAT = r""" api: - listen: "127.0.0.1:1984" + listen: "{api_ip}:1984" rtsp: # ffmpeg needs rtsp for opus audio transcoding @@ -29,29 +29,37 @@ """ -def _create_temp_file() -> str: +def _create_temp_file(api_ip: str) -> str: """Create temporary config file.""" # Set delete=False to prevent the file from being deleted when the file is closed # Linux is clearing tmp folder on reboot, so no need to delete it manually with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: - file.write(_GO2RTC_CONFIG.encode()) + file.write(_GO2RTC_CONFIG_FORMAT.format(api_ip=api_ip).encode()) return file.name class Server: """Go2rtc server.""" - def __init__(self, hass: HomeAssistant, binary: str) -> None: + def __init__( + self, hass: HomeAssistant, binary: str, *, enable_ui: bool = False + ) -> None: """Initialize the server.""" self._hass = hass self._binary = binary self._process: asyncio.subprocess.Process | None = None self._startup_complete = asyncio.Event() + self._api_ip = _LOCALHOST_IP + if enable_ui: + # Listen on all interfaces for allowing access from all ips + self._api_ip = "" async def start(self) -> None: """Start the server.""" _LOGGER.debug("Starting go2rtc server") - config_file = await self._hass.async_add_executor_job(_create_temp_file) + config_file = await self._hass.async_add_executor_job( + _create_temp_file, self._api_ip + ) self._startup_complete.clear() @@ -84,9 +92,7 @@ async def _log_output(self, process: asyncio.subprocess.Process) -> None: async for line in process.stdout: msg = line[:-1].decode().strip() _LOGGER.debug(msg) - if not self._startup_complete.is_set() and msg.endswith( - _SUCCESSFUL_BOOT_MESSAGE - ): + if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg: self._startup_complete.set() async def stop(self) -> None: diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index a215b826010de..c4a23731a93fb 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -31,7 +31,11 @@ ) from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN from homeassistant.components.go2rtc import WebRTCProvider -from homeassistant.components.go2rtc.const import DOMAIN +from homeassistant.components.go2rtc.const import ( + CONF_DEBUG_UI, + DEBUG_UI_URL_MESSAGE, + DOMAIN, +) from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant @@ -265,7 +269,15 @@ async def test() -> None: "mock_is_docker_env", "mock_go2rtc_entry", ) -@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}]) +@pytest.mark.parametrize( + ("config", "ui_enabled"), + [ + ({DOMAIN: {}}, False), + ({DOMAIN: {CONF_DEBUG_UI: True}}, True), + ({DEFAULT_CONFIG_DOMAIN: {}}, False), + ({DEFAULT_CONFIG_DOMAIN: {}, DOMAIN: {CONF_DEBUG_UI: True}}, True), + ], +) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) async def test_setup_go_binary( hass: HomeAssistant, @@ -277,12 +289,13 @@ async def test_setup_go_binary( init_test_integration: MockCamera, has_go2rtc_entry: bool, config: ConfigType, + ui_enabled: bool, ) -> None: """Test the go2rtc config entry with binary.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry def after_setup() -> None: - server.assert_called_once_with(hass, "/usr/bin/go2rtc") + server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled) server_start.assert_called_once() await _test_setup_and_signaling( @@ -468,7 +481,9 @@ async def test_close_session( ERR_CONNECT_RETRY = ( "Could not connect to go2rtc instance on http://localhost:1984/; Retrying" ) -ERR_INVALID_URL = "Invalid config for 'go2rtc': invalid url" +_INVALID_CONFIG = "Invalid config for 'go2rtc': " +ERR_INVALID_URL = _INVALID_CONFIG + "invalid url" +ERR_EXCLUSIVE = _INVALID_CONFIG + DEBUG_UI_URL_MESSAGE ERR_URL_REQUIRED = "Go2rtc URL required in non-docker installs" @@ -501,6 +516,12 @@ async def test_non_user_setup_with_error( ({DOMAIN: {}}, None, False, ERR_URL_REQUIRED), ({DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND), ({DOMAIN: {CONF_URL: "invalid"}}, None, True, ERR_INVALID_URL), + ( + {DOMAIN: {CONF_URL: "http://localhost:1984", CONF_DEBUG_UI: True}}, + None, + True, + ERR_EXCLUSIVE, + ), ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index 8373b71cee749..42f3f5e098d33 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -16,9 +16,15 @@ @pytest.fixture -def server(hass: HomeAssistant) -> Server: +def enable_ui() -> bool: + """Fixture to enable the UI.""" + return False + + +@pytest.fixture +def server(hass: HomeAssistant, enable_ui: bool) -> Server: """Fixture to initialize the Server.""" - return Server(hass, binary=TEST_BINARY) + return Server(hass, binary=TEST_BINARY, enable_ui=enable_ui) @pytest.fixture @@ -32,12 +38,20 @@ def mock_tempfile() -> Generator[Mock]: yield file +@pytest.mark.parametrize( + ("enable_ui", "api_ip"), + [ + (True, ""), + (False, "127.0.0.1"), + ], +) async def test_server_run_success( mock_create_subprocess: AsyncMock, server_stdout: list[str], server: Server, caplog: pytest.LogCaptureFixture, mock_tempfile: Mock, + api_ip: str, ) -> None: """Test that the server runs successfully.""" await server.start() @@ -53,9 +67,10 @@ async def test_server_run_success( ) # Verify that the config file was written - mock_tempfile.write.assert_called_once_with(b""" + mock_tempfile.write.assert_called_once_with( + f""" api: - listen: "127.0.0.1:1984" + listen: "{api_ip}:1984" rtsp: # ffmpeg needs rtsp for opus audio transcoding @@ -63,7 +78,8 @@ async def test_server_run_success( webrtc: ice_servers: [] -""") +""".encode() + ) # Check that server read the log lines for entry in server_stdout: From 45ff4940eb85b76f37dce118c9af9e8449afc55c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Oct 2024 16:18:31 -0500 Subject: [PATCH 0027/1070] Pin async-timeout to 4.0.3 (#129592) --- homeassistant/package_constraints.txt | 4 ++++ script/gen_requirements_all.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index aa9e614acef3c..e1547949588db 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -189,3 +189,7 @@ tuf>=4.0.0 # https://github.com/jd/tenacity/issues/471 tenacity!=8.4.0 + +# 5.0.0 breaks Timeout as a context manager +# TypeError: 'Timeout' object does not support the context manager protocol +async-timeout==4.0.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 1ad0d863062e3..36962ce1fe947 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -205,6 +205,10 @@ # https://github.com/jd/tenacity/issues/471 tenacity!=8.4.0 + +# 5.0.0 breaks Timeout as a context manager +# TypeError: 'Timeout' object does not support the context manager protocol +async-timeout==4.0.3 """ GENERATED_MESSAGE = ( From c2ceab741f74b5593348c350fcb735887dbcaf42 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 1 Nov 2024 00:00:52 +0100 Subject: [PATCH 0028/1070] Remove unnecessary husqvarna_automower_ble test fixture (#129577) --- .../husqvarna_automower_ble/conftest.py | 26 +++---------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/tests/components/husqvarna_automower_ble/conftest.py b/tests/components/husqvarna_automower_ble/conftest.py index 5e27582b81c0e..3a8e881aba0b2 100644 --- a/tests/components/husqvarna_automower_ble/conftest.py +++ b/tests/components/husqvarna_automower_ble/conftest.py @@ -1,19 +1,16 @@ """Common fixtures for the Husqvarna Automower Bluetooth tests.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Generator from unittest.mock import AsyncMock, patch -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.husqvarna_automower_ble.const import DOMAIN -from homeassistant.components.husqvarna_automower_ble.coordinator import SCAN_INTERVAL from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID -from homeassistant.core import HomeAssistant from . import AUTOMOWER_SERVICE_INFO -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry @pytest.fixture @@ -26,25 +23,8 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -@pytest.fixture -async def scan_step( - hass: HomeAssistant, freezer: FrozenDateTimeFactory -) -> Generator[None, None, Callable[[], Awaitable[None]]]: - """Step system time forward.""" - - freezer.move_to("2023-01-01T01:00:00Z") - - async def delay() -> None: - """Trigger delay in system.""" - freezer.tick(delta=SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - return delay - - @pytest.fixture(autouse=True) -def mock_automower_client(enable_bluetooth: None, scan_step) -> Generator[AsyncMock]: +def mock_automower_client(enable_bluetooth: None) -> Generator[AsyncMock]: """Mock a BleakClient client.""" with ( patch( From 5900413c08e27a4402a0a24f64185d0269a8e8d2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 1 Nov 2024 00:32:01 +0100 Subject: [PATCH 0029/1070] Add zwave_js node_capabilities and invoke_cc_api websocket commands (#125327) * Add zwave_js node_capabilities and invoke_cc_api websocket commands * Map isSecure to is_secure * Add tests * Add error handling * fix * Use to_dict function * Make response compatible with current expectations --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/api.py | 86 ++++++++++++ tests/components/zwave_js/test_api.py | 161 ++++++++++++++++++++++- 2 files changed, 246 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 6eb54afb51a1e..7d3bd8273ecef 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -43,6 +43,7 @@ ControllerFirmwareUpdateResult, ) from zwave_js_server.model.driver import Driver +from zwave_js_server.model.endpoint import Endpoint from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.log_message import LogMessage from zwave_js_server.model.node import Node, NodeStatistics @@ -75,6 +76,11 @@ from .config_validation import BITMASK_SCHEMA from .const import ( + ATTR_COMMAND_CLASS, + ATTR_ENDPOINT, + ATTR_METHOD_NAME, + ATTR_PARAMETERS, + ATTR_WAIT_FOR_RESULT, CONF_DATA_COLLECTION_OPTED_IN, DATA_CLIENT, EVENT_DEVICE_ADDED_TO_REGISTRY, @@ -437,6 +443,8 @@ def async_register_api(hass: HomeAssistant) -> None: ) websocket_api.async_register_command(hass, websocket_subscribe_node_statistics) websocket_api.async_register_command(hass, websocket_hard_reset_controller) + websocket_api.async_register_command(hass, websocket_node_capabilities) + websocket_api.async_register_command(hass, websocket_invoke_cc_api) hass.http.register_view(FirmwareUploadView(dr.async_get(hass))) @@ -2525,3 +2533,81 @@ def _handle_device_added(device: dr.DeviceEntry) -> None: ) ] await driver.async_hard_reset() + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/node_capabilities", + vol.Required(DEVICE_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_node +async def websocket_node_capabilities( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + node: Node, +) -> None: + """Get node endpoints with their support command classes.""" + # consumers expect snake_case at the moment + # remove that addition when consumers are updated + connection.send_result( + msg[ID], + { + idx: [ + command_class.to_dict() | {"is_secure": command_class.is_secure} + for command_class in endpoint.command_classes + ] + for idx, endpoint in node.endpoints.items() + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/invoke_cc_api", + vol.Required(DEVICE_ID): str, + vol.Required(ATTR_COMMAND_CLASS): vol.All( + vol.Coerce(int), vol.Coerce(CommandClass) + ), + vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), + vol.Required(ATTR_METHOD_NAME): cv.string, + vol.Required(ATTR_PARAMETERS): list, + vol.Optional(ATTR_WAIT_FOR_RESULT): cv.boolean, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_node +async def websocket_invoke_cc_api( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + node: Node, +) -> None: + """Call invokeCCAPI on the node or provided endpoint.""" + command_class: CommandClass = msg[ATTR_COMMAND_CLASS] + method_name: str = msg[ATTR_METHOD_NAME] + parameters: list[Any] = msg[ATTR_PARAMETERS] + + node_or_endpoint: Node | Endpoint = node + if (endpoint := msg.get(ATTR_ENDPOINT)) is not None: + node_or_endpoint = node.endpoints[endpoint] + + try: + result = await node_or_endpoint.async_invoke_cc_api( + command_class, + method_name, + *parameters, + wait_for_result=msg.get(ATTR_WAIT_FOR_RESULT, False), + ) + except BaseZwaveJSServerError as err: + connection.send_error(msg[ID], err.__class__.__name__, str(err)) + else: + connection.send_result( + msg[ID], + result, + ) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 05ffcee7f4eb9..8251d7d280fc7 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -81,6 +81,11 @@ VERSION, ) from homeassistant.components.zwave_js.const import ( + ATTR_COMMAND_CLASS, + ATTR_ENDPOINT, + ATTR_METHOD_NAME, + ATTR_PARAMETERS, + ATTR_WAIT_FOR_RESULT, CONF_DATA_COLLECTION_OPTED_IN, DOMAIN, ) @@ -88,7 +93,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from tests.common import MockUser +from tests.common import MockConfigEntry, MockUser from tests.typing import ClientSessionGenerator, WebSocketGenerator CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller" @@ -4828,3 +4833,157 @@ async def test_hard_reset_controller( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_FOUND + + +async def test_node_capabilities( + hass: HomeAssistant, + multisensor_6: Node, + integration: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the node_capabilities websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + node = multisensor_6 + device = get_device(hass, node) + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/node_capabilities", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + assert msg["result"] == { + "0": [ + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": False, + "is_secure": False, + } + ] + } + + # Test getting non-existent node fails + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/node_status", + DEVICE_ID: "fake_device", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/node_status", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_invoke_cc_api( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, + integration: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the invoke_cc_api websocket command.""" + ws_client = await hass_ws_client(hass) + + device_radio_thermostat = get_device( + hass, climate_radio_thermostat_ct100_plus_different_endpoints + ) + assert device_radio_thermostat + + # Test successful invoke_cc_api call with a static endpoint + client.async_send_command.return_value = {"response": True} + client.async_send_command_no_wait.return_value = {"response": True} + + # Test with wait_for_result=False (default) + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/invoke_cc_api", + DEVICE_ID: device_radio_thermostat.id, + ATTR_COMMAND_CLASS: 67, + ATTR_METHOD_NAME: "someMethod", + ATTR_PARAMETERS: [1, 2], + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] is None # We did not specify wait_for_result=True + + await hass.async_block_till_done() + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args == { + "command": "endpoint.invoke_cc_api", + "nodeId": 26, + "endpoint": 0, + "commandClass": 67, + "methodName": "someMethod", + "args": [1, 2], + } + + client.async_send_command_no_wait.reset_mock() + + # Test with wait_for_result=True + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/invoke_cc_api", + DEVICE_ID: device_radio_thermostat.id, + ATTR_COMMAND_CLASS: 67, + ATTR_ENDPOINT: 0, + ATTR_METHOD_NAME: "someMethod", + ATTR_PARAMETERS: [1, 2], + ATTR_WAIT_FOR_RESULT: True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] is True + + await hass.async_block_till_done() + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args == { + "command": "endpoint.invoke_cc_api", + "nodeId": 26, + "endpoint": 0, + "commandClass": 67, + "methodName": "someMethod", + "args": [1, 2], + } + + client.async_send_command.side_effect = NotFoundError + + # Ensure an error is returned + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/invoke_cc_api", + DEVICE_ID: device_radio_thermostat.id, + ATTR_COMMAND_CLASS: 67, + ATTR_ENDPOINT: 0, + ATTR_METHOD_NAME: "someMethod", + ATTR_PARAMETERS: [1, 2], + ATTR_WAIT_FOR_RESULT: True, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"] == {"code": "NotFoundError", "message": ""} From b41c477f44bbc5c7c05f55fe366595c8354c620e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 1 Nov 2024 10:15:20 +0100 Subject: [PATCH 0030/1070] Fix flaky camera test (#129576) --- tests/components/camera/test_init.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 58d87a4257252..e0d4e38fb576e 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -929,7 +929,8 @@ async def test(expected_types: set[StreamType]) -> None: # Assert WebSocket response assert msg["type"] == TYPE_RESULT assert msg["success"] - assert msg["result"] == {"frontend_stream_types": list(expected_types)} + assert msg["result"] == {"frontend_stream_types": ANY} + assert sorted(msg["result"]["frontend_stream_types"]) == sorted(expected_types) await test(expected_stream_types) From 5430eca93e046a3a5fa02ae32405027f58271606 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Fri, 1 Nov 2024 10:23:30 +0100 Subject: [PATCH 0031/1070] Bump python-bsblan to 1.0.0 (#129617) --- homeassistant/components/bsblan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 3f100aef04fd1..5b10f46bf1311 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==0.6.4"] + "requirements": ["python-bsblan==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d28b9e4caeba8..cee049199e3a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2308,7 +2308,7 @@ python-awair==0.2.4 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==0.6.4 +python-bsblan==1.0.0 # homeassistant.components.clementine python-clementine-remote==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ced98f9f8f96..dee450aed2619 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1859,7 +1859,7 @@ python-MotionMount==2.2.0 python-awair==0.2.4 # homeassistant.components.bsblan -python-bsblan==0.6.4 +python-bsblan==1.0.0 # homeassistant.components.ecobee python-ecobee-api==0.2.20 From b626c9b45077f7a4fe0ee093310616806798aa11 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 1 Nov 2024 10:29:58 +0100 Subject: [PATCH 0032/1070] Ensure entry_id is set on reauth/reconfigure flows (#129319) * Ensure entry_id is set on reauth/reconfigure flows * Improve * Improve * Use report helper * Adjust deprecation date * Update config_entries.py * Improve message and adjust tests * Apply suggestions from code review Co-authored-by: G Johansson --------- Co-authored-by: G Johansson --- homeassistant/config_entries.py | 17 ++++++-- tests/test_config_entries.py | 74 ++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index e99c730145e1e..ba96889d8f28a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1260,13 +1260,24 @@ async def async_init( if not context or "source" not in context: raise KeyError("Context not set or doesn't have a source set") + # reauth/reconfigure flows should be linked to a config entry + if (source := context["source"]) in { + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + } and "entry_id" not in context: + # Deprecated in 2024.12, should fail in 2025.12 + report( + f"initialises a {source} flow without a link to the config entry", + error_if_integration=False, + error_if_core=True, + ) + flow_id = ulid_util.ulid_now() # Avoid starting a config flow on an integration that only supports # a single config entry, but which already has an entry if ( - context.get("source") - not in {SOURCE_IGNORE, SOURCE_REAUTH, SOURCE_RECONFIGURE} + source not in {SOURCE_IGNORE, SOURCE_REAUTH, SOURCE_RECONFIGURE} and self.config_entries.async_has_entries(handler, include_ignore=False) and await _support_single_config_entry_only(self.hass, handler) ): @@ -1280,7 +1291,7 @@ async def async_init( loop = self.hass.loop - if context["source"] == SOURCE_IMPORT: + if source == SOURCE_IMPORT: self._pending_import_flows[handler][flow_id] = loop.create_future() cancel_init_future = loop.create_future() diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index e0135657c2b39..68f5e4033eb8b 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -37,7 +37,7 @@ ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import entity_registry as er, frame, issue_registry as ir from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps @@ -4779,6 +4779,74 @@ async def test_reauth( assert len(hass.config_entries.flow.async_progress()) == 1 +@pytest.mark.parametrize( + "source", [config_entries.SOURCE_REAUTH, config_entries.SOURCE_RECONFIGURE] +) +async def test_reauth_reconfigure_missing_entry( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + source: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the async_reauth_helper.""" + entry = MockConfigEntry(title="test_title", domain="test") + entry.add_to_hass(hass) + + mock_setup_entry = AsyncMock(return_value=True) + mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "test.config_flow", None) + + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises( + RuntimeError, + match=f"Detected code that initialises a {source} flow without a link " + "to the config entry. Please report this issue.", + ): + await manager.flow.async_init("test", context={"source": source}) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + +@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.parametrize( + "source", [config_entries.SOURCE_REAUTH, config_entries.SOURCE_RECONFIGURE] +) +async def test_reauth_reconfigure_missing_entry_component( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + source: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the async_reauth_helper.""" + entry = MockConfigEntry(title="test_title", domain="test") + entry.add_to_hass(hass) + + mock_setup_entry = AsyncMock(return_value=True) + mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "test.config_flow", None) + + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + await manager.flow.async_init("test", context={"source": source}) + await hass.async_block_till_done() + + # Flow still created, but deprecation logged + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == source + + assert ( + f"Detected that integration 'hue' initialises a {source} flow" + " without a link to the config entry at homeassistant/components" in caplog.text + ) + + async def test_reconfigure( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -5012,7 +5080,9 @@ async def async_step_reauth(self, data): config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} ): task = asyncio.create_task( - manager.flow.async_init("test", context={"source": "reauth"}) + manager.flow.async_init( + "test", context={"source": "reauth", "entry_id": "abc"} + ) ) await hass.async_block_till_done() manager.flow.async_shutdown() From 3b28bf07d1f920d6997dea196f1b55dca4b1e7a9 Mon Sep 17 00:00:00 2001 From: Marco <46717884+marcodutto@users.noreply.github.com> Date: Fri, 1 Nov 2024 06:08:55 -0400 Subject: [PATCH 0033/1070] Add boost switch to Smarty (#129466) --- homeassistant/components/smarty/__init__.py | 2 +- homeassistant/components/smarty/strings.json | 5 ++ homeassistant/components/smarty/switch.py | 90 +++++++++++++++++++ tests/components/smarty/conftest.py | 2 + .../smarty/snapshots/test_switch.ambr | 47 ++++++++++ tests/components/smarty/test_switch.py | 58 ++++++++++++ 6 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/smarty/switch.py create mode 100644 tests/components/smarty/snapshots/test_switch.ambr create mode 100644 tests/components/smarty/test_switch.py diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index cc7215349a60d..0e5ca2166213e 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -30,7 +30,7 @@ extra=vol.ALLOW_EXTRA, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.FAN, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.FAN, Platform.SENSOR, Platform.SWITCH] async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: diff --git a/homeassistant/components/smarty/strings.json b/homeassistant/components/smarty/strings.json index 367a3a3462522..5553a1c0135dc 100644 --- a/homeassistant/components/smarty/strings.json +++ b/homeassistant/components/smarty/strings.json @@ -61,6 +61,11 @@ "filter_days_left": { "name": "Filter days left" } + }, + "switch": { + "boost": { + "name": "Boost" + } } } } diff --git a/homeassistant/components/smarty/switch.py b/homeassistant/components/smarty/switch.py new file mode 100644 index 0000000000000..bf5fe80db4424 --- /dev/null +++ b/homeassistant/components/smarty/switch.py @@ -0,0 +1,90 @@ +"""Platform to control a Salda Smarty XP/XV ventilation unit.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any + +from pysmarty2 import Smarty + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import SmartyConfigEntry, SmartyCoordinator +from .entity import SmartyEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class SmartySwitchDescription(SwitchEntityDescription): + """Class describing Smarty switch.""" + + is_on_fn: Callable[[Smarty], bool] + turn_on_fn: Callable[[Smarty], bool | None] + turn_off_fn: Callable[[Smarty], bool | None] + + +ENTITIES: tuple[SmartySwitchDescription, ...] = ( + SmartySwitchDescription( + key="boost", + translation_key="boost", + is_on_fn=lambda smarty: smarty.boost, + turn_on_fn=lambda smarty: smarty.enable_boost(), + turn_off_fn=lambda smarty: smarty.disable_boost(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartyConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Smarty Switch Platform.""" + + coordinator = entry.runtime_data + + async_add_entities( + SmartySwitch(coordinator, description) for description in ENTITIES + ) + + +class SmartySwitch(SmartyEntity, SwitchEntity): + """Representation of a Smarty Switch.""" + + entity_description: SmartySwitchDescription + + def __init__( + self, + coordinator: SmartyCoordinator, + entity_description: SmartySwitchDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{entity_description.key}" + ) + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self.entity_description.is_on_fn(self.coordinator.client) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.hass.async_add_executor_job( + self.entity_description.turn_on_fn, self.coordinator.client + ) + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.hass.async_add_executor_job( + self.entity_description.turn_off_fn, self.coordinator.client + ) + await self.coordinator.async_refresh() diff --git a/tests/components/smarty/conftest.py b/tests/components/smarty/conftest.py index c62097f0516ab..c61ec4b1022db 100644 --- a/tests/components/smarty/conftest.py +++ b/tests/components/smarty/conftest.py @@ -40,6 +40,8 @@ def mock_smarty() -> Generator[AsyncMock]: client.warning = False client.alarm = False client.boost = False + client.enable_boost.return_value = True + client.disable_boost.return_value = True client.supply_air_temperature = 20 client.extract_air_temperature = 23 client.outdoor_air_temperature = 24 diff --git a/tests/components/smarty/snapshots/test_switch.ambr b/tests/components/smarty/snapshots/test_switch.ambr new file mode 100644 index 0000000000000..be1da7c696174 --- /dev/null +++ b/tests/components/smarty/snapshots/test_switch.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_all_entities[switch.mock_title_boost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_boost', + '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': 'Boost', + 'platform': 'smarty', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boost', + 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_boost', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.mock_title_boost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Boost', + }), + 'context': , + 'entity_id': 'switch.mock_title_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smarty/test_switch.py b/tests/components/smarty/test_switch.py new file mode 100644 index 0000000000000..1a6748e2d23ed --- /dev/null +++ b/tests/components/smarty/test_switch.py @@ -0,0 +1,58 @@ +"""Tests for the Smarty switch platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + 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_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_smarty: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.smarty.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_setting_value( + hass: HomeAssistant, + mock_smarty: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + target={ATTR_ENTITY_ID: "switch.mock_title_boost"}, + blocking=True, + ) + mock_smarty.enable_boost.assert_called_once_with() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + target={ATTR_ENTITY_ID: "switch.mock_title_boost"}, + blocking=True, + ) + mock_smarty.disable_boost.assert_called_once_with() From ab5b9dbdc9c717c0ee7f6642a4ef8f67ddc555a6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 1 Nov 2024 12:54:35 +0100 Subject: [PATCH 0034/1070] Add OptionsFlow helpers to get the current config entry (#129562) * Add OptionsFlow helpers to get the current config entry * Add tests * Improve * Add ValueError to indicate that the config entry is not available in `__init__` method * Use a property * Update config_entries.py * Update config_entries.py * Update config_entries.py * Add a property setter for compatibility * Add report * Update config_flow.py * Add tests * Update test_config_entries.py --- .../components/airnow/config_flow.py | 16 +- homeassistant/config_entries.py | 60 +++++-- tests/test_config_entries.py | 156 ++++++++++++++++++ 3 files changed, 211 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index e839acdcb7b31..d0ab16e9758c4 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -1,5 +1,7 @@ """Config flow for AirNow integration.""" +from __future__ import annotations + import logging from typing import Any @@ -12,7 +14,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback @@ -120,12 +121,12 @@ async def async_step_user( @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> AirNowOptionsFlowHandler: """Return the options flow.""" - return AirNowOptionsFlowHandler(config_entry) + return AirNowOptionsFlowHandler() -class AirNowOptionsFlowHandler(OptionsFlowWithConfigEntry): +class AirNowOptionsFlowHandler(OptionsFlow): """Handle an options flow for AirNow.""" async def async_step_init( @@ -136,12 +137,7 @@ async def async_step_init( return self.async_create_entry(data=user_input) options_schema = vol.Schema( - { - vol.Optional(CONF_RADIUS): vol.All( - int, - vol.Range(min=5), - ), - } + {vol.Optional(CONF_RADIUS): vol.All(int, vol.Range(min=5))} ) return self.async_show_form( diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ba96889d8f28a..971fd7d572654 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3055,6 +3055,9 @@ class OptionsFlow(ConfigEntryBaseFlow): handler: str + _config_entry: ConfigEntry + """For compatibility only - to be removed in 2025.12""" + @callback def _async_abort_entries_match( self, match_dict: dict[str, Any] | None = None @@ -3063,19 +3066,59 @@ def _async_abort_entries_match( Requires `already_configured` in strings.json in user visible flows. """ - - config_entry = cast( - ConfigEntry, self.hass.config_entries.async_get_entry(self.handler) - ) _async_abort_entries_match( [ entry - for entry in self.hass.config_entries.async_entries(config_entry.domain) - if entry is not config_entry and entry.source != SOURCE_IGNORE + for entry in self.hass.config_entries.async_entries( + self.config_entry.domain + ) + if entry is not self.config_entry and entry.source != SOURCE_IGNORE ], match_dict, ) + @property + def _config_entry_id(self) -> str: + """Return config entry id. + + Please note that this is not available inside `__init__` method, and + can only be referenced after initialisation. + """ + # This is the same as handler, but that's an implementation detail + if self.handler is None: + raise ValueError( + "The config entry id is not available during initialisation" + ) + return self.handler + + @property + def config_entry(self) -> ConfigEntry: + """Return the config entry linked to the current options flow. + + Please note that this is not available inside `__init__` method, and + can only be referenced after initialisation. + """ + # For compatibility only - to be removed in 2025.12 + if hasattr(self, "_config_entry"): + return self._config_entry + + if self.hass is None: + raise ValueError("The config entry is not available during initialisation") + if entry := self.hass.config_entries.async_get_entry(self._config_entry_id): + return entry + raise UnknownEntry + + @config_entry.setter + def config_entry(self, value: ConfigEntry) -> None: + """Set the config entry value.""" + report( + "sets option flow config_entry explicitly, which is deprecated " + "and will stop working in 2025.12", + error_if_integration=False, + error_if_core=True, + ) + self._config_entry = value + class OptionsFlowWithConfigEntry(OptionsFlow): """Base class for options flows with config entry and options.""" @@ -3085,11 +3128,6 @@ def __init__(self, config_entry: ConfigEntry) -> None: self._config_entry = config_entry self._options = deepcopy(dict(config_entry.options)) - @property - def config_entry(self) -> ConfigEntry: - """Return the config entry.""" - return self._config_entry - @property def options(self) -> dict[str, Any]: """Return a mutable copy of the config entry options.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 68f5e4033eb8b..6959dc3d3cedf 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -7308,6 +7308,162 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: assert config_entries.current_entry.get() is None +async def test_options_flow_config_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test _config_entry_id and config_entry properties in options flow.""" + original_entry = MockConfigEntry(domain="test", data={}) + original_entry.add_to_hass(hass) + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Test options flow.""" + + class _OptionsFlow(config_entries.OptionsFlow): + """Test flow.""" + + def __init__(self) -> None: + """Test initialisation.""" + try: + self.init_entry_id = self._config_entry_id + except ValueError as err: + self.init_entry_id = err + try: + self.init_entry = self.config_entry + except ValueError as err: + self.init_entry = err + + async def async_step_init(self, user_input=None): + """Test user step.""" + errors = {} + if user_input is not None: + if user_input.get("abort"): + return self.async_abort(reason="abort") + + errors["entry_id"] = self._config_entry_id + try: + errors["entry"] = self.config_entry + except config_entries.UnknownEntry as err: + errors["entry"] = err + + return self.async_show_form(step_id="init", errors=errors) + + return _OptionsFlow() + + with mock_config_flow("test", TestFlow): + result = await hass.config_entries.options.async_init(original_entry.entry_id) + + options_flow = hass.config_entries.options._progress.get(result["flow_id"]) + assert isinstance(options_flow, config_entries.OptionsFlow) + assert options_flow.handler == original_entry.entry_id + assert isinstance(options_flow.init_entry_id, ValueError) + assert ( + str(options_flow.init_entry_id) + == "The config entry id is not available during initialisation" + ) + assert isinstance(options_flow.init_entry, ValueError) + assert ( + str(options_flow.init_entry) + == "The config entry is not available during initialisation" + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {} + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"]["entry_id"] == original_entry.entry_id + assert result["errors"]["entry"] is original_entry + + # Bad handler - not linked to a config entry + options_flow.handler = "123" + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"]["entry_id"] == "123" + assert isinstance(result["errors"]["entry"], config_entries.UnknownEntry) + # Reset handler + options_flow.handler = original_entry.entry_id + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"abort": True} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "abort" + + +@pytest.mark.usefixtures("mock_integration_frame") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_options_flow_deprecated_config_entry_setter( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that setting config_entry explicitly still works.""" + original_entry = MockConfigEntry(domain="hue", data={}) + original_entry.add_to_hass(hass) + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule("hue", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "hue.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Test options flow.""" + + class _OptionsFlow(config_entries.OptionsFlow): + """Test flow.""" + + def __init__(self, entry) -> None: + """Test initialisation.""" + self.config_entry = entry + + async def async_step_init(self, user_input=None): + """Test user step.""" + errors = {} + if user_input is not None: + if user_input.get("abort"): + return self.async_abort(reason="abort") + + errors["entry_id"] = self._config_entry_id + try: + errors["entry"] = self.config_entry + except config_entries.UnknownEntry as err: + errors["entry"] = err + + return self.async_show_form(step_id="init", errors=errors) + + return _OptionsFlow(config_entry) + + with mock_config_flow("hue", TestFlow): + result = await hass.config_entries.options.async_init(original_entry.entry_id) + + options_flow = hass.config_entries.options._progress.get(result["flow_id"]) + assert options_flow.config_entry is original_entry + + assert ( + "Detected that integration 'hue' sets option flow config_entry explicitly, " + "which is deprecated and will stop working in 2025.12" in caplog.text + ) + + async def test_add_description_placeholder_automatically( hass: HomeAssistant, manager: config_entries.ConfigEntries, From 5ed7d327497c28e7920599ee9f5c7c0ed6b35e4c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 1 Nov 2024 13:44:49 +0100 Subject: [PATCH 0035/1070] Remove unnecessary asyncio EventLoopPolicy init_watcher backport (#129628) --- homeassistant/runner.py | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 102dbafe147f9..5977565585450 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -3,10 +3,8 @@ from __future__ import annotations import asyncio -from asyncio import events import dataclasses import logging -import os import subprocess import threading from time import monotonic @@ -58,22 +56,6 @@ class RuntimeConfig: safe_mode: bool = False -def can_use_pidfd() -> bool: - """Check if pidfd_open is available. - - Back ported from cpython 3.12 - """ - if not hasattr(os, "pidfd_open"): - return False - try: - pid = os.getpid() - os.close(os.pidfd_open(pid, 0)) - except OSError: - # blocked by security policy like SECCOMP - return False - return True - - class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): """Event loop policy for Home Assistant.""" @@ -81,23 +63,6 @@ def __init__(self, debug: bool) -> None: """Init the event loop policy.""" super().__init__() self.debug = debug - self._watcher: asyncio.AbstractChildWatcher | None = None - - def _init_watcher(self) -> None: - """Initialize the watcher for child processes. - - Back ported from cpython 3.12 - """ - with events._lock: # type: ignore[attr-defined] # noqa: SLF001 - if self._watcher is None: # pragma: no branch - if can_use_pidfd(): - self._watcher = asyncio.PidfdChildWatcher() - else: - self._watcher = asyncio.ThreadedChildWatcher() - if threading.current_thread() is threading.main_thread(): - self._watcher.attach_loop( - self._local._loop # type: ignore[attr-defined] # noqa: SLF001 - ) @property def loop_name(self) -> str: From 4da93f6a5ed4079ae292a1908d2b798a8a0e7fac Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Nov 2024 15:12:15 +0100 Subject: [PATCH 0036/1070] Bump spotifyaio to 0.8.1 (#129573) --- .../components/spotify/manifest.json | 2 +- homeassistant/components/spotify/sensor.py | 28 ++++++++++++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../spotify/snapshots/test_sensor.ambr | 22 +++++++-------- 5 files changed, 35 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index f799f9d8ea592..61d559232d60e 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.7.1"], + "requirements": ["spotifyaio==0.8.1"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/homeassistant/components/spotify/sensor.py b/homeassistant/components/spotify/sensor.py index 032799e69d02c..3486a911b0d0d 100644 --- a/homeassistant/components/spotify/sensor.py +++ b/homeassistant/components/spotify/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass -from spotifyaio.models import AudioFeatures +from spotifyaio.models import AudioFeatures, Key from homeassistant.components.sensor import ( SensorDeviceClass, @@ -25,14 +25,28 @@ class SpotifyAudioFeaturesSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[AudioFeatures], float | str | None] +KEYS: dict[Key, str] = { + Key.C: "C", + Key.C_SHARP_D_FLAT: "C♯/D♭", + Key.D: "D", + Key.D_SHARP_E_FLAT: "D♯/E♭", + Key.E: "E", + Key.F: "F", + Key.F_SHARP_G_FLAT: "F♯/G♭", + Key.G: "G", + Key.G_SHARP_A_FLAT: "G♯/A♭", + Key.A: "A", + Key.A_SHARP_B_FLAT: "A♯/B♭", + Key.B: "B", +} + +KEY_OPTIONS = list(KEYS.values()) + + def _get_key(audio_features: AudioFeatures) -> str | None: if audio_features.key is None: return None - key_name = audio_features.key.name - base = key_name[0] - if len(key_name) > 1: - base = f"{base}♯" - return base + return KEYS[audio_features.key] AUDIO_FEATURE_SENSORS: tuple[SpotifyAudioFeaturesSensorEntityDescription, ...] = ( @@ -119,7 +133,7 @@ def _get_key(audio_features: AudioFeatures) -> str | None: key="key", translation_key="key", device_class=SensorDeviceClass.ENUM, - options=["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"], + options=KEY_OPTIONS, value_fn=_get_key, entity_registry_enabled_default=False, ), diff --git a/requirements_all.txt b/requirements_all.txt index cee049199e3a8..cbc8d60c7287b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2710,7 +2710,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.7.1 +spotifyaio==0.8.1 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dee450aed2619..11a74b9a4e0c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2162,7 +2162,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.7.1 +spotifyaio==0.8.1 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/tests/components/spotify/snapshots/test_sensor.ambr b/tests/components/spotify/snapshots/test_sensor.ambr index 347b12dd1d8ac..ce77dda479f2c 100644 --- a/tests/components/spotify/snapshots/test_sensor.ambr +++ b/tests/components/spotify/snapshots/test_sensor.ambr @@ -207,16 +207,16 @@ 'capabilities': dict({ 'options': list([ 'C', - 'C♯', + 'C♯/D♭', 'D', - 'D♯', + 'D♯/E♭', 'E', 'F', - 'F♯', + 'F♯/G♭', 'G', - 'G♯', + 'G♯/A♭', 'A', - 'A♯', + 'A♯/B♭', 'B', ]), }), @@ -254,16 +254,16 @@ 'friendly_name': 'Spotify spotify_1 Song key', 'options': list([ 'C', - 'C♯', + 'C♯/D♭', 'D', - 'D♯', + 'D♯/E♭', 'E', 'F', - 'F♯', + 'F♯/G♭', 'G', - 'G♯', + 'G♯/A♭', 'A', - 'A♯', + 'A♯/B♭', 'B', ]), }), @@ -272,7 +272,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'D♯', + 'state': 'D♯/E♭', }) # --- # name: test_entities[sensor.spotify_spotify_1_song_liveness-entry] From 31dcc25ba525c2411ce8119c13ada03abae4eb00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 1 Nov 2024 16:25:22 +0100 Subject: [PATCH 0037/1070] Add handler to restore a backup file with the backup integration (#128365) * Early pushout of restore handling for core/container * Adjust after rebase * Move logging definition, we should only do this if we go ahead with the restore * First round * More paths * Add async_restore_backup to base class * Block restore of new backup files * manager tests * Add websocket test * Add testing to main * Add coverage for missing backup file * Catch FileNotFoundError instead * Patch Path.read_text instead * Remove HA_RESTORE from keep * Use secure paths * Fix restart test * extend coverage * Mock argv * Adjustments --- homeassistant/__main__.py | 4 + homeassistant/backup_restore.py | 126 ++++++++++ homeassistant/components/backup/const.py | 1 + homeassistant/components/backup/manager.py | 24 ++ homeassistant/components/backup/websocket.py | 19 ++ homeassistant/package_constraints.txt | 1 + pyproject.toml | 1 + requirements.txt | 1 + .../backup/snapshots/test_websocket.ambr | 19 ++ tests/components/backup/test_manager.py | 28 +++ tests/components/backup/test_websocket.py | 26 +++ tests/test_backup_restore.py | 220 ++++++++++++++++++ tests/test_main.py | 12 +- 13 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 homeassistant/backup_restore.py create mode 100644 tests/test_backup_restore.py diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 4c870e94b24e0..b9d9883270503 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -9,6 +9,7 @@ import sys import threading +from .backup_restore import restore_backup from .const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__ FAULT_LOG_FILENAME = "home-assistant.log.fault" @@ -182,6 +183,9 @@ def main() -> int: return scripts.run(args.script) config_dir = os.path.abspath(os.path.join(os.getcwd(), args.config)) + if restore_backup(config_dir): + return RESTART_EXIT_CODE + ensure_config_path(config_dir) # pylint: disable-next=import-outside-toplevel diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py new file mode 100644 index 0000000000000..6cf96fdfa91d1 --- /dev/null +++ b/homeassistant/backup_restore.py @@ -0,0 +1,126 @@ +"""Home Assistant module to handle restoring backups.""" + +from dataclasses import dataclass +import json +import logging +from pathlib import Path +import shutil +import sys +from tempfile import TemporaryDirectory + +from awesomeversion import AwesomeVersion +import securetar + +from .const import __version__ as HA_VERSION + +RESTORE_BACKUP_FILE = ".HA_RESTORE" +KEEP_PATHS = ("backups",) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class RestoreBackupFileContent: + """Definition for restore backup file content.""" + + backup_file_path: Path + + +def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None: + """Return the contents of the restore backup file.""" + instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE) + try: + instruction_content = instruction_path.read_text(encoding="utf-8") + return RestoreBackupFileContent( + backup_file_path=Path(instruction_content.split(";")[0]) + ) + except FileNotFoundError: + return None + + +def _clear_configuration_directory(config_dir: Path) -> None: + """Delete all files and directories in the config directory except for the backups directory.""" + keep_paths = [config_dir.joinpath(path) for path in KEEP_PATHS] + config_contents = sorted( + [entry for entry in config_dir.iterdir() if entry not in keep_paths] + ) + + for entry in config_contents: + entrypath = config_dir.joinpath(entry) + + if entrypath.is_file(): + entrypath.unlink() + elif entrypath.is_dir(): + shutil.rmtree(entrypath) + + +def _extract_backup(config_dir: Path, backup_file_path: Path) -> None: + """Extract the backup file to the config directory.""" + with ( + TemporaryDirectory() as tempdir, + securetar.SecureTarFile( + backup_file_path, + gzip=False, + mode="r", + ) as ostf, + ): + ostf.extractall( + path=Path(tempdir, "extracted"), + members=securetar.secure_path(ostf), + filter="fully_trusted", + ) + backup_meta_file = Path(tempdir, "extracted", "backup.json") + backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8")) + + if ( + backup_meta_version := AwesomeVersion( + backup_meta["homeassistant"]["version"] + ) + ) > HA_VERSION: + raise ValueError( + f"You need at least Home Assistant version {backup_meta_version} to restore this backup" + ) + + with securetar.SecureTarFile( + Path( + tempdir, + "extracted", + f"homeassistant.tar{'.gz' if backup_meta["compressed"] else ''}", + ), + gzip=backup_meta["compressed"], + mode="r", + ) as istf: + for member in istf.getmembers(): + if member.name == "data": + continue + member.name = member.name.replace("data/", "") + _clear_configuration_directory(config_dir) + istf.extractall( + path=config_dir, + members=[ + member + for member in securetar.secure_path(istf) + if member.name != "data" + ], + filter="fully_trusted", + ) + + +def restore_backup(config_dir_path: str) -> bool: + """Restore the backup file if any. + + Returns True if a restore backup file was found and restored, False otherwise. + """ + config_dir = Path(config_dir_path) + if not (restore_content := restore_backup_file_content(config_dir)): + return False + + logging.basicConfig(stream=sys.stdout, level=logging.INFO) + backup_file_path = restore_content.backup_file_path + _LOGGER.info("Restoring %s", backup_file_path) + try: + _extract_backup(config_dir, backup_file_path) + except FileNotFoundError as err: + raise ValueError(f"Backup file {backup_file_path} does not exist") from err + _LOGGER.info("Restore complete, restarting") + return True diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py index 90faa33fc7ff2..f613f7cc352a9 100644 --- a/homeassistant/components/backup/const.py +++ b/homeassistant/components/backup/const.py @@ -17,6 +17,7 @@ EXCLUDE_FROM_BACKUP = [ "__pycache__/*", ".DS_Store", + ".HA_RESTORE", "*.db-shm", "*.log.*", "*.log", diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 701174e1b8d63..8120e3a6e66a6 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -16,6 +16,7 @@ from securetar import SecureTarFile, atomic_contents_add +from homeassistant.backup_restore import RESTORE_BACKUP_FILE from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -123,6 +124,10 @@ async def load_platforms(self) -> None: LOGGER.debug("Loaded %s platforms", len(self.platforms)) self.loaded_platforms = True + @abc.abstractmethod + async def async_restore_backup(self, slug: str, **kwargs: Any) -> None: + """Restpre a backup.""" + @abc.abstractmethod async def async_create_backup(self, **kwargs: Any) -> Backup: """Generate a backup.""" @@ -291,6 +296,25 @@ def _mkdir_and_generate_backup_contents( return tar_file_path.stat().st_size + async def async_restore_backup(self, slug: str, **kwargs: Any) -> None: + """Restore a backup. + + This will write the restore information to .HA_RESTORE which + will be handled during startup by the restore_backup module. + """ + if (backup := await self.async_get_backup(slug=slug)) is None: + raise HomeAssistantError(f"Backup {slug} not found") + + def _write_restore_file() -> None: + """Write the restore file.""" + Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text( + f"{backup.path.as_posix()};", + encoding="utf-8", + ) + + await self.hass.async_add_executor_job(_write_restore_file) + await self.hass.services.async_call("homeassistant", "restart", {}) + def _generate_slug(date: str, name: str) -> str: """Generate a backup slug.""" diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 7daaaad1ec77b..3ac8a7ace3e67 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -22,6 +22,7 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> websocket_api.async_register_command(hass, handle_info) websocket_api.async_register_command(hass, handle_create) websocket_api.async_register_command(hass, handle_remove) + websocket_api.async_register_command(hass, handle_restore) @websocket_api.require_admin @@ -85,6 +86,24 @@ async def handle_remove( connection.send_result(msg["id"]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/restore", + vol.Required("slug"): str, + } +) +@websocket_api.async_response +async def handle_restore( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Restore a backup.""" + await hass.data[DATA_MANAGER].async_restore_backup(msg["slug"]) + connection.send_result(msg["id"]) + + @websocket_api.require_admin @websocket_api.websocket_command({vol.Required("type"): "backup/generate"}) @websocket_api.async_response diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e1547949588db..1525aa141411a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -57,6 +57,7 @@ PyTurboJPEG==1.7.5 pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 +securetar==2024.2.1 SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 diff --git a/pyproject.toml b/pyproject.toml index 2d5b0da46cc75..90e0ece377623 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.2", "requests==2.32.3", + "securetar==2024.2.1", "SQLAlchemy==2.0.31", "typing-extensions>=4.12.2,<5.0", "ulid-transform==1.0.2", diff --git a/requirements.txt b/requirements.txt index ecca136e1a748..df37f89a894a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,6 +35,7 @@ psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 +securetar==2024.2.1 SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 07e099561b11a..096df37d70477 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -269,3 +269,22 @@ 'type': 'result', }) # --- +# name: test_restore[with_hassio] + dict({ + 'error': dict({ + 'code': 'unknown_command', + 'message': 'Unknown command.', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_restore[without_hassio] + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 1bf801a0fcf79..a269a3f2f1793 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -333,3 +333,31 @@ async def test_loading_platforms_when_running_async_post_backup_actions( assert len(manager.platforms) == 1 assert "Loaded 1 platforms" in caplog.text + + +async def test_async_trigger_restore( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test trigger restore.""" + manager = BackupManager(hass) + manager.loaded_backups = True + manager.backups = {TEST_BACKUP.slug: TEST_BACKUP} + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.write_text") as mocked_write_text, + patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + ): + await manager.async_restore_backup(TEST_BACKUP.slug) + assert mocked_write_text.call_args[0][0] == "abc123.tar;" + assert mocked_service_call.called + + +async def test_async_trigger_restore_missing_backup(hass: HomeAssistant) -> None: + """Test trigger restore.""" + manager = BackupManager(hass) + manager.loaded_backups = True + + with pytest.raises(HomeAssistantError, match="Backup abc123 not found"): + await manager.async_restore_backup(TEST_BACKUP.slug) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 805182391da26..125ba8adaad1c 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -141,6 +141,32 @@ async def test_generate( assert snapshot == await client.receive_json() +@pytest.mark.parametrize( + "with_hassio", + [ + pytest.param(True, id="with_hassio"), + pytest.param(False, id="without_hassio"), + ], +) +async def test_restore( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + with_hassio: bool, +) -> None: + """Test calling the restore command.""" + await setup_backup_integration(hass, with_hassio=with_hassio) + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + ): + await client.send_json_auto_id({"type": "backup/restore", "slug": "abc123"}) + assert await client.receive_json() == snapshot + + @pytest.mark.parametrize( "access_token_fixture_name", ["hass_access_token", "hass_supervisor_access_token"], diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py new file mode 100644 index 0000000000000..fabb403468d9a --- /dev/null +++ b/tests/test_backup_restore.py @@ -0,0 +1,220 @@ +"""Test methods in backup_restore.""" + +from pathlib import Path +import tarfile +from unittest import mock + +import pytest + +from homeassistant import backup_restore + +from .common import get_test_config_dir + + +@pytest.mark.parametrize( + ("side_effect", "content", "expected"), + [ + (FileNotFoundError, "", None), + (None, "", backup_restore.RestoreBackupFileContent(backup_file_path=Path(""))), + ( + None, + "test;", + backup_restore.RestoreBackupFileContent(backup_file_path=Path("test")), + ), + ( + None, + "test;;;;", + backup_restore.RestoreBackupFileContent(backup_file_path=Path("test")), + ), + ], +) +def test_reading_the_instruction_contents( + side_effect: Exception | None, + content: str, + expected: backup_restore.RestoreBackupFileContent | None, +) -> None: + """Test reading the content of the .HA_RESTORE file.""" + with ( + mock.patch( + "pathlib.Path.read_text", + return_value=content, + side_effect=side_effect, + ), + ): + read_content = backup_restore.restore_backup_file_content( + Path(get_test_config_dir()) + ) + assert read_content == expected + + +def test_restoring_backup_that_does_not_exist() -> None: + """Test restoring a backup that does not exist.""" + backup_file_path = Path(get_test_config_dir("backups", "test")) + with ( + mock.patch( + "homeassistant.backup_restore.restore_backup_file_content", + return_value=backup_restore.RestoreBackupFileContent( + backup_file_path=backup_file_path + ), + ), + mock.patch("pathlib.Path.read_text", side_effect=FileNotFoundError), + pytest.raises( + ValueError, match=f"Backup file {backup_file_path} does not exist" + ), + ): + assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + + +def test_restoring_backup_when_instructions_can_not_be_read() -> None: + """Test restoring a backup when instructions can not be read.""" + with ( + mock.patch( + "homeassistant.backup_restore.restore_backup_file_content", + return_value=None, + ), + ): + assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + + +def test_restoring_backup_that_is_not_a_file() -> None: + """Test restoring a backup that is not a file.""" + backup_file_path = Path(get_test_config_dir("backups", "test")) + with ( + mock.patch( + "homeassistant.backup_restore.restore_backup_file_content", + return_value=backup_restore.RestoreBackupFileContent( + backup_file_path=backup_file_path + ), + ), + mock.patch("pathlib.Path.exists", return_value=True), + mock.patch("pathlib.Path.is_file", return_value=False), + pytest.raises( + ValueError, match=f"Backup file {backup_file_path} does not exist" + ), + ): + assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + + +def test_aborting_for_older_versions() -> None: + """Test that we abort for older versions.""" + config_dir = Path(get_test_config_dir()) + backup_file_path = Path(config_dir, "backups", "test.tar") + + def _patched_path_read_text(path: Path, **kwargs): + return '{"homeassistant": {"version": "9999.99.99"}, "compressed": false}' + + with ( + mock.patch( + "homeassistant.backup_restore.restore_backup_file_content", + return_value=backup_restore.RestoreBackupFileContent( + backup_file_path=backup_file_path + ), + ), + mock.patch("securetar.SecureTarFile"), + mock.patch("homeassistant.backup_restore.TemporaryDirectory"), + mock.patch("pathlib.Path.read_text", _patched_path_read_text), + mock.patch("homeassistant.backup_restore.HA_VERSION", "2013.09.17"), + pytest.raises( + ValueError, + match="You need at least Home Assistant version 9999.99.99 to restore this backup", + ), + ): + assert backup_restore.restore_backup(config_dir) is True + + +def test_removal_of_current_configuration_when_restoring() -> None: + """Test that we are removing the current configuration directory.""" + config_dir = Path(get_test_config_dir()) + backup_file_path = Path(config_dir, "backups", "test.tar") + mock_config_dir = [ + {"path": Path(config_dir, ".HA_RESTORE"), "is_file": True}, + {"path": Path(config_dir, ".HA_VERSION"), "is_file": True}, + {"path": Path(config_dir, "backups"), "is_file": False}, + {"path": Path(config_dir, "www"), "is_file": False}, + ] + + def _patched_path_read_text(path: Path, **kwargs): + return '{"homeassistant": {"version": "2013.09.17"}, "compressed": false}' + + def _patched_path_is_file(path: Path, **kwargs): + return [x for x in mock_config_dir if x["path"] == path][0]["is_file"] + + def _patched_path_is_dir(path: Path, **kwargs): + return not [x for x in mock_config_dir if x["path"] == path][0]["is_file"] + + with ( + mock.patch( + "homeassistant.backup_restore.restore_backup_file_content", + return_value=backup_restore.RestoreBackupFileContent( + backup_file_path=backup_file_path + ), + ), + mock.patch("securetar.SecureTarFile"), + mock.patch("homeassistant.backup_restore.TemporaryDirectory"), + mock.patch("homeassistant.backup_restore.HA_VERSION", "2013.09.17"), + mock.patch("pathlib.Path.read_text", _patched_path_read_text), + mock.patch("pathlib.Path.is_file", _patched_path_is_file), + mock.patch("pathlib.Path.is_dir", _patched_path_is_dir), + mock.patch( + "pathlib.Path.iterdir", + return_value=[x["path"] for x in mock_config_dir], + ), + mock.patch("pathlib.Path.unlink") as unlink_mock, + mock.patch("shutil.rmtree") as rmtreemock, + ): + assert backup_restore.restore_backup(config_dir) is True + assert unlink_mock.call_count == 2 + assert ( + rmtreemock.call_count == 1 + ) # We have 2 directories in the config directory, but backups is kept + + removed_directories = {Path(call.args[0]) for call in rmtreemock.mock_calls} + assert removed_directories == {Path(config_dir, "www")} + + +def test_extracting_the_contents_of_a_backup_file() -> None: + """Test extracting the contents of a backup file.""" + config_dir = Path(get_test_config_dir()) + backup_file_path = Path(config_dir, "backups", "test.tar") + + def _patched_path_read_text(path: Path, **kwargs): + return '{"homeassistant": {"version": "2013.09.17"}, "compressed": false}' + + getmembers_mock = mock.MagicMock( + return_value=[ + tarfile.TarInfo(name="data"), + tarfile.TarInfo(name="data/../test"), + tarfile.TarInfo(name="data/.HA_VERSION"), + tarfile.TarInfo(name="data/.storage"), + tarfile.TarInfo(name="data/www"), + ] + ) + extractall_mock = mock.MagicMock() + + with ( + mock.patch( + "homeassistant.backup_restore.restore_backup_file_content", + return_value=backup_restore.RestoreBackupFileContent( + backup_file_path=backup_file_path + ), + ), + mock.patch( + "tarfile.open", + return_value=mock.MagicMock( + getmembers=getmembers_mock, + extractall=extractall_mock, + __iter__=lambda x: iter(getmembers_mock.return_value), + ), + ), + mock.patch("homeassistant.backup_restore.TemporaryDirectory"), + mock.patch("pathlib.Path.read_text", _patched_path_read_text), + mock.patch("pathlib.Path.is_file", return_value=False), + mock.patch("pathlib.Path.iterdir", return_value=[]), + ): + assert backup_restore.restore_backup(config_dir) is True + assert getmembers_mock.call_count == 1 + assert extractall_mock.call_count == 2 + + assert { + member.name for member in extractall_mock.mock_calls[-1].kwargs["members"] + } == {".HA_VERSION", ".storage", "www"} diff --git a/tests/test_main.py b/tests/test_main.py index 080787311a01e..d32ca59a846f3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,7 +3,7 @@ from unittest.mock import PropertyMock, patch from homeassistant import __main__ as main -from homeassistant.const import REQUIRED_PYTHON_VER +from homeassistant.const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE @patch("sys.exit") @@ -86,3 +86,13 @@ def parse_args(*args): assert mock_exit.called is False args = parse_args("--skip-pip", "--skip-pip-packages", "foo") assert mock_exit.called is True + + +def test_restart_after_backup_restore() -> None: + """Test restarting if we restored a backup.""" + with ( + patch("sys.argv", ["python"]), + patch("homeassistant.__main__.restore_backup", return_value=True), + ): + exit_code = main.main() + assert exit_code == RESTART_EXIT_CODE From 17f3ba143466e035d7107aaccd55815e81611678 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 1 Nov 2024 17:24:44 +0100 Subject: [PATCH 0038/1070] Bump webrtc-models to 0.2.0 (#129627) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1525aa141411a..42bda4d3c4027 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -66,7 +66,7 @@ uv==0.4.28 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -webrtc-models==0.1.0 +webrtc-models==0.2.0 yarl==1.17.1 zeroconf==0.136.0 diff --git a/pyproject.toml b/pyproject.toml index 90e0ece377623..0c9c825e535a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,7 @@ dependencies = [ "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", "yarl==1.17.1", - "webrtc-models==0.1.0", + "webrtc-models==0.2.0", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index df37f89a894a5..e90164ed272cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,4 +45,4 @@ voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 yarl==1.17.1 -webrtc-models==0.1.0 +webrtc-models==0.2.0 From 37f42707e5b233bd3368b3eb82558bec8a7d0b7c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Nov 2024 17:33:39 +0100 Subject: [PATCH 0039/1070] Fix Geniushub setup (#129569) --- homeassistant/components/geniushub/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index 18580f331d223..f3081e50289ff 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -170,7 +170,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> ) session = async_get_clientsession(hass) - unique_id: str if CONF_HOST in entry.data: client = GeniusHub( entry.data[CONF_HOST], @@ -178,10 +177,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> password=entry.data[CONF_PASSWORD], session=session, ) - unique_id = entry.data[CONF_MAC] else: client = GeniusHub(entry.data[CONF_TOKEN], session=session) - unique_id = entry.entry_id + + unique_id = entry.unique_id or entry.entry_id broker = entry.runtime_data = GeniusBroker(hass, client, unique_id) From 02b34f05aa40e35186113ee80ff7ec3ff1c538ee Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Nov 2024 18:25:26 +0100 Subject: [PATCH 0040/1070] Bump spotifyaio to 0.8.2 (#129639) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 61d559232d60e..5885d0103f214 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.1"], + "requirements": ["spotifyaio==0.8.2"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index cbc8d60c7287b..6af44815d4e51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2710,7 +2710,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.1 +spotifyaio==0.8.2 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 11a74b9a4e0c3..9ffdf868e3db0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2162,7 +2162,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.1 +spotifyaio==0.8.2 # homeassistant.components.sql sqlparse==0.5.0 From f55aa0b86e80eccab7e5c9185e79b27d4c2507e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Nov 2024 13:16:15 -0500 Subject: [PATCH 0041/1070] Bump aioesphomeapi to 27.0.1 (#129643) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 410c826c5a0c6..b9b6a98dcd13d 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==27.0.0", + "aioesphomeapi==27.0.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==1.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 6af44815d4e51..03f24a3ec699b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -240,7 +240,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==27.0.0 +aioesphomeapi==27.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ffdf868e3db0..fa1926fd440ea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -228,7 +228,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==27.0.0 +aioesphomeapi==27.0.1 # homeassistant.components.flo aioflo==2021.11.0 From a6865f1639502b76aa108ead24aa449f87ab5502 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Nov 2024 14:01:33 -0500 Subject: [PATCH 0042/1070] Bump aiohomekit to 3.2.6 (#129640) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 598e8078a2c05..cddd61a12c114 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.5"], + "requirements": ["aiohomekit==3.2.6"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 03f24a3ec699b..15543947bc6fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -262,7 +262,7 @@ aioharmony==0.2.10 aiohasupervisor==0.2.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.5 +aiohomekit==3.2.6 # homeassistant.components.hue aiohue==4.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa1926fd440ea..bf50a5947c87f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -247,7 +247,7 @@ aioharmony==0.2.10 aiohasupervisor==0.2.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.5 +aiohomekit==3.2.6 # homeassistant.components.hue aiohue==4.7.3 From 269aefd405d6b988ff1978adb32a2977e2d9802c Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sat, 2 Nov 2024 11:29:08 +0100 Subject: [PATCH 0043/1070] Bump ruff to 0.7.2 (#129669) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a619936cbbf3e..f89dadda43df5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.1 + rev: v0.7.2 hooks: - id: ruff args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index a1c6304220c74..bab89d20584f4 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.3.0 -ruff==0.7.1 +ruff==0.7.2 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 5f32b5a38c1d3..cd53c25ffc6c3 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -22,7 +22,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.4.28,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.1 \ + stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.2 \ PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.10.30 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" From d6e73a89f39a8d5b2404798e2f4c6ff5215bb6ba Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 2 Nov 2024 18:15:41 +0100 Subject: [PATCH 0044/1070] Cleanup unnecessary __init__ method in OptionsFlow (#129651) * Cleanup unnecessary init step in OptionsFlow * Increase coverage --- homeassistant/components/canary/config_flow.py | 6 +----- homeassistant/components/coinbase/config_flow.py | 6 +----- homeassistant/components/control4/config_flow.py | 6 +----- homeassistant/components/denonavr/config_flow.py | 6 +----- homeassistant/components/dexcom/config_flow.py | 6 +----- homeassistant/components/dlna_dmr/config_flow.py | 6 +----- homeassistant/components/doorbird/config_flow.py | 6 +----- homeassistant/components/esphome/config_flow.py | 6 +----- homeassistant/components/ezviz/config_flow.py | 6 +----- .../components/forecast_solar/config_flow.py | 6 +----- .../components/forked_daapd/config_flow.py | 6 +----- .../components/fritzbox_callmonitor/config_flow.py | 6 +----- homeassistant/components/github/config_flow.py | 6 +----- homeassistant/components/google/config_flow.py | 6 +----- .../components/google_assistant_sdk/config_flow.py | 6 +----- .../components/google_travel_time/config_flow.py | 6 +----- homeassistant/components/harmony/config_flow.py | 7 +------ homeassistant/components/honeywell/config_flow.py | 6 +----- homeassistant/components/huawei_lte/config_flow.py | 6 +----- homeassistant/components/hue/config_flow.py | 12 ++---------- homeassistant/components/ibeacon/config_flow.py | 6 +----- .../components/islamic_prayer_times/config_flow.py | 6 +----- homeassistant/components/isy994/config_flow.py | 6 +----- homeassistant/components/kmtronic/config_flow.py | 6 +----- homeassistant/components/kraken/config_flow.py | 6 +----- homeassistant/components/litejet/config_flow.py | 6 +----- homeassistant/components/mikrotik/config_flow.py | 6 +----- homeassistant/components/mjpeg/config_flow.py | 6 +----- homeassistant/components/monoprice/config_flow.py | 6 +----- homeassistant/components/mopeka/config_flow.py | 6 +----- .../components/motion_blinds/config_flow.py | 6 +----- .../components/motionblinds_ble/config_flow.py | 6 +----- homeassistant/components/netgear/config_flow.py | 6 +----- homeassistant/components/nobo_hub/config_flow.py | 6 +----- homeassistant/components/nut/config_flow.py | 6 +----- homeassistant/components/omnilogic/config_flow.py | 6 +----- .../components/opentherm_gw/config_flow.py | 6 +----- .../components/openweathermap/config_flow.py | 6 +----- homeassistant/components/ping/config_flow.py | 6 +----- homeassistant/components/proximity/config_flow.py | 6 +----- homeassistant/components/rachio/config_flow.py | 6 +----- homeassistant/components/rainbird/config_flow.py | 6 +----- homeassistant/components/rainmachine/config_flow.py | 6 +----- homeassistant/components/reolink/config_flow.py | 6 +----- .../components/rtsp_to_webrtc/config_flow.py | 6 +----- homeassistant/components/screenlogic/config_flow.py | 6 +----- homeassistant/components/sentry/config_flow.py | 6 +----- homeassistant/components/shelly/config_flow.py | 6 +----- homeassistant/components/simplisafe/config_flow.py | 6 +----- homeassistant/components/sonarr/config_flow.py | 6 +----- homeassistant/components/subaru/config_flow.py | 6 +----- homeassistant/components/switchbot/config_flow.py | 6 +----- .../components/synology_dsm/config_flow.py | 6 +----- homeassistant/components/tado/config_flow.py | 6 +----- .../components/totalconnect/config_flow.py | 6 +----- .../components/transmission/config_flow.py | 6 +----- .../components/unifiprotect/config_flow.py | 6 +----- homeassistant/components/upcloud/config_flow.py | 6 +----- homeassistant/components/vera/config_flow.py | 6 +----- homeassistant/components/vizio/config_flow.py | 6 +----- homeassistant/components/voip/config_flow.py | 6 +----- .../components/waze_travel_time/config_flow.py | 6 +----- homeassistant/components/wemo/config_flow.py | 6 +----- homeassistant/components/wiffi/config_flow.py | 6 +----- homeassistant/components/ws66i/config_flow.py | 6 +----- homeassistant/components/xiaomi_miio/config_flow.py | 6 +----- tests/components/isy994/test_config_flow.py | 13 +++++++++++++ tests/components/rachio/test_config_flow.py | 13 +++++++++++++ 68 files changed, 93 insertions(+), 336 deletions(-) diff --git a/homeassistant/components/canary/config_flow.py b/homeassistant/components/canary/config_flow.py index 5af7142af8fa2..2dd3a678b5da1 100644 --- a/homeassistant/components/canary/config_flow.py +++ b/homeassistant/components/canary/config_flow.py @@ -52,7 +52,7 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Get the options flow for this handler.""" - return CanaryOptionsFlowHandler(config_entry) + return CanaryOptionsFlowHandler() async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Handle a flow initiated by configuration file.""" @@ -104,10 +104,6 @@ async def async_step_user( class CanaryOptionsFlowHandler(OptionsFlow): """Handle Canary client options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 616fdaf8f7ab4..8b7b4b9e3135a 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -158,16 +158,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle a option flow for Coinbase.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 77ae2c98c7d9d..19fae1ef7ca62 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -154,16 +154,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle a option flow for Control4.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 9a7d2a30438d2..9ff0541158833 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -52,10 +52,6 @@ class OptionsFlowHandler(OptionsFlow): """Options for the component.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Init object.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -119,7 +115,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/dexcom/config_flow.py b/homeassistant/components/dexcom/config_flow.py index c3ed43c8e9ad6..c5c830dedf69d 100644 --- a/homeassistant/components/dexcom/config_flow.py +++ b/homeassistant/components/dexcom/config_flow.py @@ -69,16 +69,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> DexcomOptionsFlowHandler: """Get the options flow for this handler.""" - return DexcomOptionsFlowHandler(config_entry) + return DexcomOptionsFlowHandler() class DexcomOptionsFlowHandler(OptionsFlow): """Handle a option flow for Dexcom.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index 06ac935e8d9db..75f50192500d4 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -74,7 +74,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlow: """Define the config flow to handle options.""" - return DlnaDmrOptionsFlowHandler(config_entry) + return DlnaDmrOptionsFlowHandler() async def async_step_user(self, user_input: FlowInput = None) -> ConfigFlowResult: """Handle a flow initialized by the user. @@ -327,10 +327,6 @@ class DlnaDmrOptionsFlowHandler(OptionsFlow): Configures the single instance and updates the existing config entry. """ - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 650ddb8811dc1..ebb1d6fc12605 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -213,16 +213,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle a option flow for doorbird.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 87061b0366ffc..99dae2e68abcd 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -482,16 +482,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle a option flow for esphome.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index aa998cc6f60bf..a7551737c10d9 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -150,7 +150,7 @@ async def _validate_and_create_camera_rtsp(self, data: dict) -> ConfigFlowResult @callback def async_get_options_flow(config_entry: ConfigEntry) -> EzvizOptionsFlowHandler: """Get the options flow for this handler.""" - return EzvizOptionsFlowHandler(config_entry) + return EzvizOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -391,10 +391,6 @@ async def async_step_reauth_confirm( class EzvizOptionsFlowHandler(OptionsFlow): """Handle EZVIZ client options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 982f32eb07bc6..9a64ce6e1fb35 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -41,7 +41,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> ForecastSolarOptionFlowHandler: """Get the options flow for this handler.""" - return ForecastSolarOptionFlowHandler(config_entry) + return ForecastSolarOptionFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -91,10 +91,6 @@ async def async_step_user( class ForecastSolarOptionFlowHandler(OptionsFlow): """Handle options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py index 5f061aa4be187..5fb9f08f1c009 100644 --- a/homeassistant/components/forked_daapd/config_flow.py +++ b/homeassistant/components/forked_daapd/config_flow.py @@ -52,10 +52,6 @@ class ForkedDaapdOptionsFlowHandler(OptionsFlow): """Handle a forked-daapd options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -122,7 +118,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> ForkedDaapdOptionsFlowHandler: """Return options flow handler.""" - return ForkedDaapdOptionsFlowHandler(config_entry) + return ForkedDaapdOptionsFlowHandler() async def validate_input(self, user_input): """Validate the user input.""" diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py index 69efceae281bb..7bd0eacb66aeb 100644 --- a/homeassistant/components/fritzbox_callmonitor/config_flow.py +++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py @@ -141,7 +141,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> FritzBoxCallMonitorOptionsFlowHandler: """Get the options flow for this handler.""" - return FritzBoxCallMonitorOptionsFlowHandler(config_entry) + return FritzBoxCallMonitorOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -278,10 +278,6 @@ async def async_step_reauth_confirm( class FritzBoxCallMonitorOptionsFlowHandler(OptionsFlow): """Handle a fritzbox_callmonitor options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize.""" - self.config_entry = config_entry - @classmethod def _are_prefixes_valid(cls, prefixes: str | None) -> bool: """Check if prefixes are valid.""" diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index 25d8782618f73..9977f9d84cc41 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -211,16 +211,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle a option flow for GitHub.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None, diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index 39b3c2d5666aa..8ae09b5895783 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -238,16 +238,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlow: """Create an options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Google Calendar options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/google_assistant_sdk/config_flow.py b/homeassistant/components/google_assistant_sdk/config_flow.py index ea1ebe9e24aca..cd78c90e29716 100644 --- a/homeassistant/components/google_assistant_sdk/config_flow.py +++ b/homeassistant/components/google_assistant_sdk/config_flow.py @@ -84,16 +84,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlow: """Create the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Google Assistant SDK options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index ee809a23aea8f..08de293bc7dc2 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -148,10 +148,6 @@ def default_options(hass: HomeAssistant) -> dict[str, str]: class GoogleOptionsFlow(OptionsFlow): """Handle an options flow for Google Travel Time.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize google options flow.""" - self.config_entry = config_entry - async def async_step_init(self, user_input=None) -> ConfigFlowResult: """Handle the initial step.""" if user_input is not None: @@ -213,7 +209,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> GoogleOptionsFlow: """Get the options flow for this handler.""" - return GoogleOptionsFlow(config_entry) + return GoogleOptionsFlow() async def async_step_user(self, user_input=None) -> ConfigFlowResult: """Handle the initial step.""" diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index 87eb657a0a98a..b75ad617b3913 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -28,7 +28,6 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN, PREVIOUS_ACTIVE_ACTIVITY, UNIQUE_ID -from .data import HarmonyConfigEntry from .util import ( find_best_name_for_remote, find_unique_id_for_remote, @@ -156,7 +155,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def _async_create_entry_from_valid_input( self, validated: dict[str, Any], user_input: dict[str, Any] @@ -186,10 +185,6 @@ def _options_from_user_input(user_input: dict[str, Any]) -> dict[str, Any]: class OptionsFlowHandler(OptionsFlow): """Handle a option flow for Harmony.""" - def __init__(self, config_entry: HarmonyConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index c9b1dfb950a34..c7cda5006920b 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -129,16 +129,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> HoneywellOptionsFlowHandler: """Options callback for Honeywell.""" - return HoneywellOptionsFlowHandler(config_entry) + return HoneywellOptionsFlowHandler() class HoneywellOptionsFlowHandler(OptionsFlow): """Config flow options for Honeywell.""" - def __init__(self, entry: ConfigEntry) -> None: - """Initialize Honeywell options flow.""" - self.config_entry = entry - async def async_step_init(self, user_input=None) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 02349b2ae7f61..08fdae50c515d 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -69,7 +69,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def _async_show_user_form( self, @@ -345,10 +345,6 @@ async def async_step_reauth_confirm( class OptionsFlowHandler(OptionsFlow): """Huawei LTE options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index e73ae8fe11ddd..8d17f81046154 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -57,8 +57,8 @@ def async_get_options_flow( ) -> HueV1OptionsFlowHandler | HueV2OptionsFlowHandler: """Get the options flow for this handler.""" if config_entry.data.get(CONF_API_VERSION, 1) == 1: - return HueV1OptionsFlowHandler(config_entry) - return HueV2OptionsFlowHandler(config_entry) + return HueV1OptionsFlowHandler() + return HueV2OptionsFlowHandler() def __init__(self) -> None: """Initialize the Hue flow.""" @@ -280,10 +280,6 @@ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResu class HueV1OptionsFlowHandler(OptionsFlow): """Handle Hue options for V1 implementation.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize Hue options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -315,10 +311,6 @@ async def async_step_init( class HueV2OptionsFlowHandler(OptionsFlow): """Handle Hue options for V2 implementation.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize Hue options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/ibeacon/config_flow.py b/homeassistant/components/ibeacon/config_flow.py index feb5a801d5114..c00398e39b070 100644 --- a/homeassistant/components/ibeacon/config_flow.py +++ b/homeassistant/components/ibeacon/config_flow.py @@ -44,16 +44,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlow: """Get the options flow for this handler.""" - return IBeaconOptionsFlow(config_entry) + return IBeaconOptionsFlow() class IBeaconOptionsFlow(OptionsFlow): """Handle options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult: """Manage the options.""" errors = {} diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 2db89183499d4..ce911ccc49d35 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -52,7 +52,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> IslamicPrayerOptionsFlowHandler: """Get the options flow for this handler.""" - return IslamicPrayerOptionsFlowHandler(config_entry) + return IslamicPrayerOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -93,10 +93,6 @@ async def async_step_user( class IslamicPrayerOptionsFlowHandler(OptionsFlow): """Handle Islamic Prayer client options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 0239926f5e3ec..3575fa99a55e7 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -140,7 +140,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlow: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -314,10 +314,6 @@ async def async_step_reauth_confirm( class OptionsFlowHandler(OptionsFlow): """Handle a option flow for ISY/IoX.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/kmtronic/config_flow.py b/homeassistant/components/kmtronic/config_flow.py index 6bf0b878f7291..56b1d4675bce9 100644 --- a/homeassistant/components/kmtronic/config_flow.py +++ b/homeassistant/components/kmtronic/config_flow.py @@ -66,7 +66,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> KMTronicOptionsFlow: """Get the options flow for this handler.""" - return KMTronicOptionsFlow(config_entry) + return KMTronicOptionsFlow() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -102,10 +102,6 @@ class InvalidAuth(HomeAssistantError): class KMTronicOptionsFlow(OptionsFlow): """Handle options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/kraken/config_flow.py b/homeassistant/components/kraken/config_flow.py index 6777851527379..54a817f0a50dc 100644 --- a/homeassistant/components/kraken/config_flow.py +++ b/homeassistant/components/kraken/config_flow.py @@ -33,7 +33,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> KrakenOptionsFlowHandler: """Get the options flow for this handler.""" - return KrakenOptionsFlowHandler(config_entry) + return KrakenOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -53,10 +53,6 @@ async def async_step_user( class KrakenOptionsFlowHandler(OptionsFlow): """Handle Kraken client options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize Kraken options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index b9f8a0f4b66e3..9aa0b19c5060a 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -24,10 +24,6 @@ class LiteJetOptionsFlow(OptionsFlow): """Handle LiteJet options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize LiteJet options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -84,4 +80,4 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> LiteJetOptionsFlow: """Get the options flow for this handler.""" - return LiteJetOptionsFlow(config_entry) + return LiteJetOptionsFlow() diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py index 9830388919471..bca394f0d3842 100644 --- a/homeassistant/components/mikrotik/config_flow.py +++ b/homeassistant/components/mikrotik/config_flow.py @@ -46,7 +46,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> MikrotikOptionsFlowHandler: """Get the options flow for this handler.""" - return MikrotikOptionsFlowHandler(config_entry) + return MikrotikOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -122,10 +122,6 @@ async def async_step_reauth_confirm( class MikrotikOptionsFlowHandler(OptionsFlow): """Handle Mikrotik options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize Mikrotik options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/mjpeg/config_flow.py b/homeassistant/components/mjpeg/config_flow.py index 8426793678812..e0150f8c461d4 100644 --- a/homeassistant/components/mjpeg/config_flow.py +++ b/homeassistant/components/mjpeg/config_flow.py @@ -141,7 +141,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> MJPEGOptionsFlowHandler: """Get the options flow for this handler.""" - return MJPEGOptionsFlowHandler(config_entry) + return MJPEGOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -183,10 +183,6 @@ async def async_step_user( class MJPEGOptionsFlowHandler(OptionsFlow): """Handle MJPEG IP Camera options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize MJPEG IP Camera options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/monoprice/config_flow.py b/homeassistant/components/monoprice/config_flow.py index cac673e38c191..b2619623a07b2 100644 --- a/homeassistant/components/monoprice/config_flow.py +++ b/homeassistant/components/monoprice/config_flow.py @@ -108,7 +108,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> MonopriceOptionsFlowHandler: """Define the config flow to handle options.""" - return MonopriceOptionsFlowHandler(config_entry) + return MonopriceOptionsFlowHandler() @callback @@ -126,10 +126,6 @@ def _key_for_source(index, source, previous_sources): class MonopriceOptionsFlowHandler(OptionsFlow): """Handle a Monoprice options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize.""" - self.config_entry = config_entry - @callback def _previous_sources(self): if CONF_SOURCES in self.config_entry.options: diff --git a/homeassistant/components/mopeka/config_flow.py b/homeassistant/components/mopeka/config_flow.py index 72e9386a47f54..2e35ff4283f45 100644 --- a/homeassistant/components/mopeka/config_flow.py +++ b/homeassistant/components/mopeka/config_flow.py @@ -58,7 +58,7 @@ def async_get_options_flow( config_entry: config_entries.ConfigEntry, ) -> MopekaOptionsFlow: """Return the options flow for this handler.""" - return MopekaOptionsFlow(config_entry) + return MopekaOptionsFlow() async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -139,10 +139,6 @@ async def async_step_user( class MopekaOptionsFlow(config_entries.OptionsFlow): """Handle options for the Mopeka component.""" - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index 131299314a256..e961880375c39 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -38,10 +38,6 @@ class OptionsFlowHandler(OptionsFlow): """Options for the component.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Init object.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -83,7 +79,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_dhcp( self, discovery_info: dhcp.DhcpServiceInfo diff --git a/homeassistant/components/motionblinds_ble/config_flow.py b/homeassistant/components/motionblinds_ble/config_flow.py index cda673b13aca0..d99096d3a09b1 100644 --- a/homeassistant/components/motionblinds_ble/config_flow.py +++ b/homeassistant/components/motionblinds_ble/config_flow.py @@ -187,16 +187,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlow: """Create the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle an options flow for Motionblinds BLE.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index fba934af38d1f..965e3618645b1 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -63,10 +63,6 @@ def _ordered_shared_schema(schema_input): class OptionsFlowHandler(OptionsFlow): """Options for the component.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Init object.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, int] | None = None ) -> ConfigFlowResult: @@ -109,7 +105,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def _show_setup_form( self, diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index 8aed520f21e7a..7e1ae4c1d9b65 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -175,7 +175,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlow: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class NoboHubConnectError(HomeAssistantError): @@ -190,10 +190,6 @@ def __init__(self, msg) -> None: class OptionsFlowHandler(OptionsFlow): """Handles options flow for the component.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize the options flow.""" - self.config_entry = config_entry - async def async_step_init(self, user_input=None) -> ConfigFlowResult: """Manage the options.""" diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index d0a2da124a66f..966c51e98e9d0 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -235,16 +235,12 @@ async def async_step_reauth_confirm( @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle a option flow for nut.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/omnilogic/config_flow.py b/homeassistant/components/omnilogic/config_flow.py index 489c8e6f601ce..dfbd010ea98f6 100644 --- a/homeassistant/components/omnilogic/config_flow.py +++ b/homeassistant/components/omnilogic/config_flow.py @@ -34,7 +34,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -78,10 +78,6 @@ async def async_step_user( class OptionsFlowHandler(OptionsFlow): """Handle Omnilogic client options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 1f52b47cbad0a..80c16ee88e1f8 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -49,7 +49,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OpenThermGwOptionsFlow: """Get the options flow for this handler.""" - return OpenThermGwOptionsFlow(config_entry) + return OpenThermGwOptionsFlow() async def async_step_init( self, info: dict[str, Any] | None = None @@ -132,10 +132,6 @@ def _create_entry(self, gw_id, name, device): class OpenThermGwOptionsFlow(OptionsFlow): """Handle opentherm_gw options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize the options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index 5fe06ea2dcd37..8d33e1172879c 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -44,7 +44,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OpenWeatherMapOptionsFlow: """Get the options flow for this handler.""" - return OpenWeatherMapOptionsFlow(config_entry) + return OpenWeatherMapOptionsFlow() async def async_step_user(self, user_input=None) -> ConfigFlowResult: """Handle a flow initialized by the user.""" @@ -97,10 +97,6 @@ async def async_step_user(self, user_input=None) -> ConfigFlowResult: class OpenWeatherMapOptionsFlow(OptionsFlow): """Handle options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py index 9470b2134d442..4f2adb0d2c0fa 100644 --- a/homeassistant/components/ping/config_flow.py +++ b/homeassistant/components/ping/config_flow.py @@ -66,16 +66,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlow: """Create the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle an options flow for Ping.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/proximity/config_flow.py b/homeassistant/components/proximity/config_flow.py index 1758b182ad75a..5818ec2979b7b 100644 --- a/homeassistant/components/proximity/config_flow.py +++ b/homeassistant/components/proximity/config_flow.py @@ -89,7 +89,7 @@ def _user_form_schema(self, user_input: dict[str, Any] | None = None) -> vol.Sch @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Get the options flow for this handler.""" - return ProximityOptionsFlow(config_entry) + return ProximityOptionsFlow() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -121,10 +121,6 @@ async def async_step_user( class ProximityOptionsFlow(OptionsFlow): """Handle a option flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - def _user_form_schema(self, user_input: dict[str, Any]) -> vol.Schema: return vol.Schema(_base_schema(user_input)) diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py index 6681109182088..fac93952b35af 100644 --- a/homeassistant/components/rachio/config_flow.py +++ b/homeassistant/components/rachio/config_flow.py @@ -108,16 +108,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle a option flow for Rachio.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, int] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index c1c814b05c4a6..abeb1b5da157c 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -65,7 +65,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> RainBirdOptionsFlowHandler: """Define the config flow to handle options.""" - return RainBirdOptionsFlowHandler(config_entry) + return RainBirdOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -165,10 +165,6 @@ async def async_finish( class RainBirdOptionsFlowHandler(OptionsFlow): """Handle a RainBird options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize RainBirdOptionsFlowHandler.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 5c07f04c1639f..0b40d50656623 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -63,7 +63,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> RainMachineOptionsFlowHandler: """Define the config flow to handle options.""" - return RainMachineOptionsFlowHandler(config_entry) + return RainMachineOptionsFlowHandler() async def async_step_homekit( self, discovery_info: zeroconf.ZeroconfServiceInfo @@ -168,10 +168,6 @@ async def async_step_user( class RainMachineOptionsFlowHandler(OptionsFlow): """Handle a RainMachine options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 102aeae575e21..0b1ed7b4b15bc 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -54,10 +54,6 @@ class ReolinkOptionsFlowHandler(OptionsFlow): """Handle Reolink options.""" - def __init__(self, config_entry: ReolinkConfigEntry) -> None: - """Initialize ReolinkOptionsFlowHandler.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -112,7 +108,7 @@ def async_get_options_flow( config_entry: ReolinkConfigEntry, ) -> ReolinkOptionsFlowHandler: """Options callback for Reolink.""" - return ReolinkOptionsFlowHandler(config_entry) + return ReolinkOptionsFlowHandler() async def async_step_reauth( self, entry_data: Mapping[str, Any] diff --git a/homeassistant/components/rtsp_to_webrtc/config_flow.py b/homeassistant/components/rtsp_to_webrtc/config_flow.py index 8c2eac3a4b1e6..2250265975700 100644 --- a/homeassistant/components/rtsp_to_webrtc/config_flow.py +++ b/homeassistant/components/rtsp_to_webrtc/config_flow.py @@ -119,16 +119,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlow: """Create an options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """RTSPtoWeb Options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 4a46756cf2f6a..19db89dc03de8 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -81,7 +81,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> ScreenLogicOptionsFlowHandler: """Get the options flow for ScreenLogic.""" - return ScreenLogicOptionsFlowHandler(config_entry) + return ScreenLogicOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -192,10 +192,6 @@ async def async_step_gateway_entry(self, user_input=None) -> ConfigFlowResult: class ScreenLogicOptionsFlowHandler(OptionsFlow): """Handles the options for the ScreenLogic integration.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Init the screen logic options flow.""" - self.config_entry = config_entry - async def async_step_init(self, user_input=None) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: diff --git a/homeassistant/components/sentry/config_flow.py b/homeassistant/components/sentry/config_flow.py index 59cd1f3f0e9e3..2fead7c27cdd8 100644 --- a/homeassistant/components/sentry/config_flow.py +++ b/homeassistant/components/sentry/config_flow.py @@ -49,7 +49,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> SentryOptionsFlow: """Get the options flow for this handler.""" - return SentryOptionsFlow(config_entry) + return SentryOptionsFlow() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -78,10 +78,6 @@ async def async_step_user( class SentryOptionsFlow(OptionsFlow): """Handle Sentry options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize Sentry options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 717e0923fd6b6..1daa4710f3015 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -444,7 +444,7 @@ async def _async_get_info(self, host: str, port: int) -> dict[str, Any]: @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() @classmethod @callback @@ -460,10 +460,6 @@ def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: class OptionsFlowHandler(OptionsFlow): """Handle the option flow for shelly.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 6fdbd351a299e..68974fe118fee 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -67,7 +67,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> SimpliSafeOptionsFlowHandler: """Define the config flow to handle options.""" - return SimpliSafeOptionsFlowHandler(config_entry) + return SimpliSafeOptionsFlowHandler() async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -153,10 +153,6 @@ async def async_step_user( class SimpliSafeOptionsFlowHandler(OptionsFlow): """Handle a SimpliSafe options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index 1c1d02638d84b..c868c04f7d0c4 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -63,7 +63,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow(config_entry: ConfigEntry) -> SonarrOptionsFlowHandler: """Get the options flow for this handler.""" - return SonarrOptionsFlowHandler(config_entry) + return SonarrOptionsFlowHandler() async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -148,10 +148,6 @@ def _get_user_data_schema(self) -> dict[vol.Marker, type]: class SonarrOptionsFlowHandler(OptionsFlow): """Handle Sonarr client options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, int] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/subaru/config_flow.py b/homeassistant/components/subaru/config_flow.py index 3d96a89a14f73..0ef4ed29941f1 100644 --- a/homeassistant/components/subaru/config_flow.py +++ b/homeassistant/components/subaru/config_flow.py @@ -106,7 +106,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def validate_login_creds(self, data): """Validate the user input allows us to connect. @@ -218,10 +218,6 @@ async def async_step_pin( class OptionsFlowHandler(OptionsFlow): """Handle a option flow for Subaru.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 0468db5618adf..a0e451697709e 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -80,7 +80,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> SwitchbotOptionsFlowHandler: """Get the options flow for this handler.""" - return SwitchbotOptionsFlowHandler(config_entry) + return SwitchbotOptionsFlowHandler() def __init__(self) -> None: """Initialize the config flow.""" @@ -346,10 +346,6 @@ async def async_step_user( class SwitchbotOptionsFlowHandler(OptionsFlow): """Handle Switchbot options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 70ab13c5c095a..918a24035f888 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -118,7 +118,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> SynologyDSMOptionsFlowHandler: """Get the options flow for this handler.""" - return SynologyDSMOptionsFlowHandler(config_entry) + return SynologyDSMOptionsFlowHandler() def __init__(self) -> None: """Initialize the synology_dsm config flow.""" @@ -376,10 +376,6 @@ def _async_get_existing_entry(self, discovered_mac: str) -> ConfigEntry | None: class SynologyDSMOptionsFlowHandler(OptionsFlow): """Handle a option flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index 2ab2a86f200d1..c7bb76849010d 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -160,16 +160,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle an option flow for Tado.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/totalconnect/config_flow.py b/homeassistant/components/totalconnect/config_flow.py index c64dd5c612018..3f5d05fda13c6 100644 --- a/homeassistant/components/totalconnect/config_flow.py +++ b/homeassistant/components/totalconnect/config_flow.py @@ -193,16 +193,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> TotalConnectOptionsFlowHandler: """Get options flow.""" - return TotalConnectOptionsFlowHandler(config_entry) + return TotalConnectOptionsFlowHandler() class TotalConnectOptionsFlowHandler(OptionsFlow): """TotalConnect options flow handler.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, bool] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index a6e77dd23f781..30e9f5a146bde 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -63,7 +63,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> TransmissionOptionsFlowHandler: """Get the options flow for this handler.""" - return TransmissionOptionsFlowHandler(config_entry) + return TransmissionOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -138,10 +138,6 @@ async def async_step_reauth_confirm( class TransmissionOptionsFlowHandler(OptionsFlow): """Handle Transmission client options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize Transmission options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 6a9dc1210c0dc..31950f8f7e44e 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -225,7 +225,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlow: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() @callback def _async_create_entry(self, title: str, data: dict[str, Any]) -> ConfigFlowResult: @@ -376,10 +376,6 @@ async def async_step_user( class OptionsFlowHandler(OptionsFlow): """Handle options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py index 20860df555322..bb988726ba57b 100644 --- a/homeassistant/components/upcloud/config_flow.py +++ b/homeassistant/components/upcloud/config_flow.py @@ -95,16 +95,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> UpCloudOptionsFlow: """Get options flow.""" - return UpCloudOptionsFlow(config_entry) + return UpCloudOptionsFlow() class UpCloudOptionsFlow(OptionsFlow): """UpCloud options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/vera/config_flow.py b/homeassistant/components/vera/config_flow.py index 08e7640773b1d..f2b182cc27046 100644 --- a/homeassistant/components/vera/config_flow.py +++ b/homeassistant/components/vera/config_flow.py @@ -76,10 +76,6 @@ def options_data(user_input: dict[str, str]) -> dict[str, list[int]]: class OptionsFlowHandler(OptionsFlow): """Options for the component.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Init object.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, str] | None = None, @@ -104,7 +100,7 @@ class VeraFlowHandler(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: """Get the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index c8f1aaa21cb28..49f6a7095651e 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -108,10 +108,6 @@ def _host_is_same(host1: str, host2: str) -> bool: class VizioOptionsConfigFlow(OptionsFlow): """Handle Vizio options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize vizio options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -184,7 +180,7 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow(config_entry: ConfigEntry) -> VizioOptionsConfigFlow: """Get the options flow for this handler.""" - return VizioOptionsConfigFlow(config_entry) + return VizioOptionsConfigFlow() def __init__(self) -> None: """Initialize config flow.""" diff --git a/homeassistant/components/voip/config_flow.py b/homeassistant/components/voip/config_flow.py index 821c7f29a1ebb..63dcb8f86ee54 100644 --- a/homeassistant/components/voip/config_flow.py +++ b/homeassistant/components/voip/config_flow.py @@ -47,16 +47,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlow: """Create the options flow.""" - return VoipOptionsFlowHandler(config_entry) + return VoipOptionsFlowHandler() class VoipOptionsFlowHandler(OptionsFlow): """Handle VoIP options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index 1d75adc6c2907..6ab6a4b121c8a 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -113,10 +113,6 @@ def default_options(hass: HomeAssistant) -> dict[str, str | bool | list[str]]: class WazeOptionsFlow(OptionsFlow): """Handle an options flow for Waze Travel Time.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize waze options flow.""" - self.config_entry = config_entry - async def async_step_init(self, user_input=None) -> ConfigFlowResult: """Handle the initial step.""" if user_input is not None: @@ -148,7 +144,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> WazeOptionsFlow: """Get the options flow for this handler.""" - return WazeOptionsFlow(config_entry) + return WazeOptionsFlow() async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/wemo/config_flow.py b/homeassistant/components/wemo/config_flow.py index 10a9bf5604bcb..361c58953c540 100644 --- a/homeassistant/components/wemo/config_flow.py +++ b/homeassistant/components/wemo/config_flow.py @@ -32,16 +32,12 @@ def __init__(self) -> None: @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Get the options flow for this handler.""" - return WemoOptionsFlow(config_entry) + return WemoOptionsFlow() class WemoOptionsFlow(OptionsFlow): """Options flow for the WeMo component.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/wiffi/config_flow.py b/homeassistant/components/wiffi/config_flow.py index 3fcbef395e60a..308923597cd63 100644 --- a/homeassistant/components/wiffi/config_flow.py +++ b/homeassistant/components/wiffi/config_flow.py @@ -34,7 +34,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Create Wiffi server setup option flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -79,10 +79,6 @@ def _async_show_form(self, errors=None): class OptionsFlowHandler(OptionsFlow): """Wiffi server setup option flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, int] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/ws66i/config_flow.py b/homeassistant/components/ws66i/config_flow.py index 9f6f4ca59c201..120b7738d2ec1 100644 --- a/homeassistant/components/ws66i/config_flow.py +++ b/homeassistant/components/ws66i/config_flow.py @@ -130,7 +130,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> Ws66iOptionsFlowHandler: """Define the config flow to handle options.""" - return Ws66iOptionsFlowHandler(config_entry) + return Ws66iOptionsFlowHandler() @callback @@ -145,10 +145,6 @@ def _key_for_source( class Ws66iOptionsFlowHandler(OptionsFlow): """Handle a WS66i options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 7fc84c2623595..b068f4a1e61c8 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -63,10 +63,6 @@ class OptionsFlowHandler(OptionsFlow): """Options for the component.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Init object.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -122,7 +118,7 @@ def __init__(self) -> None: @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: """Get the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_reauth( self, entry_data: Mapping[str, Any] diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index 34e267fe904ce..2bc1fff222ffc 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -698,3 +698,16 @@ async def test_reauth(hass: HomeAssistant) -> None: assert mock_setup_entry.called assert result4["type"] is FlowResultType.ABORT assert result4["reason"] == "reauth_successful" + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test option flow.""" + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # This should be improved at a later stage to increase test coverage + hass.config_entries.options.async_abort(result["flow_id"]) diff --git a/tests/components/rachio/test_config_flow.py b/tests/components/rachio/test_config_flow.py index 1eaec1bc46e0f..586b31b092f0a 100644 --- a/tests/components/rachio/test_config_flow.py +++ b/tests/components/rachio/test_config_flow.py @@ -183,3 +183,16 @@ async def test_form_homekit_ignored(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test option flow.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "api_key"}) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # This should be improved at a later stage to increase test coverage + hass.config_entries.options.async_abort(result["flow_id"]) From 6f7eac5c6d5f310b62a765f52052e9d61fd87f5b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Nov 2024 12:26:31 -0500 Subject: [PATCH 0045/1070] Bump sensorpush-ble to 1.7.1 (#129657) --- homeassistant/components/sensorpush/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensorpush/manifest.json b/homeassistant/components/sensorpush/manifest.json index 5e7cf0d050903..7729a67d7a12f 100644 --- a/homeassistant/components/sensorpush/manifest.json +++ b/homeassistant/components/sensorpush/manifest.json @@ -17,5 +17,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensorpush", "iot_class": "local_push", - "requirements": ["sensorpush-ble==1.7.0"] + "requirements": ["sensorpush-ble==1.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 15543947bc6fd..b09c4c84ff2cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2635,7 +2635,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.7.0 +sensorpush-ble==1.7.1 # homeassistant.components.sensoterra sensoterra==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf50a5947c87f..3fa0919eeed01 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2102,7 +2102,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.7.0 +sensorpush-ble==1.7.1 # homeassistant.components.sensoterra sensoterra==2.0.1 From bf4922a7ef134c8de2199de3cf2342855bc57a1e Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Sat, 2 Nov 2024 18:42:56 +0100 Subject: [PATCH 0046/1070] Bump autarco lib to v3.1.0 (#129684) Bump autarco to v3.1.0 --- homeassistant/components/autarco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/autarco/manifest.json b/homeassistant/components/autarco/manifest.json index 0058ab9af7781..0567aeba72241 100644 --- a/homeassistant/components/autarco/manifest.json +++ b/homeassistant/components/autarco/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/autarco", "iot_class": "cloud_polling", - "requirements": ["autarco==3.0.0"] + "requirements": ["autarco==3.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b09c4c84ff2cc..97b5b864fbae6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -521,7 +521,7 @@ auroranoaa==0.0.5 aurorapy==0.2.7 # homeassistant.components.autarco -autarco==3.0.0 +autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble automower-ble==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3fa0919eeed01..18da37f18f4cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -476,7 +476,7 @@ auroranoaa==0.0.5 aurorapy==0.2.7 # homeassistant.components.autarco -autarco==3.0.0 +autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble automower-ble==0.2.0 From f7103da81867573b146395ab71f6e0d6cc6fe792 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 2 Nov 2024 19:03:32 +0100 Subject: [PATCH 0047/1070] Refactor av.open calls to support type annotations (#129688) --- homeassistant/components/stream/recorder.py | 13 ++- homeassistant/components/stream/worker.py | 107 ++++++++++---------- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index 6dfc09891b744..aa5e08a1594fd 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -105,17 +105,16 @@ def write_segment(segment: Segment) -> None: # Create output on first segment if not output: + container_options: dict[str, str] = { + "video_track_timescale": str(int(1 / source_v.time_base)), + "movflags": "frag_keyframe+empty_moov", + "min_frag_duration": str(self.stream_settings.min_segment_duration), + } output = av.open( self.video_path + ".tmp", "w", format=RECORDER_CONTAINER_FORMAT, - container_options={ - "video_track_timescale": str(int(1 / source_v.time_base)), - "movflags": "frag_keyframe+empty_moov", - "min_frag_duration": str( - self.stream_settings.min_segment_duration - ), - }, + container_options=container_options, ) # Add output streams if necessary diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 0d72a9b081871..1661a5b673fca 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -164,63 +164,64 @@ def make_new_av( av.audio.stream.AudioStream | None, ]: """Make a new av OutputContainer and add output streams.""" + container_options: dict[str, str] = { + # Removed skip_sidx - see: + # https://github.com/home-assistant/core/pull/39970 + # "cmaf" flag replaces several of the movflags used, + # but too recent to use for now + "movflags": "frag_custom+empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer+delay_moov", + # Sometimes the first segment begins with negative timestamps, + # and this setting just + # adjusts the timestamps in the output from that segment to start + # from 0. Helps from having to make some adjustments + # in test_durations + "avoid_negative_ts": "make_non_negative", + "fragment_index": str(sequence + 1), + "video_track_timescale": str(int(1 / input_vstream.time_base)), + # Only do extra fragmenting if we are using ll_hls + # Let ffmpeg do the work using frag_duration + # Fragment durations may exceed the 15% allowed variance but it seems ok + **( + { + "movflags": "empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer+delay_moov", + # Create a fragment every TARGET_PART_DURATION. The data from + # each fragment is stored in a "Part" that can be combined with + # the data from all the other "Part"s, plus an init section, + # to reconstitute the data in a "Segment". + # + # The LL-HLS spec allows for a fragment's duration to be within + # the range [0.85x,1.0x] of the part target duration. We use the + # frag_duration option to tell ffmpeg to try to cut the + # fragments when they reach frag_duration. However, + # the resulting fragments can have variability in their + # durations and can end up being too short or too long. With a + # video track with no audio, the discrete nature of frames means + # that the frame at the end of a fragment will sometimes extend + # slightly beyond the desired frag_duration. + # + # If there are two tracks, as in the case of a video feed with + # audio, there is an added wrinkle as the fragment cut seems to + # be done on the first track that crosses the desired threshold, + # and cutting on the audio track may also result in a shorter + # video fragment than desired. + # + # Given this, our approach is to give ffmpeg a frag_duration + # somewhere in the middle of the range, hoping that the parts + # stay pretty well bounded, and we adjust the part durations + # a bit in the hls metadata so that everything "looks" ok. + "frag_duration": str( + int(self._stream_settings.part_target_duration * 9e5) + ), + } + if self._stream_settings.ll_hls + else {} + ), + } container = av.open( memory_file, mode="w", format=SEGMENT_CONTAINER_FORMAT, - container_options={ - # Removed skip_sidx - see: - # https://github.com/home-assistant/core/pull/39970 - # "cmaf" flag replaces several of the movflags used, - # but too recent to use for now - "movflags": "frag_custom+empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer+delay_moov", - # Sometimes the first segment begins with negative timestamps, - # and this setting just - # adjusts the timestamps in the output from that segment to start - # from 0. Helps from having to make some adjustments - # in test_durations - "avoid_negative_ts": "make_non_negative", - "fragment_index": str(sequence + 1), - "video_track_timescale": str(int(1 / input_vstream.time_base)), - # Only do extra fragmenting if we are using ll_hls - # Let ffmpeg do the work using frag_duration - # Fragment durations may exceed the 15% allowed variance but it seems ok - **( - { - "movflags": "empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer+delay_moov", - # Create a fragment every TARGET_PART_DURATION. The data from - # each fragment is stored in a "Part" that can be combined with - # the data from all the other "Part"s, plus an init section, - # to reconstitute the data in a "Segment". - # - # The LL-HLS spec allows for a fragment's duration to be within - # the range [0.85x,1.0x] of the part target duration. We use the - # frag_duration option to tell ffmpeg to try to cut the - # fragments when they reach frag_duration. However, - # the resulting fragments can have variability in their - # durations and can end up being too short or too long. With a - # video track with no audio, the discrete nature of frames means - # that the frame at the end of a fragment will sometimes extend - # slightly beyond the desired frag_duration. - # - # If there are two tracks, as in the case of a video feed with - # audio, there is an added wrinkle as the fragment cut seems to - # be done on the first track that crosses the desired threshold, - # and cutting on the audio track may also result in a shorter - # video fragment than desired. - # - # Given this, our approach is to give ffmpeg a frag_duration - # somewhere in the middle of the range, hoping that the parts - # stay pretty well bounded, and we adjust the part durations - # a bit in the hls metadata so that everything "looks" ok. - "frag_duration": str( - int(self._stream_settings.part_target_duration * 9e5) - ), - } - if self._stream_settings.ll_hls - else {} - ), - }, + container_options=container_options, ) output_vstream = container.add_stream(template=input_vstream) # Check if audio is requested From 5bd63bb56b0a27ac88a3ef29fc30ace413cc8a1b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 2 Nov 2024 19:14:59 +0100 Subject: [PATCH 0048/1070] Replace AVError with FFmpegError (#129689) --- homeassistant/components/stream/worker.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 1661a5b673fca..a44598b5971f2 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -47,7 +47,7 @@ class StreamWorkerError(Exception): """An exception thrown while processing a stream.""" -def redact_av_error_string(err: av.AVError) -> str: +def redact_av_error_string(err: av.FFmpegError) -> str: """Return an error string with credentials redacted from the url.""" parts = [str(err.type), err.strerror] if err.filename is not None: @@ -525,7 +525,7 @@ def stream_worker( del pyav_options["stimeout"] try: container = av.open(source, options=pyav_options, timeout=SOURCE_TIMEOUT) - except av.AVError as err: + except av.FFmpegError as err: raise StreamWorkerError( f"Error opening stream ({redact_av_error_string(err)})" ) from err @@ -599,7 +599,7 @@ def is_video(packet: av.Packet) -> Any: except StopIteration as ex: container.close() raise StreamEndedError("Stream ended; no additional packets") from ex - except av.AVError as ex: + except av.FFmpegError as ex: container.close() raise StreamWorkerError( f"Error demuxing stream while finding first packet ({redact_av_error_string(ex)})" @@ -626,7 +626,7 @@ def is_video(packet: av.Packet) -> Any: raise except StopIteration as ex: raise StreamEndedError("Stream ended; no additional packets") from ex - except av.AVError as ex: + except av.FFmpegError as ex: raise StreamWorkerError( f"Error demuxing stream ({redact_av_error_string(ex)})" ) from ex From 4f20977a8e952905618c690ccbb257d1eece24bb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 2 Nov 2024 19:15:50 +0100 Subject: [PATCH 0049/1070] Update mypy-dev to 1.14.0a2 (#129625) --- homeassistant/components/energy/data.py | 2 +- homeassistant/components/image_processing/__init__.py | 2 +- mypy.ini | 1 + requirements_test.txt | 2 +- script/hassfest/mypy_config.py | 1 + 5 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index 9c5a9fbacd164..ff86177cf4120 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -331,7 +331,7 @@ async def async_update(self, update: EnergyPreferencesUpdate) -> None: "device_consumption", ): if key in update: - data[key] = update[key] # type: ignore[literal-required] + data[key] = update[key] self.data = data self._store.async_delay_save(lambda: data, 60) diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 2c1d0f9304c9b..0ac8d39813bfd 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -223,7 +223,7 @@ def state(self) -> str | int | None: confidence = f_co for attr in (ATTR_NAME, ATTR_MOTION): if attr in face: - state = face[attr] # type: ignore[literal-required] + state = face[attr] break return state diff --git a/mypy.ini b/mypy.ini index 1b98877759425..c851e586246ee 100644 --- a/mypy.ini +++ b/mypy.ini @@ -11,6 +11,7 @@ follow_imports = normal local_partial_types = true strict_equality = true no_implicit_optional = true +report_deprecated_as_error = true warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true diff --git a/requirements_test.txt b/requirements_test.txt index c879f0c662167..241fff89ac3d4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.1 freezegun==1.5.1 license-expression==30.4.0 mock-open==1.4.0 -mypy-dev==1.13.0a1 +mypy-dev==1.14.0a2 pre-commit==4.0.0 pydantic==1.10.18 pylint==3.3.1 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index de42c964ddf72..25fe875e43788 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -43,6 +43,7 @@ "local_partial_types": "true", "strict_equality": "true", "no_implicit_optional": "true", + "report_deprecated_as_error": "true", "warn_incomplete_stub": "true", "warn_redundant_casts": "true", "warn_unused_configs": "true", From 0eea3176d6b6bf871acc7a340f748af88615637e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 2 Nov 2024 19:29:09 +0100 Subject: [PATCH 0050/1070] Minor stream typing improvements (#129691) --- homeassistant/components/stream/const.py | 8 ++++++-- homeassistant/components/stream/core.py | 4 ++-- homeassistant/components/stream/recorder.py | 5 ++++- homeassistant/components/stream/worker.py | 16 ++++++++++------ 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index a2fa065e0192b..66455ffad1a9e 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -1,5 +1,9 @@ """Constants for Stream component.""" +from __future__ import annotations + +from typing import Final + DOMAIN = "stream" ATTR_ENDPOINTS = "endpoints" @@ -11,8 +15,8 @@ OUTPUT_FORMATS = [HLS_PROVIDER] -SEGMENT_CONTAINER_FORMAT = "mp4" # format for segments -RECORDER_CONTAINER_FORMAT = "mp4" # format for recorder output +SEGMENT_CONTAINER_FORMAT: Final = "mp4" # format for segments +RECORDER_CONTAINER_FORMAT: Final = "mp4" # format for recorder output AUDIO_CODECS = {"aac", "mp3"} FORMAT_CONTENT_TYPE = {HLS_PROVIDER: "application/vnd.apple.mpegurl"} diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 68c08a4f07207..a2ac242156e15 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -438,11 +438,11 @@ def __init__( """Initialize.""" # Keep import here so that we can import stream integration - # without installingreqs + # without installing reqs # pylint: disable-next=import-outside-toplevel from homeassistant.components.camera.img_util import TurboJPEGSingleton - self._packet: Packet = None + self._packet: Packet | None = None self._event: asyncio.Event = asyncio.Event() self._hass = hass self._image: bytes | None = None diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index aa5e08a1594fd..43b3ae163a773 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING import av +import av.container from homeassistant.core import HomeAssistant, callback @@ -168,7 +169,9 @@ def write_transform_matrix_and_rename(video_path: str) -> None: os.remove(video_path + ".tmp") def finish_writing( - segments: deque[Segment], output: av.OutputContainer, video_path: str + segments: deque[Segment], + output: av.container.OutputContainer | None, + video_path: str, ) -> None: """Finish writing output.""" # Should only have 0 or 1 segments, but loop through just in case diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index a44598b5971f2..7d6d11591c733 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -13,6 +13,10 @@ from typing import Any, Self, cast import av +import av.audio +import av.container +import av.stream +import av.video from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -127,7 +131,7 @@ def __init__( self, hass: HomeAssistant, video_stream: av.video.VideoStream, - audio_stream: av.audio.stream.AudioStream | None, + audio_stream: av.audio.AudioStream | None, audio_bsf: av.BitStreamFilter | None, stream_state: StreamState, stream_settings: StreamSettings, @@ -138,11 +142,11 @@ def __init__( self._memory_file: BytesIO = cast(BytesIO, None) self._av_output: av.container.OutputContainer = None self._input_video_stream: av.video.VideoStream = video_stream - self._input_audio_stream: av.audio.stream.AudioStream | None = audio_stream + self._input_audio_stream: av.audio.AudioStream | None = audio_stream self._audio_bsf = audio_bsf self._audio_bsf_context: av.BitStreamFilterContext = None self._output_video_stream: av.video.VideoStream = None - self._output_audio_stream: av.audio.stream.AudioStream | None = None + self._output_audio_stream: av.audio.AudioStream | None = None self._segment: Segment | None = None # the following 3 member variables are used for Part formation self._memory_file_pos: int = cast(int, None) @@ -157,11 +161,11 @@ def make_new_av( memory_file: BytesIO, sequence: int, input_vstream: av.video.VideoStream, - input_astream: av.audio.stream.AudioStream | None, + input_astream: av.audio.AudioStream | None, ) -> tuple[ av.container.OutputContainer, av.video.VideoStream, - av.audio.stream.AudioStream | None, + av.audio.AudioStream | None, ]: """Make a new av OutputContainer and add output streams.""" container_options: dict[str, str] = { @@ -396,7 +400,7 @@ def close(self) -> None: self._memory_file.close() -class PeekIterator(Iterator): +class PeekIterator(Iterator[av.Packet]): """An Iterator that may allow multiple passes. This may be consumed like a normal Iterator, however also supports a From e18ffc53f21200bec5f580a619e1503d9a5a4f3d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 2 Nov 2024 20:39:17 +0100 Subject: [PATCH 0051/1070] Revert "Create a script service schema based on fields" (#129591) --- homeassistant/components/script/__init__.py | 35 +------- tests/components/script/test_init.py | 97 --------------------- 2 files changed, 1 insertion(+), 131 deletions(-) diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 1af553165bd27..c0d79c446bb8f 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -18,13 +18,11 @@ ATTR_MODE, ATTR_NAME, CONF_ALIAS, - CONF_DEFAULT, CONF_DESCRIPTION, CONF_ICON, CONF_MODE, CONF_NAME, CONF_PATH, - CONF_SELECTOR, CONF_SEQUENCE, CONF_VARIABLES, SERVICE_RELOAD, @@ -60,7 +58,6 @@ ScriptRunResult, script_stack_cv, ) -from homeassistant.helpers.selector import selector from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.trace import trace_get, trace_path from homeassistant.helpers.typing import ConfigType @@ -74,7 +71,6 @@ ATTR_LAST_TRIGGERED, ATTR_VARIABLES, CONF_FIELDS, - CONF_REQUIRED, CONF_TRACE, DOMAIN, ENTITY_ID_FORMAT, @@ -734,40 +730,11 @@ async def async_added_to_hass(self) -> None: unique_id = self.unique_id hass = self.hass - - service_schema = {} - for field_name, field_info in self.fields.items(): - key_cls = vol.Required if field_info[CONF_REQUIRED] else vol.Optional - key_kwargs = {} - if CONF_DEFAULT in field_info: - key_kwargs["default"] = field_info[CONF_DEFAULT] - - if CONF_SELECTOR in field_info: - validator: Any = selector(field_info[CONF_SELECTOR]) - - # Default values need to match the validator. - # When they don't match, we will not enforce validation - if CONF_DEFAULT in field_info: - try: - validator(field_info[CONF_DEFAULT]) - except vol.Invalid: - logging.getLogger(f"{__name__}.{self._attr_unique_id}").warning( - "Field %s has invalid default value %s", - field_name, - field_info[CONF_DEFAULT], - ) - validator = cv.match_all - - else: - validator = cv.match_all - - service_schema[key_cls(field_name, **key_kwargs)] = validator - hass.services.async_register( DOMAIN, unique_id, self._service_handler, - schema=vol.Schema(service_schema, extra=vol.ALLOW_EXTRA), + schema=SCRIPT_SERVICE_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 96ac73438ea56..a5eda3757a978 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -6,7 +6,6 @@ from unittest.mock import ANY, Mock, patch import pytest -import voluptuous as vol from homeassistant.components import script from homeassistant.components.script import DOMAIN, EVENT_SCRIPT_STARTED, ScriptEntity @@ -49,7 +48,6 @@ from tests.common import ( MockConfigEntry, MockUser, - async_capture_events, async_fire_time_changed, async_mock_service, mock_restore_cache, @@ -559,101 +557,6 @@ async def test_reload_unchanged_script( assert len(calls) == 2 -async def test_service_schema( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test that service schema are defined correctly.""" - events = async_capture_events(hass, "test_event") - - assert await async_setup_component( - hass, - "script", - { - "script": { - "test": { - "fields": { - "param_with_default": { - "default": "default_value", - }, - "required_param": { - "required": True, - }, - "selector_param": { - "selector": { - "select": { - "options": [ - "one", - "two", - ] - } - } - }, - "invalid_default": { - "default": "invalid-value", - "selector": {"number": {"min": 0, "max": 2}}, - }, - }, - "sequence": [ - { - "event": "test_event", - "event_data": { - "param_with_default": "{{ param_with_default }}", - "required_param": "{{ required_param }}", - "selector_param": "{{ selector_param | default('not_set') }}", - "invalid_default": "{{ invalid_default }}", - }, - } - ], - } - } - }, - ) - - assert ( - "Field invalid_default has invalid default value invalid-value" in caplog.text - ) - - await hass.services.async_call( - DOMAIN, - "test", - {"required_param": "required_value"}, - blocking=True, - ) - assert len(events) == 1 - assert events[0].data["param_with_default"] == "default_value" - assert events[0].data["required_param"] == "required_value" - assert events[0].data["selector_param"] == "not_set" - assert events[0].data["invalid_default"] == "invalid-value" - - with pytest.raises(vol.Invalid): - await hass.services.async_call( - DOMAIN, - "test", - { - "required_param": "required_value", - "selector_param": "invalid_value", - }, - blocking=True, - ) - - await hass.services.async_call( - DOMAIN, - "test", - { - "param_with_default": "service_set_value", - "required_param": "required_value", - "selector_param": "one", - "invalid_default": "another-value", - }, - blocking=True, - ) - assert len(events) == 2 - assert events[1].data["param_with_default"] == "service_set_value" - assert events[1].data["required_param"] == "required_value" - assert events[1].data["selector_param"] == "one" - assert events[1].data["invalid_default"] == "another-value" - - async def test_service_descriptions(hass: HomeAssistant) -> None: """Test that service descriptions are loaded and reloaded correctly.""" # Test 1: has "description" but no "fields" From 5cf13d92739c8554d386874617e056258fb043c6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 2 Nov 2024 22:22:31 +0100 Subject: [PATCH 0052/1070] Additional stream typing improvements (#129695) --- homeassistant/components/stream/core.py | 13 +++++---- homeassistant/components/stream/recorder.py | 2 +- homeassistant/components/stream/worker.py | 29 +++++++++++---------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index a2ac242156e15..bce16ff4c8713 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -9,7 +9,7 @@ import datetime from enum import IntEnum import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from aiohttp import web import numpy as np @@ -27,7 +27,8 @@ ) if TYPE_CHECKING: - from av import CodecContext, Packet + from av import Packet + from av.video.codeccontext import VideoCodecContext from homeassistant.components.camera import DynamicStreamSettings @@ -448,7 +449,7 @@ def __init__( self._image: bytes | None = None self._turbojpeg = TurboJPEGSingleton.instance() self._lock = asyncio.Lock() - self._codec_context: CodecContext | None = None + self._codec_context: VideoCodecContext | None = None self._stream_settings = stream_settings self._dynamic_stream_settings = dynamic_stream_settings @@ -460,7 +461,7 @@ def stash_keyframe_packet(self, packet: Packet) -> None: self._packet = packet self._hass.loop.call_soon_threadsafe(self._event.set) - def create_codec_context(self, codec_context: CodecContext) -> None: + def create_codec_context(self, codec_context: VideoCodecContext) -> None: """Create a codec context to be used for decoding the keyframes. This is run by the worker thread and will only be called once per worker. @@ -474,7 +475,9 @@ def create_codec_context(self, codec_context: CodecContext) -> None: # pylint: disable-next=import-outside-toplevel from av import CodecContext - self._codec_context = CodecContext.create(codec_context.name, "r") + self._codec_context = cast( + "VideoCodecContext", CodecContext.create(codec_context.name, "r") + ) self._codec_context.extradata = codec_context.extradata self._codec_context.skip_frame = "NONKEY" self._codec_context.thread_type = "NONE" diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index 43b3ae163a773..d28982ea30de6 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -122,7 +122,7 @@ def write_segment(segment: Segment) -> None: if not output_v: output_v = output.add_stream(template=source_v) context = output_v.codec_context - context.flags |= "GLOBAL_HEADER" + context.global_header = True if source_a and not output_a: output_a = output.add_stream(template=source_a) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 7d6d11591c733..42bfa13f13ec7 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -127,6 +127,16 @@ def diagnostics(self) -> Diagnostics: class StreamMuxer: """StreamMuxer re-packages video/audio packets for output.""" + _segment_start_dts: int + _memory_file: BytesIO + _av_output: av.container.OutputContainer + _output_video_stream: av.video.VideoStream + _output_audio_stream: av.audio.AudioStream | None + _segment: Segment | None + # the following 2 member variables are used for Part formation + _memory_file_pos: int + _part_start_dts: int + def __init__( self, hass: HomeAssistant, @@ -138,19 +148,10 @@ def __init__( ) -> None: """Initialize StreamMuxer.""" self._hass = hass - self._segment_start_dts: int = cast(int, None) - self._memory_file: BytesIO = cast(BytesIO, None) - self._av_output: av.container.OutputContainer = None - self._input_video_stream: av.video.VideoStream = video_stream - self._input_audio_stream: av.audio.AudioStream | None = audio_stream + self._input_video_stream = video_stream + self._input_audio_stream = audio_stream self._audio_bsf = audio_bsf - self._audio_bsf_context: av.BitStreamFilterContext = None - self._output_video_stream: av.video.VideoStream = None - self._output_audio_stream: av.audio.AudioStream | None = None - self._segment: Segment | None = None - # the following 3 member variables are used for Part formation - self._memory_file_pos: int = cast(int, None) - self._part_start_dts: int = cast(int, None) + self._audio_bsf_context: av.BitStreamFilterContext | None = None self._part_has_keyframe = False self._stream_settings = stream_settings self._stream_state = stream_state @@ -256,7 +257,7 @@ def reset(self, video_dts: int) -> None: input_astream=self._input_audio_stream, ) if self._output_video_stream.name == "hevc": - self._output_video_stream.codec_tag = "hvc1" + self._output_video_stream.codec_context.codec_tag = "hvc1" def mux_packet(self, packet: av.Packet) -> None: """Mux a packet to the appropriate output stream.""" @@ -562,7 +563,7 @@ def stream_worker( dts_validator = TimestampValidator( int(1 / video_stream.time_base), - 1 / audio_stream.time_base if audio_stream else 1, + int(1 / audio_stream.time_base) if audio_stream else 1, ) container_packets = PeekIterator( filter(dts_validator.is_valid, container.demux((video_stream, audio_stream))) From dfbb7630319bbb9b5cdd7385a8dd5131d0c14ec4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Nov 2024 22:15:56 -0500 Subject: [PATCH 0053/1070] Disable cleanup_closed on python 3.12.7+ and 3.13.1+ (#129645) --- homeassistant/helpers/aiohttp_client.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 2f4c1980468b8..f01ae325875dd 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -44,11 +44,13 @@ f"aiohttp/{aiohttp.__version__} Python/{sys.version_info[0]}.{sys.version_info[1]}" ) -ENABLE_CLEANUP_CLOSED = not (3, 11, 1) <= sys.version_info < (3, 11, 4) -# Enabling cleanup closed on python 3.11.1+ leaks memory relatively quickly -# see https://github.com/aio-libs/aiohttp/issues/7252 -# aiohttp interacts poorly with https://github.com/python/cpython/pull/98540 -# The issue was fixed in 3.11.4 via https://github.com/python/cpython/pull/104485 +ENABLE_CLEANUP_CLOSED = (3, 13, 0) <= sys.version_info < ( + 3, + 13, + 1, +) or sys.version_info < (3, 12, 7) +# Cleanup closed is no longer needed after https://github.com/python/cpython/pull/118960 +# which first appeared in Python 3.12.7 and 3.13.1 WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session" From ed3376352dfb3d65a69210b90f383969b370cd73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Nov 2024 22:43:21 -0500 Subject: [PATCH 0054/1070] Bump DoorBirdPy to 3.0.8 (#129709) --- homeassistant/components/doorbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json index 85a705d1dabac..8480a4967629f 100644 --- a/homeassistant/components/doorbird/manifest.json +++ b/homeassistant/components/doorbird/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/doorbird", "iot_class": "local_push", "loggers": ["doorbirdpy"], - "requirements": ["DoorBirdPy==3.0.7"], + "requirements": ["DoorBirdPy==3.0.8"], "zeroconf": [ { "type": "_axis-video._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 97b5b864fbae6..4ae97d028a4f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -13,7 +13,7 @@ AIOSomecomfort==0.0.25 Adax-local==0.1.5 # homeassistant.components.doorbird -DoorBirdPy==3.0.7 +DoorBirdPy==3.0.8 # homeassistant.components.homekit HAP-python==4.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18da37f18f4cc..893a6dbb5be1d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -13,7 +13,7 @@ AIOSomecomfort==0.0.25 Adax-local==0.1.5 # homeassistant.components.doorbird -DoorBirdPy==3.0.7 +DoorBirdPy==3.0.8 # homeassistant.components.homekit HAP-python==4.9.1 From eddab96a69aecb79711b73a9ed2d35aca70b92f5 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sun, 3 Nov 2024 09:44:35 +0100 Subject: [PATCH 0055/1070] Add DHCP discovery to lamarzocco (#129675) * Add DHCP discovery to lamarzocco * ensure serial is upper * shorten pattern * parametrize across models --- .../components/lamarzocco/config_flow.py | 31 +++++++++++ .../components/lamarzocco/manifest.json | 11 ++++ homeassistant/generated/dhcp.py | 12 +++++ tests/components/lamarzocco/conftest.py | 6 +-- .../components/lamarzocco/test_config_flow.py | 53 ++++++++++++++++++- 5 files changed, 109 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 438bf7fe6b98c..43221eed58433 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -14,6 +14,7 @@ BluetoothServiceInfo, async_discovered_service_info, ) +from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -103,6 +104,15 @@ async def async_step_user( errors["base"] = "machine_not_found" else: self._config = data + # if DHCP discovery was used, auto fill machine selection + if CONF_HOST in self._discovered: + return await self.async_step_machine_selection( + user_input={ + CONF_HOST: self._discovered[CONF_HOST], + CONF_MACHINE: self._discovered[CONF_MACHINE], + } + ) + # if Bluetooth discovery was used, only select host return self.async_show_form( step_id="machine_selection", data_schema=vol.Schema( @@ -258,6 +268,27 @@ async def async_step_bluetooth( return await self.async_step_user() + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle discovery via dhcp.""" + + serial = discovery_info.hostname.upper() + + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + + _LOGGER.debug( + "Discovered La Marzocco machine %s through DHCP at address %s", + discovery_info.hostname, + discovery_info.ip, + ) + + self._discovered[CONF_MACHINE] = serial + self._discovered[CONF_HOST] = discovery_info.ip + + return await self.async_step_user() + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index a1da8982cd856..bfe0d34a9e412 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -18,6 +18,17 @@ "codeowners": ["@zweckj"], "config_flow": true, "dependencies": ["bluetooth_adapters"], + "dhcp": [ + { + "hostname": "gs[0-9][0-9][0-9][0-9][0-9][0-9]" + }, + { + "hostname": "lm[0-9][0-9][0-9][0-9][0-9][0-9]" + }, + { + "hostname": "mr[0-9][0-9][0-9][0-9][0-9][0-9]" + } + ], "documentation": "https://www.home-assistant.io/integrations/lamarzocco", "integration_type": "device", "iot_class": "cloud_polling", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 7dd13473d3173..cd20b88b285df 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -276,6 +276,18 @@ "hostname": "polisy*", "macaddress": "000DB9*", }, + { + "domain": "lamarzocco", + "hostname": "gs[0-9][0-9][0-9][0-9][0-9][0-9]", + }, + { + "domain": "lamarzocco", + "hostname": "lm[0-9][0-9][0-9][0-9][0-9][0-9]", + }, + { + "domain": "lamarzocco", + "hostname": "mr[0-9][0-9][0-9][0-9][0-9][0-9]", + }, { "domain": "lametric", "registered_devices": True, diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index 2520433e86ab6..df71d14baeb86 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -75,11 +75,11 @@ def device_fixture() -> MachineModel: @pytest.fixture -def mock_device_info() -> LaMarzoccoDeviceInfo: +def mock_device_info(device_fixture: MachineModel) -> LaMarzoccoDeviceInfo: """Return a mocked La Marzocco device info.""" return LaMarzoccoDeviceInfo( - model=MachineModel.GS3_AV, - serial_number="GS01234", + model=device_fixture, + serial_number=SERIAL_DICT[device_fixture], name="GS3", communication_key="token", ) diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 89e5c96872482..3d23908abf720 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -2,13 +2,20 @@ from unittest.mock import MagicMock, patch +from lmcloud.const import MachineModel from lmcloud.exceptions import AuthFail, RequestNotSuccessful from lmcloud.models import LaMarzoccoDeviceInfo import pytest +from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import CONF_USE_BLUETOOTH, DOMAIN -from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER, ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_BLUETOOTH, + SOURCE_DHCP, + SOURCE_USER, + ConfigEntryState, +) from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -435,6 +442,50 @@ async def test_bluetooth_discovery_errors( } +@pytest.mark.parametrize( + "device_fixture", + [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI, MachineModel.GS3_AV], +) +async def test_dhcp_discovery( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_cloud_client: MagicMock, + mock_device_info: LaMarzoccoDeviceInfo, +) -> None: + """Test dhcp discovery.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.1.42", + hostname=mock_lamarzocco.serial_number, + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == { + **USER_INPUT, + CONF_HOST: "192.168.1.42", + CONF_MACHINE: mock_lamarzocco.serial_number, + CONF_MODEL: mock_device_info.model, + CONF_NAME: mock_device_info.name, + CONF_TOKEN: mock_device_info.communication_key, + } + + async def test_options_flow( hass: HomeAssistant, mock_lamarzocco: MagicMock, From fbe27749a046e5c60bf92bf7ecd38675c90c9ed3 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sun, 3 Nov 2024 13:35:42 +0100 Subject: [PATCH 0056/1070] Correct length of the serials in lamarzocco tests (#129725) --- tests/components/lamarzocco/__init__.py | 8 +- tests/components/lamarzocco/conftest.py | 2 +- .../snapshots/test_binary_sensor.ambr | 36 ++-- .../lamarzocco/snapshots/test_button.ambr | 8 +- .../lamarzocco/snapshots/test_calendar.ambr | 46 ++--- .../lamarzocco/snapshots/test_number.ambr | 192 +++++++++--------- .../lamarzocco/snapshots/test_select.ambr | 40 ++-- .../lamarzocco/snapshots/test_sensor.ambr | 60 +++--- .../lamarzocco/snapshots/test_switch.ambr | 46 ++--- .../lamarzocco/snapshots/test_update.ambr | 16 +- 10 files changed, 227 insertions(+), 227 deletions(-) diff --git a/tests/components/lamarzocco/__init__.py b/tests/components/lamarzocco/__init__.py index 4d274d10baaa8..f88fa474f8b76 100644 --- a/tests/components/lamarzocco/__init__.py +++ b/tests/components/lamarzocco/__init__.py @@ -19,10 +19,10 @@ USER_INPUT = PASSWORD_SELECTION | {CONF_USERNAME: "username"} SERIAL_DICT = { - MachineModel.GS3_AV: "GS01234", - MachineModel.GS3_MP: "GS01234", - MachineModel.LINEA_MICRA: "MR01234", - MachineModel.LINEA_MINI: "LM01234", + MachineModel.GS3_AV: "GS012345", + MachineModel.GS3_MP: "GS012345", + MachineModel.LINEA_MICRA: "MR012345", + MachineModel.LINEA_MINI: "LM012345", } WAKE_UP_SLEEP_ENTRY_IDS = ["Os2OswX", "aXFz5bJ"] diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index df71d14baeb86..d8047dfbabfb5 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -157,5 +157,5 @@ def mock_bluetooth(enable_bluetooth: None) -> None: def mock_ble_device() -> BLEDevice: """Return a mock BLE device.""" return BLEDevice( - "00:00:00:00:00:00", "GS_GS01234", details={"path": "path"}, rssi=50 + "00:00:00:00:00:00", "GS_GS012345", details={"path": "path"}, rssi=50 ) diff --git a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr index df47ac002e6fd..cda285a71069f 100644 --- a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr @@ -1,19 +1,19 @@ # serializer version: 1 -# name: test_binary_sensors[GS01234_backflush_active-binary_sensor] +# name: test_binary_sensors[GS012345_backflush_active-binary_sensor] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'running', - 'friendly_name': 'GS01234 Backflush active', + 'friendly_name': 'GS012345 Backflush active', }), 'context': , - 'entity_id': 'binary_sensor.gs01234_backflush_active', + 'entity_id': 'binary_sensor.gs012345_backflush_active', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensors[GS01234_backflush_active-entry] +# name: test_binary_sensors[GS012345_backflush_active-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -25,7 +25,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.gs01234_backflush_active', + 'entity_id': 'binary_sensor.gs012345_backflush_active', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -42,25 +42,25 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'backflush_enabled', - 'unique_id': 'GS01234_backflush_enabled', + 'unique_id': 'GS012345_backflush_enabled', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[GS01234_brewing_active-binary_sensor] +# name: test_binary_sensors[GS012345_brewing_active-binary_sensor] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'running', - 'friendly_name': 'GS01234 Brewing active', + 'friendly_name': 'GS012345 Brewing active', }), 'context': , - 'entity_id': 'binary_sensor.gs01234_brewing_active', + 'entity_id': 'binary_sensor.gs012345_brewing_active', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensors[GS01234_brewing_active-entry] +# name: test_binary_sensors[GS012345_brewing_active-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -72,7 +72,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.gs01234_brewing_active', + 'entity_id': 'binary_sensor.gs012345_brewing_active', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -89,25 +89,25 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'brew_active', - 'unique_id': 'GS01234_brew_active', + 'unique_id': 'GS012345_brew_active', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[GS01234_water_tank_empty-binary_sensor] +# name: test_binary_sensors[GS012345_water_tank_empty-binary_sensor] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'GS01234 Water tank empty', + 'friendly_name': 'GS012345 Water tank empty', }), 'context': , - 'entity_id': 'binary_sensor.gs01234_water_tank_empty', + 'entity_id': 'binary_sensor.gs012345_water_tank_empty', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensors[GS01234_water_tank_empty-entry] +# name: test_binary_sensors[GS012345_water_tank_empty-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -119,7 +119,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.gs01234_water_tank_empty', + 'entity_id': 'binary_sensor.gs012345_water_tank_empty', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -136,7 +136,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'water_tank', - 'unique_id': 'GS01234_water_tank', + 'unique_id': 'GS012345_water_tank', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lamarzocco/snapshots/test_button.ambr b/tests/components/lamarzocco/snapshots/test_button.ambr index 023039cc6f7fa..64d47a110727f 100644 --- a/tests/components/lamarzocco/snapshots/test_button.ambr +++ b/tests/components/lamarzocco/snapshots/test_button.ambr @@ -2,10 +2,10 @@ # name: test_start_backflush StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Start backflush', + 'friendly_name': 'GS012345 Start backflush', }), 'context': , - 'entity_id': 'button.gs01234_start_backflush', + 'entity_id': 'button.gs012345_start_backflush', 'last_changed': , 'last_reported': , 'last_updated': , @@ -24,7 +24,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': None, - 'entity_id': 'button.gs01234_start_backflush', + 'entity_id': 'button.gs012345_start_backflush', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -41,7 +41,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_backflush', - 'unique_id': 'GS01234_start_backflush', + 'unique_id': 'GS012345_start_backflush', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lamarzocco/snapshots/test_calendar.ambr b/tests/components/lamarzocco/snapshots/test_calendar.ambr index 2fd5dab846a38..729eed5879a31 100644 --- a/tests/components/lamarzocco/snapshots/test_calendar.ambr +++ b/tests/components/lamarzocco/snapshots/test_calendar.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_calendar_edge_cases[start_date0-end_date0] dict({ - 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ + 'calendar.gs012345_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', @@ -15,7 +15,7 @@ # --- # name: test_calendar_edge_cases[start_date1-end_date1] dict({ - 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ + 'calendar.gs012345_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', @@ -29,7 +29,7 @@ # --- # name: test_calendar_edge_cases[start_date2-end_date2] dict({ - 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ + 'calendar.gs012345_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', @@ -43,7 +43,7 @@ # --- # name: test_calendar_edge_cases[start_date3-end_date3] dict({ - 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ + 'calendar.gs012345_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', @@ -57,7 +57,7 @@ # --- # name: test_calendar_edge_cases[start_date4-end_date4] dict({ - 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ + 'calendar.gs012345_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ ]), }), @@ -65,7 +65,7 @@ # --- # name: test_calendar_edge_cases[start_date5-end_date5] dict({ - 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ + 'calendar.gs012345_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', @@ -83,7 +83,7 @@ }), }) # --- -# name: test_calendar_events[entry.GS01234_auto_on_off_schedule_axfz5bj] +# name: test_calendar_events[entry.GS012345_auto_on_off_schedule_axfz5bj] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -95,7 +95,7 @@ 'disabled_by': None, 'domain': 'calendar', 'entity_category': None, - 'entity_id': 'calendar.gs01234_auto_on_off_schedule_axfz5bj', + 'entity_id': 'calendar.gs012345_auto_on_off_schedule_axfz5bj', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -112,11 +112,11 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'auto_on_off_schedule', - 'unique_id': 'GS01234_auto_on_off_schedule_aXFz5bJ', + 'unique_id': 'GS012345_auto_on_off_schedule_aXFz5bJ', 'unit_of_measurement': None, }) # --- -# name: test_calendar_events[entry.GS01234_auto_on_off_schedule_os2oswx] +# name: test_calendar_events[entry.GS012345_auto_on_off_schedule_os2oswx] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -128,7 +128,7 @@ 'disabled_by': None, 'domain': 'calendar', 'entity_category': None, - 'entity_id': 'calendar.gs01234_auto_on_off_schedule_os2oswx', + 'entity_id': 'calendar.gs012345_auto_on_off_schedule_os2oswx', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -145,13 +145,13 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'auto_on_off_schedule', - 'unique_id': 'GS01234_auto_on_off_schedule_Os2OswX', + 'unique_id': 'GS012345_auto_on_off_schedule_Os2OswX', 'unit_of_measurement': None, }) # --- -# name: test_calendar_events[events.GS01234_auto_on_off_schedule_axfz5bj] +# name: test_calendar_events[events.GS012345_auto_on_off_schedule_axfz5bj] dict({ - 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ + 'calendar.gs012345_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', @@ -181,9 +181,9 @@ }), }) # --- -# name: test_calendar_events[events.GS01234_auto_on_off_schedule_os2oswx] +# name: test_calendar_events[events.GS012345_auto_on_off_schedule_os2oswx] dict({ - 'calendar.gs01234_auto_on_off_schedule_os2oswx': dict({ + 'calendar.gs012345_auto_on_off_schedule_os2oswx': dict({ 'events': list([ dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', @@ -327,38 +327,38 @@ }), }) # --- -# name: test_calendar_events[state.GS01234_auto_on_off_schedule_axfz5bj] +# name: test_calendar_events[state.GS012345_auto_on_off_schedule_axfz5bj] StateSnapshot({ 'attributes': ReadOnlyDict({ 'all_day': False, 'description': 'Machine is scheduled to turn on at the start time and off at the end time', 'end_time': '2024-01-14 07:30:00', - 'friendly_name': 'GS01234 Auto on/off schedule (aXFz5bJ)', + 'friendly_name': 'GS012345 Auto on/off schedule (aXFz5bJ)', 'location': '', 'message': 'Machine My LaMarzocco on', 'start_time': '2024-01-14 07:00:00', }), 'context': , - 'entity_id': 'calendar.gs01234_auto_on_off_schedule_axfz5bj', + 'entity_id': 'calendar.gs012345_auto_on_off_schedule_axfz5bj', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_calendar_events[state.GS01234_auto_on_off_schedule_os2oswx] +# name: test_calendar_events[state.GS012345_auto_on_off_schedule_os2oswx] StateSnapshot({ 'attributes': ReadOnlyDict({ 'all_day': False, 'description': 'Machine is scheduled to turn on at the start time and off at the end time', 'end_time': '2024-01-13 00:00:00', - 'friendly_name': 'GS01234 Auto on/off schedule (Os2OswX)', + 'friendly_name': 'GS012345 Auto on/off schedule (Os2OswX)', 'location': '', 'message': 'Machine My LaMarzocco on', 'start_time': '2024-01-12 22:00:00', }), 'context': , - 'entity_id': 'calendar.gs01234_auto_on_off_schedule_os2oswx', + 'entity_id': 'calendar.gs012345_auto_on_off_schedule_os2oswx', 'last_changed': , 'last_reported': , 'last_updated': , @@ -367,7 +367,7 @@ # --- # name: test_no_calendar_events_global_disable dict({ - 'calendar.gs01234_auto_on_off_schedule_os2oswx': dict({ + 'calendar.gs012345_auto_on_off_schedule_os2oswx': dict({ 'events': list([ ]), }), diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index bd54ce2c0b473..b7e42bb425ffb 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -3,7 +3,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'GS01234 Coffee target temperature', + 'friendly_name': 'GS012345 Coffee target temperature', 'max': 104, 'min': 85, 'mode': , @@ -11,7 +11,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_coffee_target_temperature', + 'entity_id': 'number.gs012345_coffee_target_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -35,7 +35,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': None, - 'entity_id': 'number.gs01234_coffee_target_temperature', + 'entity_id': 'number.gs012345_coffee_target_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -52,7 +52,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'coffee_temp', - 'unique_id': 'GS01234_coffee_temp', + 'unique_id': 'GS012345_coffee_temp', 'unit_of_measurement': , }) # --- @@ -60,7 +60,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Smart standby time', + 'friendly_name': 'GS012345 Smart standby time', 'max': 240, 'min': 10, 'mode': , @@ -68,7 +68,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_smart_standby_time', + 'entity_id': 'number.gs012345_smart_standby_time', 'last_changed': , 'last_reported': , 'last_updated': , @@ -92,7 +92,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.gs01234_smart_standby_time', + 'entity_id': 'number.gs012345_smart_standby_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -109,7 +109,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'smart_standby_time', - 'unique_id': 'GS01234_smart_standby_time', + 'unique_id': 'GS012345_smart_standby_time', 'unit_of_measurement': , }) # --- @@ -117,7 +117,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'GS01234 Steam target temperature', + 'friendly_name': 'GS012345 Steam target temperature', 'max': 131, 'min': 126, 'mode': , @@ -125,7 +125,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_steam_target_temperature', + 'entity_id': 'number.gs012345_steam_target_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -149,7 +149,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': None, - 'entity_id': 'number.gs01234_steam_target_temperature', + 'entity_id': 'number.gs012345_steam_target_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -166,7 +166,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'steam_temp', - 'unique_id': 'GS01234_steam_temp', + 'unique_id': 'GS012345_steam_temp', 'unit_of_measurement': , }) # --- @@ -174,7 +174,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'GS01234 Steam target temperature', + 'friendly_name': 'GS012345 Steam target temperature', 'max': 131, 'min': 126, 'mode': , @@ -182,7 +182,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_steam_target_temperature', + 'entity_id': 'number.gs012345_steam_target_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -206,7 +206,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': None, - 'entity_id': 'number.gs01234_steam_target_temperature', + 'entity_id': 'number.gs012345_steam_target_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -223,7 +223,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'steam_temp', - 'unique_id': 'GS01234_steam_temp', + 'unique_id': 'GS012345_steam_temp', 'unit_of_measurement': , }) # --- @@ -231,7 +231,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Tea water duration', + 'friendly_name': 'GS012345 Tea water duration', 'max': 30, 'min': 0, 'mode': , @@ -239,7 +239,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_tea_water_duration', + 'entity_id': 'number.gs012345_tea_water_duration', 'last_changed': , 'last_reported': , 'last_updated': , @@ -263,7 +263,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': None, - 'entity_id': 'number.gs01234_tea_water_duration', + 'entity_id': 'number.gs012345_tea_water_duration', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -280,7 +280,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'tea_water_duration', - 'unique_id': 'GS01234_tea_water_duration', + 'unique_id': 'GS012345_tea_water_duration', 'unit_of_measurement': , }) # --- @@ -288,7 +288,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Tea water duration', + 'friendly_name': 'GS012345 Tea water duration', 'max': 30, 'min': 0, 'mode': , @@ -296,7 +296,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_tea_water_duration', + 'entity_id': 'number.gs012345_tea_water_duration', 'last_changed': , 'last_reported': , 'last_updated': , @@ -320,7 +320,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': None, - 'entity_id': 'number.gs01234_tea_water_duration', + 'entity_id': 'number.gs012345_tea_water_duration', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -337,14 +337,14 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'tea_water_duration', - 'unique_id': 'GS01234_tea_water_duration', + 'unique_id': 'GS012345_tea_water_duration', 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS01234_dose_key_1-state] +# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS012345_dose_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Dose Key 1', + 'friendly_name': 'GS012345 Dose Key 1', 'max': 999, 'min': 0, 'mode': , @@ -352,17 +352,17 @@ 'unit_of_measurement': 'ticks', }), 'context': , - 'entity_id': 'number.gs01234_dose_key_1', + 'entity_id': 'number.gs012345_dose_key_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '135', }) # --- -# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS01234_dose_key_2-state] +# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS012345_dose_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Dose Key 2', + 'friendly_name': 'GS012345 Dose Key 2', 'max': 999, 'min': 0, 'mode': , @@ -370,17 +370,17 @@ 'unit_of_measurement': 'ticks', }), 'context': , - 'entity_id': 'number.gs01234_dose_key_2', + 'entity_id': 'number.gs012345_dose_key_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '97', }) # --- -# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS01234_dose_key_3-state] +# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS012345_dose_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Dose Key 3', + 'friendly_name': 'GS012345 Dose Key 3', 'max': 999, 'min': 0, 'mode': , @@ -388,17 +388,17 @@ 'unit_of_measurement': 'ticks', }), 'context': , - 'entity_id': 'number.gs01234_dose_key_3', + 'entity_id': 'number.gs012345_dose_key_3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '108', }) # --- -# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS01234_dose_key_4-state] +# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS012345_dose_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Dose Key 4', + 'friendly_name': 'GS012345 Dose Key 4', 'max': 999, 'min': 0, 'mode': , @@ -406,18 +406,18 @@ 'unit_of_measurement': 'ticks', }), 'context': , - 'entity_id': 'number.gs01234_dose_key_4', + 'entity_id': 'number.gs012345_dose_key_4', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '121', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS01234_prebrew_off_time_key_1-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Prebrew off time Key 1', + 'friendly_name': 'GS012345 Prebrew off time Key 1', 'max': 10, 'min': 1, 'mode': , @@ -425,18 +425,18 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_prebrew_off_time_key_1', + 'entity_id': 'number.gs012345_prebrew_off_time_key_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS01234_prebrew_off_time_key_2-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Prebrew off time Key 2', + 'friendly_name': 'GS012345 Prebrew off time Key 2', 'max': 10, 'min': 1, 'mode': , @@ -444,18 +444,18 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_prebrew_off_time_key_2', + 'entity_id': 'number.gs012345_prebrew_off_time_key_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS01234_prebrew_off_time_key_3-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Prebrew off time Key 3', + 'friendly_name': 'GS012345 Prebrew off time Key 3', 'max': 10, 'min': 1, 'mode': , @@ -463,18 +463,18 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_prebrew_off_time_key_3', + 'entity_id': 'number.gs012345_prebrew_off_time_key_3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '3.29999995231628', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS01234_prebrew_off_time_key_4-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Prebrew off time Key 4', + 'friendly_name': 'GS012345 Prebrew off time Key 4', 'max': 10, 'min': 1, 'mode': , @@ -482,18 +482,18 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_prebrew_off_time_key_4', + 'entity_id': 'number.gs012345_prebrew_off_time_key_4', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS01234_prebrew_on_time_key_1-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Prebrew on time Key 1', + 'friendly_name': 'GS012345 Prebrew on time Key 1', 'max': 10, 'min': 2, 'mode': , @@ -501,18 +501,18 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_prebrew_on_time_key_1', + 'entity_id': 'number.gs012345_prebrew_on_time_key_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS01234_prebrew_on_time_key_2-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Prebrew on time Key 2', + 'friendly_name': 'GS012345 Prebrew on time Key 2', 'max': 10, 'min': 2, 'mode': , @@ -520,18 +520,18 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_prebrew_on_time_key_2', + 'entity_id': 'number.gs012345_prebrew_on_time_key_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS01234_prebrew_on_time_key_3-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Prebrew on time Key 3', + 'friendly_name': 'GS012345 Prebrew on time Key 3', 'max': 10, 'min': 2, 'mode': , @@ -539,18 +539,18 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_prebrew_on_time_key_3', + 'entity_id': 'number.gs012345_prebrew_on_time_key_3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '3.29999995231628', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS01234_prebrew_on_time_key_4-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Prebrew on time Key 4', + 'friendly_name': 'GS012345 Prebrew on time Key 4', 'max': 10, 'min': 2, 'mode': , @@ -558,18 +558,18 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_prebrew_on_time_key_4', + 'entity_id': 'number.gs012345_prebrew_on_time_key_4', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2', }) # --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS01234_preinfusion_time_key_1-state] +# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Preinfusion time Key 1', + 'friendly_name': 'GS012345 Preinfusion time Key 1', 'max': 29, 'min': 2, 'mode': , @@ -577,18 +577,18 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_preinfusion_time_key_1', + 'entity_id': 'number.gs012345_preinfusion_time_key_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS01234_preinfusion_time_key_2-state] +# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Preinfusion time Key 2', + 'friendly_name': 'GS012345 Preinfusion time Key 2', 'max': 29, 'min': 2, 'mode': , @@ -596,18 +596,18 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_preinfusion_time_key_2', + 'entity_id': 'number.gs012345_preinfusion_time_key_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS01234_preinfusion_time_key_3-state] +# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Preinfusion time Key 3', + 'friendly_name': 'GS012345 Preinfusion time Key 3', 'max': 29, 'min': 2, 'mode': , @@ -615,18 +615,18 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_preinfusion_time_key_3', + 'entity_id': 'number.gs012345_preinfusion_time_key_3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '3.29999995231628', }) # --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS01234_preinfusion_time_key_4-state] +# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Preinfusion time Key 4', + 'friendly_name': 'GS012345 Preinfusion time Key 4', 'max': 29, 'min': 2, 'mode': , @@ -634,7 +634,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.gs01234_preinfusion_time_key_4', + 'entity_id': 'number.gs012345_preinfusion_time_key_4', 'last_changed': , 'last_reported': , 'last_updated': , @@ -645,7 +645,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'LM01234 Prebrew off time', + 'friendly_name': 'LM012345 Prebrew off time', 'max': 10, 'min': 1, 'mode': , @@ -653,7 +653,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.lm01234_prebrew_off_time', + 'entity_id': 'number.lm012345_prebrew_off_time', 'last_changed': , 'last_reported': , 'last_updated': , @@ -677,7 +677,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.lm01234_prebrew_off_time', + 'entity_id': 'number.lm012345_prebrew_off_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -694,7 +694,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'prebrew_off', - 'unique_id': 'LM01234_prebrew_off', + 'unique_id': 'LM012345_prebrew_off', 'unit_of_measurement': , }) # --- @@ -702,7 +702,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'MR01234 Prebrew off time', + 'friendly_name': 'MR012345 Prebrew off time', 'max': 10, 'min': 1, 'mode': , @@ -710,7 +710,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.mr01234_prebrew_off_time', + 'entity_id': 'number.mr012345_prebrew_off_time', 'last_changed': , 'last_reported': , 'last_updated': , @@ -734,7 +734,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.mr01234_prebrew_off_time', + 'entity_id': 'number.mr012345_prebrew_off_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -751,7 +751,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'prebrew_off', - 'unique_id': 'MR01234_prebrew_off', + 'unique_id': 'MR012345_prebrew_off', 'unit_of_measurement': , }) # --- @@ -759,7 +759,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'LM01234 Prebrew on time', + 'friendly_name': 'LM012345 Prebrew on time', 'max': 10, 'min': 2, 'mode': , @@ -767,7 +767,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.lm01234_prebrew_on_time', + 'entity_id': 'number.lm012345_prebrew_on_time', 'last_changed': , 'last_reported': , 'last_updated': , @@ -791,7 +791,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.lm01234_prebrew_on_time', + 'entity_id': 'number.lm012345_prebrew_on_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -808,7 +808,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'prebrew_on', - 'unique_id': 'LM01234_prebrew_on', + 'unique_id': 'LM012345_prebrew_on', 'unit_of_measurement': , }) # --- @@ -816,7 +816,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'MR01234 Prebrew on time', + 'friendly_name': 'MR012345 Prebrew on time', 'max': 10, 'min': 2, 'mode': , @@ -824,7 +824,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.mr01234_prebrew_on_time', + 'entity_id': 'number.mr012345_prebrew_on_time', 'last_changed': , 'last_reported': , 'last_updated': , @@ -848,7 +848,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.mr01234_prebrew_on_time', + 'entity_id': 'number.mr012345_prebrew_on_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -865,7 +865,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'prebrew_on', - 'unique_id': 'MR01234_prebrew_on', + 'unique_id': 'MR012345_prebrew_on', 'unit_of_measurement': , }) # --- @@ -873,7 +873,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'LM01234 Preinfusion time', + 'friendly_name': 'LM012345 Preinfusion time', 'max': 29, 'min': 2, 'mode': , @@ -881,7 +881,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.lm01234_preinfusion_time', + 'entity_id': 'number.lm012345_preinfusion_time', 'last_changed': , 'last_reported': , 'last_updated': , @@ -905,7 +905,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.lm01234_preinfusion_time', + 'entity_id': 'number.lm012345_preinfusion_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -922,7 +922,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'preinfusion_off', - 'unique_id': 'LM01234_preinfusion_off', + 'unique_id': 'LM012345_preinfusion_off', 'unit_of_measurement': , }) # --- @@ -930,7 +930,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'MR01234 Preinfusion time', + 'friendly_name': 'MR012345 Preinfusion time', 'max': 29, 'min': 2, 'mode': , @@ -938,7 +938,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.mr01234_preinfusion_time', + 'entity_id': 'number.mr012345_preinfusion_time', 'last_changed': , 'last_reported': , 'last_updated': , @@ -962,7 +962,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.mr01234_preinfusion_time', + 'entity_id': 'number.mr012345_preinfusion_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -979,7 +979,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'preinfusion_off', - 'unique_id': 'MR01234_preinfusion_off', + 'unique_id': 'MR012345_preinfusion_off', 'unit_of_measurement': , }) # --- diff --git a/tests/components/lamarzocco/snapshots/test_select.ambr b/tests/components/lamarzocco/snapshots/test_select.ambr index 4f08b0898b195..46fa55eff13e1 100644 --- a/tests/components/lamarzocco/snapshots/test_select.ambr +++ b/tests/components/lamarzocco/snapshots/test_select.ambr @@ -2,7 +2,7 @@ # name: test_pre_brew_infusion_select[GS3 AV] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Prebrew/-infusion mode', + 'friendly_name': 'GS012345 Prebrew/-infusion mode', 'options': list([ 'disabled', 'prebrew', @@ -10,7 +10,7 @@ ]), }), 'context': , - 'entity_id': 'select.gs01234_prebrew_infusion_mode', + 'entity_id': 'select.gs012345_prebrew_infusion_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -35,7 +35,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': , - 'entity_id': 'select.gs01234_prebrew_infusion_mode', + 'entity_id': 'select.gs012345_prebrew_infusion_mode', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -52,14 +52,14 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'prebrew_infusion_select', - 'unique_id': 'GS01234_prebrew_infusion_select', + 'unique_id': 'GS012345_prebrew_infusion_select', 'unit_of_measurement': None, }) # --- # name: test_pre_brew_infusion_select[Linea Mini] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'LM01234 Prebrew/-infusion mode', + 'friendly_name': 'LM012345 Prebrew/-infusion mode', 'options': list([ 'disabled', 'prebrew', @@ -67,7 +67,7 @@ ]), }), 'context': , - 'entity_id': 'select.lm01234_prebrew_infusion_mode', + 'entity_id': 'select.lm012345_prebrew_infusion_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -92,7 +92,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': , - 'entity_id': 'select.lm01234_prebrew_infusion_mode', + 'entity_id': 'select.lm012345_prebrew_infusion_mode', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -109,14 +109,14 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'prebrew_infusion_select', - 'unique_id': 'LM01234_prebrew_infusion_select', + 'unique_id': 'LM012345_prebrew_infusion_select', 'unit_of_measurement': None, }) # --- # name: test_pre_brew_infusion_select[Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'MR01234 Prebrew/-infusion mode', + 'friendly_name': 'MR012345 Prebrew/-infusion mode', 'options': list([ 'disabled', 'prebrew', @@ -124,7 +124,7 @@ ]), }), 'context': , - 'entity_id': 'select.mr01234_prebrew_infusion_mode', + 'entity_id': 'select.mr012345_prebrew_infusion_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -149,7 +149,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': , - 'entity_id': 'select.mr01234_prebrew_infusion_mode', + 'entity_id': 'select.mr012345_prebrew_infusion_mode', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -166,21 +166,21 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'prebrew_infusion_select', - 'unique_id': 'MR01234_prebrew_infusion_select', + 'unique_id': 'MR012345_prebrew_infusion_select', 'unit_of_measurement': None, }) # --- # name: test_smart_standby_mode StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Smart standby mode', + 'friendly_name': 'GS012345 Smart standby mode', 'options': list([ 'power_on', 'last_brewing', ]), }), 'context': , - 'entity_id': 'select.gs01234_smart_standby_mode', + 'entity_id': 'select.gs012345_smart_standby_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -204,7 +204,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': , - 'entity_id': 'select.gs01234_smart_standby_mode', + 'entity_id': 'select.gs012345_smart_standby_mode', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -221,14 +221,14 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'smart_standby_mode', - 'unique_id': 'GS01234_smart_standby_mode', + 'unique_id': 'GS012345_smart_standby_mode', 'unit_of_measurement': None, }) # --- # name: test_steam_boiler_level[Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'MR01234 Steam level', + 'friendly_name': 'MR012345 Steam level', 'options': list([ '1', '2', @@ -236,7 +236,7 @@ ]), }), 'context': , - 'entity_id': 'select.mr01234_steam_level', + 'entity_id': 'select.mr012345_steam_level', 'last_changed': , 'last_reported': , 'last_updated': , @@ -261,7 +261,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': None, - 'entity_id': 'select.mr01234_steam_level', + 'entity_id': 'select.mr012345_steam_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -278,7 +278,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'steam_temp_select', - 'unique_id': 'MR01234_steam_temp_select', + 'unique_id': 'MR012345_steam_temp_select', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 2237a8416e166..da1efbf1eaae4 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[GS01234_current_coffee_temperature-entry] +# name: test_sensors[GS012345_current_coffee_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -13,7 +13,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.gs01234_current_coffee_temperature', + 'entity_id': 'sensor.gs012345_current_coffee_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -33,27 +33,27 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_temp_coffee', - 'unique_id': 'GS01234_current_temp_coffee', + 'unique_id': 'GS012345_current_temp_coffee', 'unit_of_measurement': , }) # --- -# name: test_sensors[GS01234_current_coffee_temperature-sensor] +# name: test_sensors[GS012345_current_coffee_temperature-sensor] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'GS01234 Current coffee temperature', + 'friendly_name': 'GS012345 Current coffee temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.gs01234_current_coffee_temperature', + 'entity_id': 'sensor.gs012345_current_coffee_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '96.5', }) # --- -# name: test_sensors[GS01234_current_steam_temperature-entry] +# name: test_sensors[GS012345_current_steam_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -67,7 +67,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.gs01234_current_steam_temperature', + 'entity_id': 'sensor.gs012345_current_steam_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -87,27 +87,27 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_temp_steam', - 'unique_id': 'GS01234_current_temp_steam', + 'unique_id': 'GS012345_current_temp_steam', 'unit_of_measurement': , }) # --- -# name: test_sensors[GS01234_current_steam_temperature-sensor] +# name: test_sensors[GS012345_current_steam_temperature-sensor] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'GS01234 Current steam temperature', + 'friendly_name': 'GS012345 Current steam temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.gs01234_current_steam_temperature', + 'entity_id': 'sensor.gs012345_current_steam_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '123.800003051758', }) # --- -# name: test_sensors[GS01234_shot_timer-entry] +# name: test_sensors[GS012345_shot_timer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -121,7 +121,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.gs01234_shot_timer', + 'entity_id': 'sensor.gs012345_shot_timer', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -138,27 +138,27 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'shot_timer', - 'unique_id': 'GS01234_shot_timer', + 'unique_id': 'GS012345_shot_timer', 'unit_of_measurement': , }) # --- -# name: test_sensors[GS01234_shot_timer-sensor] +# name: test_sensors[GS012345_shot_timer-sensor] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'GS01234 Shot timer', + 'friendly_name': 'GS012345 Shot timer', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.gs01234_shot_timer', + 'entity_id': 'sensor.gs012345_shot_timer', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0', }) # --- -# name: test_sensors[GS01234_total_coffees_made-entry] +# name: test_sensors[GS012345_total_coffees_made-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -172,7 +172,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.gs01234_total_coffees_made', + 'entity_id': 'sensor.gs012345_total_coffees_made', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -189,26 +189,26 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drink_stats_coffee', - 'unique_id': 'GS01234_drink_stats_coffee', + 'unique_id': 'GS012345_drink_stats_coffee', 'unit_of_measurement': 'drinks', }) # --- -# name: test_sensors[GS01234_total_coffees_made-sensor] +# name: test_sensors[GS012345_total_coffees_made-sensor] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Total coffees made', + 'friendly_name': 'GS012345 Total coffees made', 'state_class': , 'unit_of_measurement': 'drinks', }), 'context': , - 'entity_id': 'sensor.gs01234_total_coffees_made', + 'entity_id': 'sensor.gs012345_total_coffees_made', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1047', }) # --- -# name: test_sensors[GS01234_total_flushes_made-entry] +# name: test_sensors[GS012345_total_flushes_made-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -222,7 +222,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.gs01234_total_flushes_made', + 'entity_id': 'sensor.gs012345_total_flushes_made', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -239,19 +239,19 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drink_stats_flushing', - 'unique_id': 'GS01234_drink_stats_flushing', + 'unique_id': 'GS012345_drink_stats_flushing', 'unit_of_measurement': 'drinks', }) # --- -# name: test_sensors[GS01234_total_flushes_made-sensor] +# name: test_sensors[GS012345_total_flushes_made-sensor] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Total flushes made', + 'friendly_name': 'GS012345 Total flushes made', 'state_class': , 'unit_of_measurement': 'drinks', }), 'context': , - 'entity_id': 'sensor.gs01234_total_flushes_made', + 'entity_id': 'sensor.gs012345_total_flushes_made', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lamarzocco/snapshots/test_switch.ambr b/tests/components/lamarzocco/snapshots/test_switch.ambr index 2a368a5646732..5e3b99da6176e 100644 --- a/tests/components/lamarzocco/snapshots/test_switch.ambr +++ b/tests/components/lamarzocco/snapshots/test_switch.ambr @@ -11,7 +11,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.gs01234_auto_on_off_os2oswx', + 'entity_id': 'switch.gs012345_auto_on_off_os2oswx', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'auto_on_off', - 'unique_id': 'GS01234_auto_on_off_Os2OswX', + 'unique_id': 'GS012345_auto_on_off_Os2OswX', 'unit_of_measurement': None, }) # --- @@ -44,7 +44,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.gs01234_auto_on_off_axfz5bj', + 'entity_id': 'switch.gs012345_auto_on_off_axfz5bj', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -61,17 +61,17 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'auto_on_off', - 'unique_id': 'GS01234_auto_on_off_aXFz5bJ', + 'unique_id': 'GS012345_auto_on_off_aXFz5bJ', 'unit_of_measurement': None, }) # --- # name: test_auto_on_off_switches[state.auto_on_off_Os2OswX] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Auto on/off (Os2OswX)', + 'friendly_name': 'GS012345 Auto on/off (Os2OswX)', }), 'context': , - 'entity_id': 'switch.gs01234_auto_on_off_os2oswx', + 'entity_id': 'switch.gs012345_auto_on_off_os2oswx', 'last_changed': , 'last_reported': , 'last_updated': , @@ -81,10 +81,10 @@ # name: test_auto_on_off_switches[state.auto_on_off_aXFz5bJ] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Auto on/off (aXFz5bJ)', + 'friendly_name': 'GS012345 Auto on/off (aXFz5bJ)', }), 'context': , - 'entity_id': 'switch.gs01234_auto_on_off_axfz5bj', + 'entity_id': 'switch.gs012345_auto_on_off_axfz5bj', 'last_changed': , 'last_reported': , 'last_updated': , @@ -105,7 +105,7 @@ 'identifiers': set({ tuple( 'lamarzocco', - 'GS01234', + 'GS012345', ), }), 'is_new': False, @@ -114,10 +114,10 @@ 'manufacturer': 'La Marzocco', 'model': , 'model_id': , - 'name': 'GS01234', + 'name': 'GS012345', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': 'GS01234', + 'serial_number': 'GS012345', 'suggested_area': None, 'sw_version': '1.40', 'via_device_id': None, @@ -126,10 +126,10 @@ # name: test_switches[-set_power-kwargs0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234', + 'friendly_name': 'GS012345', }), 'context': , - 'entity_id': 'switch.gs01234', + 'entity_id': 'switch.gs012345', 'last_changed': , 'last_reported': , 'last_updated': , @@ -148,7 +148,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.gs01234', + 'entity_id': 'switch.gs012345', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -165,17 +165,17 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'main', - 'unique_id': 'GS01234_main', + 'unique_id': 'GS012345_main', 'unit_of_measurement': None, }) # --- # name: test_switches[_smart_standby_enabled-set_smart_standby-kwargs2] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Smart standby enabled', + 'friendly_name': 'GS012345 Smart standby enabled', }), 'context': , - 'entity_id': 'switch.gs01234_smart_standby_enabled', + 'entity_id': 'switch.gs012345_smart_standby_enabled', 'last_changed': , 'last_reported': , 'last_updated': , @@ -194,7 +194,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.gs01234_smart_standby_enabled', + 'entity_id': 'switch.gs012345_smart_standby_enabled', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -211,17 +211,17 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'smart_standby_enabled', - 'unique_id': 'GS01234_smart_standby_enabled', + 'unique_id': 'GS012345_smart_standby_enabled', 'unit_of_measurement': None, }) # --- # name: test_switches[_steam_boiler-set_steam-kwargs1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Steam boiler', + 'friendly_name': 'GS012345 Steam boiler', }), 'context': , - 'entity_id': 'switch.gs01234_steam_boiler', + 'entity_id': 'switch.gs012345_steam_boiler', 'last_changed': , 'last_reported': , 'last_updated': , @@ -240,7 +240,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.gs01234_steam_boiler', + 'entity_id': 'switch.gs012345_steam_boiler', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -257,7 +257,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'steam_boiler', - 'unique_id': 'GS01234_steam_boiler_enable', + 'unique_id': 'GS012345_steam_boiler_enable', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lamarzocco/snapshots/test_update.ambr b/tests/components/lamarzocco/snapshots/test_update.ambr index 6e6b7285797ef..46fa4cff8154f 100644 --- a/tests/components/lamarzocco/snapshots/test_update.ambr +++ b/tests/components/lamarzocco/snapshots/test_update.ambr @@ -6,7 +6,7 @@ 'device_class': 'firmware', 'display_precision': 0, 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', - 'friendly_name': 'GS01234 Gateway firmware', + 'friendly_name': 'GS012345 Gateway firmware', 'in_progress': False, 'installed_version': 'v3.1-rc4', 'latest_version': 'v3.5-rc3', @@ -18,7 +18,7 @@ 'update_percentage': None, }), 'context': , - 'entity_id': 'update.gs01234_gateway_firmware', + 'entity_id': 'update.gs012345_gateway_firmware', 'last_changed': , 'last_reported': , 'last_updated': , @@ -37,7 +37,7 @@ 'disabled_by': None, 'domain': 'update', 'entity_category': , - 'entity_id': 'update.gs01234_gateway_firmware', + 'entity_id': 'update.gs012345_gateway_firmware', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -54,7 +54,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'gateway_firmware', - 'unique_id': 'GS01234_gateway_firmware', + 'unique_id': 'GS012345_gateway_firmware', 'unit_of_measurement': None, }) # --- @@ -65,7 +65,7 @@ 'device_class': 'firmware', 'display_precision': 0, 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', - 'friendly_name': 'GS01234 Machine firmware', + 'friendly_name': 'GS012345 Machine firmware', 'in_progress': False, 'installed_version': '1.40', 'latest_version': '1.55', @@ -77,7 +77,7 @@ 'update_percentage': None, }), 'context': , - 'entity_id': 'update.gs01234_machine_firmware', + 'entity_id': 'update.gs012345_machine_firmware', 'last_changed': , 'last_reported': , 'last_updated': , @@ -96,7 +96,7 @@ 'disabled_by': None, 'domain': 'update', 'entity_category': , - 'entity_id': 'update.gs01234_machine_firmware', + 'entity_id': 'update.gs012345_machine_firmware', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -113,7 +113,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'machine_firmware', - 'unique_id': 'GS01234_machine_firmware', + 'unique_id': 'GS012345_machine_firmware', 'unit_of_measurement': None, }) # --- From 02046fcdb4612c9a9a563bb4a391e523e379d6cd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 3 Nov 2024 17:29:33 +0100 Subject: [PATCH 0057/1070] Fix advantage_air CI failure (#129735) --- tests/components/advantage_air/test_binary_sensor.py | 4 ++-- tests/components/advantage_air/test_sensor.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index 13bbadb38f987..7a7b2f8df5b9e 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -85,7 +85,7 @@ async def test_binary_sensor_async_setup_entry( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 2 + assert len(mock_get.mock_calls) == 3 state = hass.states.get(entity_id) assert state @@ -116,7 +116,7 @@ async def test_binary_sensor_async_setup_entry( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 2 + assert len(mock_get.mock_calls) == 3 state = hass.states.get(entity_id) assert state diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index 06243921a645c..4389e67228a60 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -140,7 +140,7 @@ async def test_sensor_platform_disabled_entity( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 2 + assert len(mock_get.mock_calls) == 3 state = hass.states.get(entity_id) assert state From 4d5c3ee0aace53b48a69102560b676ef04a99d47 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 3 Nov 2024 17:46:16 +0100 Subject: [PATCH 0058/1070] Bump bring-api to 0.9.1 (#129702) --- homeassistant/components/bring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index 79336c086ed84..ff24a9913508a 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bring", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["bring-api==0.9.0"] + "requirements": ["bring-api==0.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4ae97d028a4f2..1376caa0916f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -632,7 +632,7 @@ boto3==1.34.131 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.9.0 +bring-api==0.9.1 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 893a6dbb5be1d..29e527062eb3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -552,7 +552,7 @@ boschshcpy==0.2.91 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.9.0 +bring-api==0.9.1 # homeassistant.components.broadlink broadlink==0.19.0 From ed582fae916ecfe2b042edcac46cd187578100f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Nov 2024 11:27:57 -0600 Subject: [PATCH 0059/1070] Bump HAP-python to 4.9.2 (#129715) --- homeassistant/components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index eebdc0026fd73..cf74bcc7d67e8 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["pyhap"], "requirements": [ - "HAP-python==4.9.1", + "HAP-python==4.9.2", "fnv-hash-fast==1.0.2", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/requirements_all.txt b/requirements_all.txt index 1376caa0916f5..6c2d573f03ea1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -16,7 +16,7 @@ Adax-local==0.1.5 DoorBirdPy==3.0.8 # homeassistant.components.homekit -HAP-python==4.9.1 +HAP-python==4.9.2 # homeassistant.components.tasmota HATasmota==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 29e527062eb3c..dc60a031e032e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -16,7 +16,7 @@ Adax-local==0.1.5 DoorBirdPy==3.0.8 # homeassistant.components.homekit -HAP-python==4.9.1 +HAP-python==4.9.2 # homeassistant.components.tasmota HATasmota==0.9.2 From d671d488690588a84a4086f0f200bc836cb1aac8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 3 Nov 2024 19:17:37 +0100 Subject: [PATCH 0060/1070] Small cleanup mold_indicator (#129736) --- .../components/mold_indicator/sensor.py | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 8b0230e80931e..262d13ad3af2f 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -22,6 +22,7 @@ CONF_NAME, CONF_UNIQUE_ID, PERCENTAGE, + STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature, ) @@ -310,7 +311,7 @@ def _update_temp_sensor(state: State) -> float | None: _LOGGER.debug("Updating temp sensor with value %s", state.state) # Return an error if the sensor change its state to Unknown. - if state.state == STATE_UNKNOWN: + if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): _LOGGER.error( "Unable to parse temperature sensor %s with state: %s", state.entity_id, @@ -318,8 +319,6 @@ def _update_temp_sensor(state: State) -> float | None: ) return None - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if (temp := util.convert(state.state, float)) is None: _LOGGER.error( "Unable to parse temperature sensor %s with state: %s", @@ -329,12 +328,10 @@ def _update_temp_sensor(state: State) -> float | None: return None # convert to celsius if necessary - if unit == UnitOfTemperature.FAHRENHEIT: - return TemperatureConverter.convert( - temp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS - ) - if unit == UnitOfTemperature.CELSIUS: - return temp + if ( + unit := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + ) in UnitOfTemperature: + return TemperatureConverter.convert(temp, unit, UnitOfTemperature.CELSIUS) _LOGGER.error( "Temp sensor %s has unsupported unit: %s (allowed: %s, %s)", state.entity_id, @@ -351,7 +348,7 @@ def _update_hum_sensor(state: State) -> float | None: _LOGGER.debug("Updating humidity sensor with value %s", state.state) # Return an error if the sensor change its state to Unknown. - if state.state == STATE_UNKNOWN: + if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): _LOGGER.error( "Unable to parse humidity sensor %s, state: %s", state.entity_id, @@ -369,19 +366,18 @@ def _update_hum_sensor(state: State) -> float | None: if (unit := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) != PERCENTAGE: _LOGGER.error( - "Humidity sensor %s has unsupported unit: %s %s", + "Humidity sensor %s has unsupported unit: %s (allowed: %s)", state.entity_id, unit, - " (allowed: %)", + PERCENTAGE, ) return None if hum > 100 or hum < 0: _LOGGER.error( - "Humidity sensor %s is out of range: %s %s", + "Humidity sensor %s is out of range: %s (allowed: 0-100)", state.entity_id, hum, - "(allowed: 0-100%)", ) return None From 89eb395e2d754c998116ea6ae7ffd8e8f073ea9d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 3 Nov 2024 20:37:58 +0100 Subject: [PATCH 0061/1070] Add OptionsFlow helper for a mutable copy of the config entry options (#129718) * Add OptionsFlow helper for a mutable copy of the config entry options * Add tests * Improve coverage * error_if_core=False * Adjust report * Avoid mutli-line ternary --- homeassistant/components/mqtt/config_flow.py | 6 +-- homeassistant/components/onvif/config_flow.py | 7 +-- .../components/webostv/config_flow.py | 5 +- homeassistant/config_entries.py | 34 ++++++++++++-- tests/test_config_entries.py | 46 +++++++++++++++++-- 5 files changed, 76 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index e94f734069ac6..6e6b44cd4b886 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -220,7 +220,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> MQTTOptionsFlowHandler: """Get the options flow for this handler.""" - return MQTTOptionsFlowHandler(config_entry) + return MQTTOptionsFlowHandler() async def _async_install_addon(self) -> None: """Install the Mosquitto Mqtt broker add-on.""" @@ -543,11 +543,9 @@ async def async_step_hassio_confirm( class MQTTOptionsFlowHandler(OptionsFlow): """Handle MQTT options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize MQTT options flow.""" - self.config_entry = config_entry self.broker_config: dict[str, str | int] = {} - self.options = config_entry.options async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the MQTT options.""" diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 34f322b9f75cf..830f74b94e8bd 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -109,7 +109,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OnvifOptionsFlowHandler: """Get the options flow for this handler.""" - return OnvifOptionsFlowHandler(config_entry) + return OnvifOptionsFlowHandler() def __init__(self) -> None: """Initialize the ONVIF config flow.""" @@ -389,11 +389,6 @@ async def async_setup_profiles( class OnvifOptionsFlowHandler(OptionsFlow): """Handle ONVIF options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize ONVIF options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the ONVIF options.""" return await self.async_step_onvif_devices() diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 24bf89b24a6ee..45395bd282a15 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -170,8 +170,6 @@ class OptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.config_entry = config_entry - self.options = config_entry.options self.host = config_entry.data[CONF_HOST] self.key = config_entry.data[CONF_CLIENT_SECRET] @@ -188,7 +186,8 @@ async def async_step_init( if not sources_list: errors["base"] = "cannot_retrieve" - sources = [s for s in self.options.get(CONF_SOURCES, []) if s in sources_list] + option_sources = self.config_entry.options.get(CONF_SOURCES, []) + sources = [s for s in option_sources if s in sources_list] if not sources: sources = sources_list diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 971fd7d572654..f533a62e75361 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3053,6 +3053,7 @@ async def _async_setup_preview( class OptionsFlow(ConfigEntryBaseFlow): """Base class for config options flows.""" + _options: dict[str, Any] handler: str _config_entry: ConfigEntry @@ -3119,6 +3120,28 @@ def config_entry(self, value: ConfigEntry) -> None: ) self._config_entry = value + @property + def options(self) -> dict[str, Any]: + """Return a mutable copy of the config entry options. + + Please note that this is not available inside `__init__` method, and + can only be referenced after initialisation. + """ + if not hasattr(self, "_options"): + self._options = deepcopy(dict(self.config_entry.options)) + return self._options + + @options.setter + def options(self, value: dict[str, Any]) -> None: + """Set the options value.""" + report( + "sets option flow options explicitly, which is deprecated " + "and will stop working in 2025.12", + error_if_integration=False, + error_if_core=True, + ) + self._options = value + class OptionsFlowWithConfigEntry(OptionsFlow): """Base class for options flows with config entry and options.""" @@ -3127,11 +3150,12 @@ def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self._config_entry = config_entry self._options = deepcopy(dict(config_entry.options)) - - @property - def options(self) -> dict[str, Any]: - """Return a mutable copy of the config entry options.""" - return self._options + report( + "inherits from OptionsFlowWithConfigEntry, which is deprecated " + "and will stop working in 2025.12", + error_if_integration=False, + error_if_core=False, + ) class EntityRegistryDisabledHandler: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 6959dc3d3cedf..e3f1d110ac011 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4812,6 +4812,7 @@ async def test_reauth_reconfigure_missing_entry( @pytest.mark.usefixtures("mock_integration_frame") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) @pytest.mark.parametrize( "source", [config_entries.SOURCE_REAUTH, config_entries.SOURCE_RECONFIGURE] ) @@ -5039,15 +5040,21 @@ async def mock_setup(hass: HomeAssistant, _) -> bool: assert "test" in hass.config.components -async def test_options_flow_options_not_mutated() -> None: +@pytest.mark.usefixtures("mock_integration_frame") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) -> None: """Test that OptionsFlowWithConfigEntry doesn't mutate entry options.""" entry = MockConfigEntry( - domain="test", + domain="hue", data={"first": True}, options={"sub_dict": {"1": "one"}, "sub_list": ["one"]}, ) options_flow = config_entries.OptionsFlowWithConfigEntry(entry) + assert ( + "Detected that integration 'hue' inherits from OptionsFlowWithConfigEntry," + " which is deprecated and will stop working in 2025.12" in caplog.text + ) options_flow._options["sub_dict"]["2"] = "two" options_flow._options["sub_list"].append("two") @@ -5059,6 +5066,31 @@ async def test_options_flow_options_not_mutated() -> None: assert entry.options == {"sub_dict": {"1": "one"}, "sub_list": ["one"]} +@pytest.mark.usefixtures("mock_integration_frame") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_options_flow_options_not_mutated(hass: HomeAssistant) -> None: + """Test that OptionsFlow doesn't mutate entry options.""" + entry = MockConfigEntry( + domain="test", + data={"first": True}, + options={"sub_dict": {"1": "one"}, "sub_list": ["one"]}, + ) + entry.add_to_hass(hass) + + options_flow = config_entries.OptionsFlow() + options_flow.handler = entry.entry_id + options_flow.hass = hass + + options_flow.options["sub_dict"]["2"] = "two" + options_flow._options["sub_list"].append("two") + + assert options_flow._options == { + "sub_dict": {"1": "one", "2": "two"}, + "sub_list": ["one", "two"], + } + assert entry.options == {"sub_dict": {"1": "one"}, "sub_list": ["one"]} + + async def test_initializing_flows_canceled_on_shutdown( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -7405,7 +7437,6 @@ async def async_step_init(self, user_input=None): @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_deprecated_config_entry_setter( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -7433,7 +7464,10 @@ class _OptionsFlow(config_entries.OptionsFlow): def __init__(self, entry) -> None: """Test initialisation.""" - self.config_entry = entry + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + self.config_entry = entry + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + self.options = entry.options async def async_step_init(self, user_input=None): """Test user step.""" @@ -7462,6 +7496,10 @@ async def async_step_init(self, user_input=None): "Detected that integration 'hue' sets option flow config_entry explicitly, " "which is deprecated and will stop working in 2025.12" in caplog.text ) + assert ( + "Detected that integration 'hue' sets option flow options explicitly, " + "which is deprecated and will stop working in 2025.12" in caplog.text + ) async def test_add_description_placeholder_automatically( From 6b33bf3961de0bfd2d97a5060fc27107c3472e7e Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sun, 3 Nov 2024 20:56:08 +0100 Subject: [PATCH 0062/1070] Add missing translation string to lamarzocco (#129713) * add missing translation string * Update strings.json * import pytest again --- homeassistant/components/lamarzocco/strings.json | 1 + tests/components/lamarzocco/test_config_flow.py | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index ec3b00a74749e..959dda265a945 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -8,6 +8,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "machine_not_found": "Discovered machine not found in given account", "no_machines": "No machines found in account", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 3d23908abf720..13cf6a72b81d6 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -373,10 +373,6 @@ async def test_bluetooth_discovery( } -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.lamarzocco.config.error.machine_not_found"], -) async def test_bluetooth_discovery_errors( hass: HomeAssistant, mock_lamarzocco: MagicMock, From ab5c65b08c9a439e145b83aa36b1dfbc17b6d451 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 3 Nov 2024 21:04:53 +0100 Subject: [PATCH 0063/1070] Improve code quality in yale_smart_alarm options flow (#129531) * Improve code quality in yale_smart_alarm options flow * mods * Fix --- .../yale_smart_alarm/config_flow.py | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index 7b68a1f5dab9b..9d653da7a7e58 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -23,7 +23,6 @@ CONF_AREA_ID, CONF_LOCK_CODE_DIGITS, DEFAULT_AREA_ID, - DEFAULT_LOCK_CODE_DIGITS, DEFAULT_NAME, DOMAIN, LOGGER, @@ -44,6 +43,14 @@ } ) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_LOCK_CODE_DIGITS, + ): int, + } +) + class YaleConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Yale integration.""" @@ -54,7 +61,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow(config_entry: ConfigEntry) -> YaleOptionsFlowHandler: """Get the options flow for this handler.""" - return YaleOptionsFlowHandler(config_entry) + return YaleOptionsFlowHandler() async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -143,32 +150,18 @@ async def async_step_user( class YaleOptionsFlowHandler(OptionsFlow): """Handle Yale options.""" - def __init__(self, entry: ConfigEntry) -> None: - """Initialize Yale options flow.""" - self.entry = entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage Yale options.""" - errors: dict[str, Any] = {} - if user_input: + if user_input is not None: return self.async_create_entry(data=user_input) return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_LOCK_CODE_DIGITS, - description={ - "suggested_value": self.entry.options.get( - CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS - ) - }, - ): int, - } + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, + self.config_entry.options, ), - errors=errors, ) From 144d5ff0cc96b8f6f28a3e4ac601de5b6d35781a Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Sun, 3 Nov 2024 21:06:46 +0100 Subject: [PATCH 0064/1070] Add state class to precipitation_intensity in Aemet (#129670) Update sensor.py --- homeassistant/components/aemet/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 83d490f7fe2d6..e55344490aae1 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -249,6 +249,7 @@ class AemetSensorEntityDescription(SensorEntityDescription): name="Rain", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + state_class=SensorStateClass.MEASUREMENT, ), AemetSensorEntityDescription( key=ATTR_API_RAIN_PROB, @@ -263,6 +264,7 @@ class AemetSensorEntityDescription(SensorEntityDescription): name="Snow", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + state_class=SensorStateClass.MEASUREMENT, ), AemetSensorEntityDescription( key=ATTR_API_SNOW_PROB, From 0cfd8032c0b2cb379b81828e8ebad227039d768f Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Sun, 3 Nov 2024 21:07:59 +0100 Subject: [PATCH 0065/1070] Add Measurement StateClass to HomematicIP Cloud Wind and Rain Sensor (#129724) Add Meassurement StateClass to Wind and Rain Sensor --- homeassistant/components/homematicip_cloud/sensor.py | 2 ++ tests/components/homematicip_cloud/test_sensor.py | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index eab7ba4f09e30..c44d280c19083 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -420,6 +420,7 @@ class HomematicipWindspeedSensor(HomematicipGenericEntity, SensorEntity): _attr_device_class = SensorDeviceClass.WIND_SPEED _attr_native_unit_of_measurement = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the windspeed sensor.""" @@ -451,6 +452,7 @@ class HomematicipTodayRainSensor(HomematicipGenericEntity, SensorEntity): _attr_device_class = SensorDeviceClass.PRECIPITATION _attr_native_unit_of_measurement = UnitOfPrecipitationDepth.MILLIMETERS + _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index bdd0b6194ed7b..2dda3116032ce 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -23,7 +23,11 @@ ATTR_WIND_DIRECTION, ATTR_WIND_DIRECTION_VARIATION, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + SensorStateClass, +) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, LIGHT_LUX, @@ -362,6 +366,7 @@ async def test_hmip_windspeed_sensor( assert ( ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfSpeed.KILOMETERS_PER_HOUR ) + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT await async_manipulate_test_data(hass, hmip_device, "windSpeed", 9.4) ha_state = hass.states.get(entity_id) assert ha_state.state == "9.4" @@ -411,6 +416,7 @@ async def test_hmip_today_rain_sensor( assert ha_state.state == "3.9" assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfLength.MILLIMETERS + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT await async_manipulate_test_data(hass, hmip_device, "todayRainCounter", 14.2) ha_state = hass.states.get(entity_id) assert ha_state.state == "14.2" From 463bffaeb663c5138fbc808eb1b987cde146ef4a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 3 Nov 2024 21:55:12 +0100 Subject: [PATCH 0066/1070] Bump spotifyaio to 0.8.3 (#129729) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 5885d0103f214..2d86083d49c6b 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.2"], + "requirements": ["spotifyaio==0.8.3"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 6c2d573f03ea1..02c6853edae97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2710,7 +2710,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.2 +spotifyaio==0.8.3 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc60a031e032e..21040bf22ca78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2162,7 +2162,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.2 +spotifyaio==0.8.3 # homeassistant.components.sql sqlparse==0.5.0 From 8b6c99776eb434cec951d401dc45f07840d2ac94 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 3 Nov 2024 22:57:18 +0100 Subject: [PATCH 0067/1070] Cleanup unnecessary OptionsFlowWithConfigEntry (part 1) (#129752) * Cleanup unnecessary OptionsFlowWithConfigEntry * Fix emoncms * Fix imap * Fix met * Fix workday --- .../components/analytics_insights/config_flow.py | 9 +++++---- homeassistant/components/axis/config_flow.py | 10 ++++++---- .../components/bmw_connected_drive/config_flow.py | 6 +++--- homeassistant/components/dnsip/config_flow.py | 6 +++--- homeassistant/components/emoncms/config_flow.py | 12 +++++++----- .../components/enphase_envoy/config_flow.py | 10 ++++++---- homeassistant/components/feedreader/config_flow.py | 9 +++++---- homeassistant/components/file/config_flow.py | 11 +++++++---- homeassistant/components/fritz/config_flow.py | 9 +++++---- .../components/google_cloud/config_flow.py | 6 +++--- homeassistant/components/imap/config_flow.py | 14 +++++++------- homeassistant/components/jellyfin/config_flow.py | 12 ++++-------- .../components/jewish_calendar/config_flow.py | 10 ++++++---- .../components/kitchen_sink/config_flow.py | 6 +++--- homeassistant/components/lamarzocco/config_flow.py | 9 +++++---- homeassistant/components/lastfm/config_flow.py | 6 +++--- homeassistant/components/met/config_flow.py | 13 ++++++------- homeassistant/components/onewire/config_flow.py | 10 ++++++---- homeassistant/components/opensky/config_flow.py | 6 +++--- .../components/pvpc_hourly_pricing/config_flow.py | 6 +++--- homeassistant/components/roborock/config_flow.py | 7 +++---- homeassistant/components/roku/config_flow.py | 8 ++++---- homeassistant/components/roomba/config_flow.py | 6 +++--- homeassistant/components/sql/config_flow.py | 6 +++--- .../components/trafikverket_train/config_flow.py | 6 +++--- homeassistant/components/upnp/config_flow.py | 9 +++++---- .../components/vodafone_station/config_flow.py | 9 +++++---- homeassistant/components/wled/config_flow.py | 10 ++++++---- homeassistant/components/workday/config_flow.py | 8 ++++---- homeassistant/components/youtube/config_flow.py | 6 +++--- 30 files changed, 135 insertions(+), 120 deletions(-) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index baf0190967da0..0212f208436ff 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -16,7 +16,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -46,9 +45,11 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> HomeassistantAnalyticsOptionsFlowHandler: """Get the options flow for this handler.""" - return HomeassistantAnalyticsOptionsFlowHandler(config_entry) + return HomeassistantAnalyticsOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -132,7 +133,7 @@ async def async_step_user( ) -class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry): +class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow): """Handle Homeassistant Analytics options.""" async def async_step_init( diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 84d9880b7f86c..5026f7e7ab6ea 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -18,7 +18,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import ( CONF_HOST, @@ -59,9 +59,11 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> AxisOptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> AxisOptionsFlowHandler: """Get the options flow for this handler.""" - return AxisOptionsFlowHandler(config_entry) + return AxisOptionsFlowHandler() def __init__(self) -> None: """Initialize the Axis config flow.""" @@ -264,7 +266,7 @@ async def _process_discovered_device( return await self.async_step_user() -class AxisOptionsFlowHandler(OptionsFlowWithConfigEntry): +class AxisOptionsFlowHandler(OptionsFlow): """Handle Axis device options.""" config_entry: AxisConfigEntry diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 37ff1eb374c79..cd43325f1295c 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -17,7 +17,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -145,10 +145,10 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> BMWOptionsFlow: """Return a MyBMW option flow.""" - return BMWOptionsFlow(config_entry) + return BMWOptionsFlow() -class BMWOptionsFlow(OptionsFlowWithConfigEntry): +class BMWOptionsFlow(OptionsFlow): """Handle a option flow for MyBMW.""" async def async_step_init( diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index 6dda0c0391021..8c2cfa5e556d0 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -14,7 +14,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import callback @@ -101,7 +101,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> DnsIPOptionsFlowHandler: """Return Option handler.""" - return DnsIPOptionsFlowHandler(config_entry) + return DnsIPOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -165,7 +165,7 @@ async def async_step_user( ) -class DnsIPOptionsFlowHandler(OptionsFlowWithConfigEntry): +class DnsIPOptionsFlowHandler(OptionsFlow): """Handle a option config flow for dnsip integration.""" async def async_step_init( diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index fdd5d29788e1a..fa68418871308 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -1,5 +1,7 @@ """Configflow for the emoncms integration.""" +from __future__ import annotations + from typing import Any from pyemoncms import EmoncmsClient @@ -9,7 +11,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant, callback @@ -68,9 +70,9 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlowWithConfigEntry: + ) -> EmoncmsOptionsFlow: """Get the options flow for this handler.""" - return EmoncmsOptionsFlow(config_entry) + return EmoncmsOptionsFlow() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -167,7 +169,7 @@ async def async_step_import(self, import_info: ConfigType) -> ConfigFlowResult: return result -class EmoncmsOptionsFlow(OptionsFlowWithConfigEntry): +class EmoncmsOptionsFlow(OptionsFlow): """Emoncms Options flow handler.""" async def async_step_init( @@ -175,7 +177,7 @@ async def async_step_init( ) -> ConfigFlowResult: """Manage the options.""" errors: dict[str, str] = {} - data = self.options if self.options else self._config_entry.data + data = self.options if self.options else self.config_entry.data url = data[CONF_URL] api_key = data[CONF_API_KEY] include_only_feeds = data.get(CONF_ONLY_INCLUDE_FEEDID, []) diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index d04f77d8e88ff..23c769293c809 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -16,7 +16,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -66,9 +66,11 @@ def __init__(self) -> None: @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> EnvoyOptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> EnvoyOptionsFlowHandler: """Options flow handler for Enphase_Envoy.""" - return EnvoyOptionsFlowHandler(config_entry) + return EnvoyOptionsFlowHandler() @callback def _async_generate_schema(self) -> vol.Schema: @@ -288,7 +290,7 @@ async def async_step_reconfigure( ) -class EnvoyOptionsFlowHandler(OptionsFlowWithConfigEntry): +class EnvoyOptionsFlowHandler(OptionsFlow): """Envoy config flow options handler.""" async def async_step_init( diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index 2a73e24a3e52f..1a19f612e7ef9 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -15,7 +15,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback @@ -46,9 +45,11 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlow: """Get the options flow for this handler.""" - return FeedReaderOptionsFlowHandler(config_entry) + return FeedReaderOptionsFlowHandler() def show_user_form( self, @@ -147,7 +148,7 @@ async def async_step_reconfigure( return self.async_abort(reason="reconfigure_successful") -class FeedReaderOptionsFlowHandler(OptionsFlowWithConfigEntry): +class FeedReaderOptionsFlowHandler(OptionsFlow): """Handle an options flow.""" async def async_step_init( diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py index d74e36ce935e4..2b8a9bde749e1 100644 --- a/homeassistant/components/file/config_flow.py +++ b/homeassistant/components/file/config_flow.py @@ -1,5 +1,7 @@ """Config flow for file integration.""" +from __future__ import annotations + from copy import deepcopy import os from typing import Any @@ -11,7 +13,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import ( CONF_FILE_PATH, @@ -74,9 +75,11 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> FileOptionsFlowHandler: """Get the options flow for this handler.""" - return FileOptionsFlowHandler(config_entry) + return FileOptionsFlowHandler() async def validate_file_path(self, file_path: str) -> bool: """Ensure the file path is valid.""" @@ -151,7 +154,7 @@ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResu return self.async_create_entry(title=title, data=data, options=options) -class FileOptionsFlowHandler(OptionsFlowWithConfigEntry): +class FileOptionsFlowHandler(OptionsFlow): """Handle File options.""" async def async_step_init( diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 0d27894c8ab2f..38e86519a0176 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -23,7 +23,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import ( CONF_HOST, @@ -60,9 +59,11 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> FritzBoxToolsOptionsFlowHandler: """Get the options flow for this handler.""" - return FritzBoxToolsOptionsFlowHandler(config_entry) + return FritzBoxToolsOptionsFlowHandler() def __init__(self) -> None: """Initialize FRITZ!Box Tools flow.""" @@ -393,7 +394,7 @@ async def async_step_reconfigure( ) -class FritzBoxToolsOptionsFlowHandler(OptionsFlowWithConfigEntry): +class FritzBoxToolsOptionsFlowHandler(OptionsFlow): """Handle an options flow.""" async def async_step_init( diff --git a/homeassistant/components/google_cloud/config_flow.py b/homeassistant/components/google_cloud/config_flow.py index dec849de4e6ba..8b8fd751df9d6 100644 --- a/homeassistant/components/google_cloud/config_flow.py +++ b/homeassistant/components/google_cloud/config_flow.py @@ -15,7 +15,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.core import callback from homeassistant.helpers.selector import ( @@ -135,10 +135,10 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> GoogleCloudOptionsFlowHandler: """Create the options flow.""" - return GoogleCloudOptionsFlowHandler(config_entry) + return GoogleCloudOptionsFlowHandler() -class GoogleCloudOptionsFlowHandler(OptionsFlowWithConfigEntry): +class GoogleCloudOptionsFlowHandler(OptionsFlow): """Google Cloud options flow.""" async def async_step_init( diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 5bbb8599cf292..994c53b5b3e84 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -13,7 +13,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import ( CONF_NAME, @@ -213,12 +213,12 @@ async def async_step_reauth_confirm( @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> ImapOptionsFlow: """Get the options flow for this handler.""" - return OptionsFlow(config_entry) + return ImapOptionsFlow() -class OptionsFlow(OptionsFlowWithConfigEntry): +class ImapOptionsFlow(OptionsFlow): """Option flow handler.""" async def async_step_init( @@ -226,13 +226,13 @@ async def async_step_init( ) -> ConfigFlowResult: """Manage the options.""" errors: dict[str, str] | None = None - entry_data: dict[str, Any] = dict(self._config_entry.data) + entry_data: dict[str, Any] = dict(self.config_entry.data) if user_input is not None: try: self._async_abort_entries_match( { - CONF_SERVER: self._config_entry.data[CONF_SERVER], - CONF_USERNAME: self._config_entry.data[CONF_USERNAME], + CONF_SERVER: self.config_entry.data[CONF_SERVER], + CONF_USERNAME: self.config_entry.data[CONF_USERNAME], CONF_FOLDER: user_input[CONF_FOLDER], CONF_SEARCH: user_input[CONF_SEARCH], } diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py index f60d96f3efac1..0c170d2485f97 100644 --- a/homeassistant/components/jellyfin/config_flow.py +++ b/homeassistant/components/jellyfin/config_flow.py @@ -8,11 +8,7 @@ import voluptuous as vol -from homeassistant.config_entries import ( - ConfigFlow, - ConfigFlowResult, - OptionsFlowWithConfigEntry, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import callback from homeassistant.util.uuid import random_uuid_hex @@ -143,12 +139,12 @@ async def async_step_reauth_confirm( @callback def async_get_options_flow( config_entry: JellyfinConfigEntry, - ) -> OptionsFlowWithConfigEntry: + ) -> OptionsFlowHandler: """Create the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlowWithConfigEntry): +class OptionsFlowHandler(OptionsFlow): """Handle an option flow for jellyfin.""" async def async_step_init( diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index f96699d01bd73..9673fc6cf2294 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -12,7 +12,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import ( CONF_ELEVATION, @@ -90,9 +90,11 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowWithConfigEntry: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> JewishCalendarOptionsFlowHandler: """Get the options flow for this handler.""" - return JewishCalendarOptionsFlowHandler(config_entry) + return JewishCalendarOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -145,7 +147,7 @@ async def async_step_reconfigure( return self.async_update_reload_and_abort(reconfigure_entry, data=user_input) -class JewishCalendarOptionsFlowHandler(OptionsFlowWithConfigEntry): +class JewishCalendarOptionsFlowHandler(OptionsFlow): """Handle Jewish Calendar options.""" async def async_step_init( diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 986879e305888..74e738a0e04c5 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -12,7 +12,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.core import callback @@ -33,7 +33,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Set the config entry up from yaml.""" @@ -54,7 +54,7 @@ async def async_step_reauth_confirm( return self.async_abort(reason="reauth_successful") -class OptionsFlowHandler(OptionsFlowWithConfigEntry): +class OptionsFlowHandler(OptionsFlow): """Handle options.""" async def async_step_init( diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 43221eed58433..bcb55a19275eb 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -1,5 +1,7 @@ """Config flow for La Marzocco integration.""" +from __future__ import annotations + from collections.abc import Mapping import logging from typing import Any @@ -22,7 +24,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import ( CONF_HOST, @@ -339,12 +340,12 @@ async def async_step_reconfigure( @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> LmOptionsFlowHandler: """Create the options flow.""" - return LmOptionsFlowHandler(config_entry) + return LmOptionsFlowHandler() -class LmOptionsFlowHandler(OptionsFlowWithConfigEntry): +class LmOptionsFlowHandler(OptionsFlow): """Handles options flow for the component.""" async def async_step_init( diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index c6ea120242d61..d460792f7c8a9 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -11,7 +11,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_API_KEY from homeassistant.core import callback @@ -80,7 +80,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> LastFmOptionsFlowHandler: """Get the options flow for this handler.""" - return LastFmOptionsFlowHandler(config_entry) + return LastFmOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -155,7 +155,7 @@ async def async_step_friends( ) -class LastFmOptionsFlowHandler(OptionsFlowWithConfigEntry): +class LastFmOptionsFlowHandler(OptionsFlow): """LastFm Options flow handler.""" async def async_step_init( diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 84a446824131f..62964d22bb1a8 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -11,7 +11,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import ( CONF_ELEVATION, @@ -143,12 +142,12 @@ async def async_step_onboarding( @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> MetOptionsFlowHandler: """Get the options flow for Met.""" - return MetOptionsFlowHandler(config_entry) + return MetOptionsFlowHandler() -class MetOptionsFlowHandler(OptionsFlowWithConfigEntry): +class MetOptionsFlowHandler(OptionsFlow): """Options flow for Met component.""" async def async_step_init( @@ -159,13 +158,13 @@ async def async_step_init( if user_input is not None: # Update config entry with data from user input self.hass.config_entries.async_update_entry( - self._config_entry, data=user_input + self.config_entry, data=user_input ) return self.async_create_entry( - title=self._config_entry.title, data=user_input + title=self.config_entry.title, data=user_input ) return self.async_show_form( step_id="init", - data_schema=_get_data_schema(self.hass, config_entry=self._config_entry), + data_schema=_get_data_schema(self.hass, config_entry=self.config_entry), ) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index a217674e3b429..3ee0563410cbe 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -10,7 +10,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback @@ -100,12 +100,14 @@ async def async_step_user( @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OnewireOptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OnewireOptionsFlowHandler: """Get the options flow for this handler.""" - return OnewireOptionsFlowHandler(config_entry) + return OnewireOptionsFlowHandler() -class OnewireOptionsFlowHandler(OptionsFlowWithConfigEntry): +class OnewireOptionsFlowHandler(OptionsFlow): """Handle OneWire Config options.""" configurable_devices: dict[str, str] diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index 3cfd1ad30a078..f0f599628cb45 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -13,7 +13,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import ( CONF_LATITUDE, @@ -45,7 +45,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OpenSkyOptionsFlowHandler: """Get the options flow for this handler.""" - return OpenSkyOptionsFlowHandler(config_entry) + return OpenSkyOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -83,7 +83,7 @@ async def async_step_user( ) -class OpenSkyOptionsFlowHandler(OptionsFlowWithConfigEntry): +class OpenSkyOptionsFlowHandler(OptionsFlow): """OpenSky Options flow handler.""" async def async_step_init( diff --git a/homeassistant/components/pvpc_hourly_pricing/config_flow.py b/homeassistant/components/pvpc_hourly_pricing/config_flow.py index 67f9de458d0f6..af80c40b75b8b 100644 --- a/homeassistant/components/pvpc_hourly_pricing/config_flow.py +++ b/homeassistant/components/pvpc_hourly_pricing/config_flow.py @@ -13,7 +13,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_API_TOKEN, CONF_NAME from homeassistant.core import callback @@ -56,7 +56,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> PVPCOptionsFlowHandler: """Get the options flow for this handler.""" - return PVPCOptionsFlowHandler(config_entry) + return PVPCOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -178,7 +178,7 @@ async def async_step_reauth_confirm( return self.async_show_form(step_id="reauth_confirm", data_schema=data_schema) -class PVPCOptionsFlowHandler(OptionsFlowWithConfigEntry): +class PVPCOptionsFlowHandler(OptionsFlow): """Handle PVPC options.""" _power: float | None = None diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 06fbf3e717e25..e01bb904adfc4 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -24,7 +24,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_USERNAME from homeassistant.core import callback @@ -171,12 +170,12 @@ def _create_entry( @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> RoborockOptionsFlowHandler: """Create the options flow.""" - return RoborockOptionsFlowHandler(config_entry) + return RoborockOptionsFlowHandler() -class RoborockOptionsFlowHandler(OptionsFlowWithConfigEntry): +class RoborockOptionsFlowHandler(OptionsFlow): """Handle an option flow for Roborock.""" async def async_step_init( diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index 3ece9aff3f2ba..a99c475f51508 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -14,7 +14,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback @@ -165,12 +165,12 @@ async def async_step_discovery_confirm( @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlowWithConfigEntry: + ) -> RokuOptionsFlowHandler: """Create the options flow.""" - return RokuOptionsFlowHandler(config_entry) + return RokuOptionsFlowHandler() -class RokuOptionsFlowHandler(OptionsFlowWithConfigEntry): +class RokuOptionsFlowHandler(OptionsFlow): """Handle Roku options.""" async def async_step_init( diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index d0c29faca69d3..a53f0ac857f20 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -16,7 +16,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant, callback @@ -92,7 +92,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> RoombaOptionsFlowHandler: """Get the options flow for this handler.""" - return RoombaOptionsFlowHandler(config_entry) + return RoombaOptionsFlowHandler() async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo @@ -300,7 +300,7 @@ async def async_step_link_manual( ) -class RoombaOptionsFlowHandler(OptionsFlowWithConfigEntry): +class RoombaOptionsFlowHandler(OptionsFlow): """Handle options.""" async def async_step_init( diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 5537c7ff3b0ec..9f0614fae89c0 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -23,7 +23,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -144,7 +144,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> SQLOptionsFlowHandler: """Get the options flow for this handler.""" - return SQLOptionsFlowHandler(config_entry) + return SQLOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -209,7 +209,7 @@ async def async_step_user( ) -class SQLOptionsFlowHandler(OptionsFlowWithConfigEntry): +class SQLOptionsFlowHandler(OptionsFlow): """Handle SQL options.""" async def async_step_init( diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index a9eefd09b9b99..b3b8180a08dc3 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -21,7 +21,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant, callback @@ -132,7 +132,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> TVTrainOptionsFlowHandler: """Get the options flow for this handler.""" - return TVTrainOptionsFlowHandler(config_entry) + return TVTrainOptionsFlowHandler() async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -229,7 +229,7 @@ async def async_step_user( ) -class TVTrainOptionsFlowHandler(OptionsFlowWithConfigEntry): +class TVTrainOptionsFlowHandler(OptionsFlow): """Handle Trafikverket Train options.""" async def async_step_init( diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 1a40d4b34425f..5f1fdbee88ff2 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -16,7 +16,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.core import HomeAssistant, callback @@ -94,9 +93,11 @@ class UpnpFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> UpnpOptionsFlowHandler: """Get the options flow for this handler.""" - return UpnpOptionsFlowHandler(config_entry) + return UpnpOptionsFlowHandler() @property def _discoveries(self) -> dict[str, SsdpServiceInfo]: @@ -299,7 +300,7 @@ async def _async_create_entry_from_discovery( return self.async_create_entry(title=title, data=data, options=options) -class UpnpOptionsFlowHandler(OptionsFlowWithConfigEntry): +class UpnpOptionsFlowHandler(OptionsFlow): """Handle an options flow.""" async def async_step_init( diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index c373520bc58a2..288ebeb9a074a 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -17,7 +17,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -63,9 +62,11 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> VodafoneStationOptionsFlowHandler: """Get the options flow for this handler.""" - return VodafoneStationOptionsFlowHandler(config_entry) + return VodafoneStationOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -143,7 +144,7 @@ async def async_step_reauth_confirm( ) -class VodafoneStationOptionsFlowHandler(OptionsFlowWithConfigEntry): +class VodafoneStationOptionsFlowHandler(OptionsFlow): """Handle a option flow.""" async def async_step_init( diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 2798e0d46d1ab..67f2f60d13ecc 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -12,7 +12,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback @@ -30,9 +30,11 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> WLEDOptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> WLEDOptionsFlowHandler: """Get the options flow for this handler.""" - return WLEDOptionsFlowHandler(config_entry) + return WLEDOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -117,7 +119,7 @@ async def _async_get_device(self, host: str) -> Device: return await wled.update() -class WLEDOptionsFlowHandler(OptionsFlowWithConfigEntry): +class WLEDOptionsFlowHandler(OptionsFlow): """Handle WLED options.""" async def async_step_init( diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 2552fe849e26c..759cc13aecffc 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -12,7 +12,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import callback @@ -219,7 +219,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> WorkdayOptionsFlowHandler: """Get the options flow for this handler.""" - return WorkdayOptionsFlowHandler(config_entry) + return WorkdayOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -310,7 +310,7 @@ async def async_step_options( ) -class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): +class WorkdayOptionsFlowHandler(OptionsFlow): """Handle Workday options.""" async def async_step_init( @@ -340,7 +340,7 @@ async def async_step_init( else: LOGGER.debug("abort_check in options with %s", combined_input) abort_match = { - CONF_COUNTRY: self._config_entry.options.get(CONF_COUNTRY), + CONF_COUNTRY: self.config_entry.options.get(CONF_COUNTRY), CONF_EXCLUDES: combined_input[CONF_EXCLUDES], CONF_OFFSET: combined_input[CONF_OFFSET], CONF_WORKDAYS: combined_input[CONF_WORKDAYS], diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index 8d6c77532829c..d03beffdb4953 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -15,7 +15,7 @@ SOURCE_REAUTH, ConfigEntry, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import callback @@ -54,7 +54,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> YouTubeOptionsFlowHandler: """Get the options flow for this handler.""" - return YouTubeOptionsFlowHandler(config_entry) + return YouTubeOptionsFlowHandler() @property def logger(self) -> logging.Logger: @@ -159,7 +159,7 @@ async def async_step_channels( ) -class YouTubeOptionsFlowHandler(OptionsFlowWithConfigEntry): +class YouTubeOptionsFlowHandler(OptionsFlow): """YouTube Options flow handler.""" async def async_step_init( From c2ef119e504fe17482811e67d882dd6ffbf08df5 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sun, 3 Nov 2024 16:38:52 -0600 Subject: [PATCH 0068/1070] Add HassRespond intent (#129755) * Add HassHello intent * Rename to HassRespond * LLM's ignore HassRespond intent --- homeassistant/components/intent/__init__.py | 14 +++++++++++++- homeassistant/helpers/intent.py | 1 + homeassistant/helpers/llm.py | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 85fdf5c88c36e..1322576f11522 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -137,6 +137,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.async_register(hass, TimerStatusIntentHandler()) intent.async_register(hass, GetCurrentDateIntentHandler()) intent.async_register(hass, GetCurrentTimeIntentHandler()) + intent.async_register(hass, HelloIntentHandler()) return True @@ -364,7 +365,7 @@ class NevermindIntentHandler(intent.IntentHandler): description = "Cancels the current request and does nothing" async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: - """Doe not do anything, and produces an empty response.""" + """Do nothing and produces an empty response.""" return intent_obj.create_response() @@ -420,6 +421,17 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse return response +class HelloIntentHandler(intent.IntentHandler): + """Responds with no action.""" + + intent_type = intent.INTENT_RESPOND + description = "Returns the provided response with no action." + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Return the provided response, but take no action.""" + return intent_obj.create_response() + + async def _async_process_intent( hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol ) -> None: diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 6bd02b8660a2f..b38f769b302d8 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -56,6 +56,7 @@ INTENT_TIMER_STATUS = "HassTimerStatus" INTENT_GET_CURRENT_DATE = "HassGetCurrentDate" INTENT_GET_CURRENT_TIME = "HassGetCurrentTime" +INTENT_RESPOND = "HassRespond" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 39dff04fb7c2d..d322810b0ef8e 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -279,6 +279,7 @@ class AssistAPI(API): intent.INTENT_TOGGLE, intent.INTENT_GET_CURRENT_DATE, intent.INTENT_GET_CURRENT_TIME, + intent.INTENT_RESPOND, } def __init__(self, hass: HomeAssistant) -> None: From f11aba96486743ca4e8ab40c4d430b840d649a05 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 00:25:37 +0100 Subject: [PATCH 0069/1070] Fix flaky tests in advantage_air (#129758) --- .../advantage_air/test_binary_sensor.py | 52 ++++++------------- tests/components/advantage_air/test_sensor.py | 24 +++------ 2 files changed, 24 insertions(+), 52 deletions(-) diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index 7a7b2f8df5b9e..d0088d96ba565 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -1,10 +1,8 @@ """Test the Advantage Air Binary Sensor Platform.""" from datetime import timedelta -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch -from homeassistant.components.advantage_air import ADVANTAGE_AIR_SYNC_INTERVAL -from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -70,22 +68,14 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) mock_get.reset_mock() - entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) - await hass.async_block_till_done() - - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1), - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 1 - - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 3 + + with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1): + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_get.mock_calls) == 1 state = hass.states.get(entity_id) assert state @@ -101,22 +91,14 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) mock_get.reset_mock() - entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) - await hass.async_block_till_done() - - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1), - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 1 - - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 3 + + with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1): + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_get.mock_calls) == 1 state = hass.states.get(entity_id) assert state diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index 4389e67228a60..3ea368a59fbdb 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -1,15 +1,13 @@ """Test the Advantage Air Sensor Platform.""" from datetime import timedelta -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch -from homeassistant.components.advantage_air import ADVANTAGE_AIR_SYNC_INTERVAL from homeassistant.components.advantage_air.const import DOMAIN as ADVANTAGE_AIR_DOMAIN from homeassistant.components.advantage_air.sensor import ( ADVANTAGE_AIR_SERVICE_SET_TIME_TO, ADVANTAGE_AIR_SET_COUNTDOWN_VALUE, ) -from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -124,23 +122,15 @@ async def test_sensor_platform_disabled_entity( assert not hass.states.get(entity_id) - entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) - await hass.async_block_till_done(wait_background_tasks=True) mock_get.reset_mock() - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1), - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 1 + with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1): + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) + await hass.async_block_till_done(wait_background_tasks=True) - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 3 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_get.mock_calls) == 1 state = hass.states.get(entity_id) assert state From a05a34239d3898876afe7c347b15a065a492a77e Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sun, 3 Nov 2024 15:27:27 -0800 Subject: [PATCH 0070/1070] Show NUT device serial number if provided in Device Info (#124168) --- homeassistant/components/nut/__init__.py | 5 ++++- homeassistant/components/nut/sensor.py | 2 ++ tests/components/nut/test_init.py | 26 +++++++++++++++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index c9b2bcc13b2a4..6bbe19e8f3c1e 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -131,6 +131,7 @@ async def async_update_data() -> dict[str, str]: manufacturer=data.device_info.manufacturer, model=data.device_info.model, sw_version=data.device_info.firmware, + serial_number=data.device_info.serial, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -209,6 +210,7 @@ class NUTDeviceInfo: manufacturer: str | None = None model: str | None = None firmware: str | None = None + serial: str | None = None class PyNUTData: @@ -268,7 +270,8 @@ def _get_device_info(self) -> NUTDeviceInfo | None: manufacturer = _manufacturer_from_status(self._status) model = _model_from_status(self._status) firmware = _firmware_from_status(self._status) - return NUTDeviceInfo(manufacturer, model, firmware) + serial = _serial_from_status(self._status) + return NUTDeviceInfo(manufacturer, model, firmware, serial) async def _async_get_status(self) -> dict[str, str]: """Get the ups status from NUT.""" diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 7f211d5452b0e..bb70287305224 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( ATTR_MANUFACTURER, ATTR_MODEL, + ATTR_SERIAL_NUMBER, ATTR_SW_VERSION, PERCENTAGE, STATE_UNKNOWN, @@ -42,6 +43,7 @@ "manufacturer": ATTR_MANUFACTURER, "model": ATTR_MODEL, "firmware": ATTR_SW_VERSION, + "serial": ATTR_SERIAL_NUMBER, } _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index 61a5187407ba2..cd56c209a368e 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -8,8 +8,9 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr -from .util import _get_mock_nutclient +from .util import _get_mock_nutclient, async_init_integration from tests.common import MockConfigEntry @@ -96,3 +97,26 @@ async def test_auth_fails(hass: HomeAssistant) -> None: flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["context"]["source"] == "reauth" + + +async def test_serial_number(hass: HomeAssistant) -> None: + """Test for serial number set on device.""" + mock_serial_number = "A00000000000" + await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={"ups.serial": mock_serial_number}, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value=[], + ) + + device_registry = dr.async_get(hass) + assert device_registry is not None + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_serial_number)} + ) + + assert device_entry is not None + assert device_entry.serial_number == mock_serial_number From 87ab2beddff0063ad9bce2b3d998cf18df95300f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 4 Nov 2024 13:16:49 +1300 Subject: [PATCH 0071/1070] Only set ESPHome configuration url to addon if there is an existing configuration for the device (#129356) Co-authored-by: J. Nick Koston --- homeassistant/components/esphome/manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index c36a55d1f5500..afbe109d5bc4a 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -570,7 +570,9 @@ def _async_setup_device_registry( configuration_url = None if device_info.webserver_port > 0: configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" - elif dashboard := async_get_dashboard(hass): + elif (dashboard := async_get_dashboard(hass)) and dashboard.data.get( + device_info.name + ): configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}" manufacturer = "espressif" From 38afcbb21ff2ce6f134612245ac3c64ac22e9296 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 4 Nov 2024 04:56:45 +0100 Subject: [PATCH 0072/1070] Bump python-linkplay to 0.0.17 (#129683) --- homeassistant/components/linkplay/manifest.json | 2 +- homeassistant/components/linkplay/media_player.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index dd1e08eda49b0..f2b2e2da00c19 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.0.15"], + "requirements": ["python-linkplay==0.0.17"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 5e667af37adb9..36834610c04a6 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -48,6 +48,7 @@ } SOURCE_MAP: dict[PlayingMode, str] = { + PlayingMode.NETWORK: "Wifi", PlayingMode.LINE_IN: "Line In", PlayingMode.BLUETOOTH: "Bluetooth", PlayingMode.OPTICAL: "Optical", diff --git a/requirements_all.txt b/requirements_all.txt index 02c6853edae97..b200ce519d73b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2359,7 +2359,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.6 # homeassistant.components.linkplay -python-linkplay==0.0.15 +python-linkplay==0.0.17 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21040bf22ca78..9294cc5f32d6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1886,7 +1886,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.6 # homeassistant.components.linkplay -python-linkplay==0.0.15 +python-linkplay==0.0.17 # homeassistant.components.matter python-matter-server==6.6.0 From 49f0bb6990903ac49b6680ebe568ccef38be832a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 4 Nov 2024 05:30:21 +0100 Subject: [PATCH 0073/1070] Bump plugwise to v1.5.0 (#129668) * Bump plugwise to v1.5.0 * And adapt --- homeassistant/components/plugwise/config_flow.py | 1 - homeassistant/components/plugwise/coordinator.py | 1 - homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index b0d68aaa33be9..57abb1ccb863f 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -71,7 +71,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> Smile: password=data[CONF_PASSWORD], port=data[CONF_PORT], username=data[CONF_USERNAME], - timeout=30, websession=websession, ) await api.connect() diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index da2ef810d3530..b897a8bf8336e 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -54,7 +54,6 @@ def __init__(self, hass: HomeAssistant) -> None: username=self.config_entry.data.get(CONF_USERNAME, DEFAULT_USERNAME), password=self.config_entry.data[CONF_PASSWORD], port=self.config_entry.data.get(CONF_PORT, DEFAULT_PORT), - timeout=30, websession=async_get_clientsession(hass, verify_ssl=False), ) self._current_devices: set[str] = set() diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index a4253a30cb58c..dbbad15c0dca7 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], - "requirements": ["plugwise==1.4.4"], + "requirements": ["plugwise==1.5.0"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b200ce519d73b..27413878f25a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1619,7 +1619,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.4.4 +plugwise==1.5.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9294cc5f32d6a..ede9e480345b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1326,7 +1326,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.4.4 +plugwise==1.5.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From 6718cce203fbfb2566bca1c5ee7c894cf727502b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 3 Nov 2024 20:45:09 -0800 Subject: [PATCH 0074/1070] Fix nest streams broken due to CameraCapabilities change (#129711) * Fix nest streams broken due to CameraCapabilities change * Fix stream cleanup * Apply suggestions from code review Co-authored-by: Paulus Schoutsen * Update homeassistant/components/nest/camera.py --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/nest/camera.py | 230 +++++++++++---------- tests/components/nest/test_camera.py | 79 ++++--- tests/components/nest/test_media_source.py | 7 +- 3 files changed, 181 insertions(+), 135 deletions(-) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 737c0a77bede1..30f96f819c199 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -2,19 +2,17 @@ from __future__ import annotations +from abc import ABC, abstractmethod import asyncio from collections.abc import Callable import datetime import functools import logging from pathlib import Path -from typing import cast from google_nest_sdm.camera_traits import ( - CameraImageTrait, CameraLiveStreamTrait, RtspStream, - Stream, StreamingProtocol, WebRtcStream, ) @@ -57,19 +55,25 @@ async def async_setup_entry( device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ DATA_DEVICE_MANAGER ] - async_add_entities( - NestCamera(device) - for device in device_manager.devices.values() - if CameraImageTrait.NAME in device.traits - or CameraLiveStreamTrait.NAME in device.traits - ) + entities: list[NestCameraBaseEntity] = [] + for device in device_manager.devices.values(): + if (live_stream := device.traits.get(CameraLiveStreamTrait.NAME)) is None: + continue + if StreamingProtocol.WEB_RTC in live_stream.supported_protocols: + entities.append(NestWebRTCEntity(device)) + elif StreamingProtocol.RTSP in live_stream.supported_protocols: + entities.append(NestRTSPEntity(device)) + async_add_entities(entities) -class NestCamera(Camera): + +class NestCameraBaseEntity(Camera, ABC): """Devices that support cameras.""" _attr_has_entity_name = True _attr_name = None + _attr_is_streaming = True + _attr_supported_features = CameraEntityFeature.STREAM def __init__(self, device: Device) -> None: """Initialize the camera.""" @@ -79,39 +83,74 @@ def __init__(self, device: Device) -> None: self._attr_device_info = nest_device_info.device_info self._attr_brand = nest_device_info.device_brand self._attr_model = nest_device_info.device_model - self._rtsp_stream: RtspStream | None = None - self._webrtc_sessions: dict[str, WebRtcStream] = {} - self._create_stream_url_lock = asyncio.Lock() - self._stream_refresh_unsub: Callable[[], None] | None = None - self._attr_is_streaming = False - self._attr_supported_features = CameraEntityFeature(0) - self._rtsp_live_stream_trait: CameraLiveStreamTrait | None = None - if CameraLiveStreamTrait.NAME in self._device.traits: - self._attr_is_streaming = True - self._attr_supported_features |= CameraEntityFeature.STREAM - trait = cast( - CameraLiveStreamTrait, self._device.traits[CameraLiveStreamTrait.NAME] - ) - if StreamingProtocol.RTSP in trait.supported_protocols: - self._rtsp_live_stream_trait = trait self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 # The API "name" field is a unique device identifier. self._attr_unique_id = f"{self._device.name}-camera" + self._stream_refresh_unsub: Callable[[], None] | None = None - @property - def use_stream_for_stills(self) -> bool: - """Whether or not to use stream to generate stills.""" - return self._rtsp_live_stream_trait is not None + @abstractmethod + def _stream_expires_at(self) -> datetime.datetime | None: + """Next time when a stream expires.""" + + @abstractmethod + async def _async_refresh_stream(self) -> None: + """Refresh any stream to extend expiration time.""" + + def _schedule_stream_refresh(self) -> None: + """Schedules an alarm to refresh any streams before expiration.""" + if self._stream_refresh_unsub is not None: + self._stream_refresh_unsub() + + expiration_time = self._stream_expires_at() + if not expiration_time: + return + refresh_time = expiration_time - STREAM_EXPIRATION_BUFFER + _LOGGER.debug("Scheduled next stream refresh for %s", refresh_time) + + self._stream_refresh_unsub = async_track_point_in_utc_time( + self.hass, + self._handle_stream_refresh, + refresh_time, + ) + + async def _handle_stream_refresh(self, _: datetime.datetime) -> None: + """Alarm that fires to check if the stream should be refreshed.""" + _LOGGER.debug("Examining streams to refresh") + self._stream_refresh_unsub = None + try: + await self._async_refresh_stream() + finally: + self._schedule_stream_refresh() + + async def async_added_to_hass(self) -> None: + """Run when entity is added to register update signal handler.""" + self.async_on_remove( + self._device.add_update_listener(self.async_write_ha_state) + ) + + async def async_will_remove_from_hass(self) -> None: + """Invalidates the RTSP token when unloaded.""" + await super().async_will_remove_from_hass() + if self._stream_refresh_unsub: + self._stream_refresh_unsub() + + +class NestRTSPEntity(NestCameraBaseEntity): + """Nest cameras that use RTSP.""" + + _rtsp_stream: RtspStream | None = None + _rtsp_live_stream_trait: CameraLiveStreamTrait + + def __init__(self, device: Device) -> None: + """Initialize the camera.""" + super().__init__(device) + self._create_stream_url_lock = asyncio.Lock() + self._rtsp_live_stream_trait = device.traits[CameraLiveStreamTrait.NAME] @property - def frontend_stream_type(self) -> StreamType | None: - """Return the type of stream supported by this camera.""" - if CameraLiveStreamTrait.NAME not in self._device.traits: - return None - trait = self._device.traits[CameraLiveStreamTrait.NAME] - if StreamingProtocol.WEB_RTC in trait.supported_protocols: - return StreamType.WEB_RTC - return super().frontend_stream_type + def use_stream_for_stills(self) -> bool: + """Always use the RTSP stream to generate snapshots.""" + return True @property def available(self) -> bool: @@ -125,8 +164,6 @@ def available(self) -> bool: async def stream_source(self) -> str | None: """Return the source of the stream.""" - if not self._rtsp_live_stream_trait: - return None async with self._create_stream_url_lock: if not self._rtsp_stream: _LOGGER.debug("Fetching stream url") @@ -142,50 +179,14 @@ async def stream_source(self) -> str | None: _LOGGER.warning("Stream already expired") return self._rtsp_stream.rtsp_stream_url - def _all_streams(self) -> list[Stream]: - """Return the current list of active streams.""" - streams: list[Stream] = [] - if self._rtsp_stream: - streams.append(self._rtsp_stream) - streams.extend(list(self._webrtc_sessions.values())) - return streams + def _stream_expires_at(self) -> datetime.datetime | None: + """Next time when a stream expires.""" + return self._rtsp_stream.expires_at if self._rtsp_stream else None - def _schedule_stream_refresh(self) -> None: - """Schedules an alarm to refresh any streams before expiration.""" - # Schedule an alarm to extend the stream - if self._stream_refresh_unsub is not None: - self._stream_refresh_unsub() - - _LOGGER.debug("Scheduling next stream refresh") - expiration_times = [stream.expires_at for stream in self._all_streams()] - if not expiration_times: - _LOGGER.debug("No streams to refresh") - return - - refresh_time = min(expiration_times) - STREAM_EXPIRATION_BUFFER - _LOGGER.debug("Scheduled next stream refresh for %s", refresh_time) - - self._stream_refresh_unsub = async_track_point_in_utc_time( - self.hass, - self._handle_stream_refresh, - refresh_time, - ) - - async def _handle_stream_refresh(self, _: datetime.datetime) -> None: - """Alarm that fires to check if the stream should be refreshed.""" - _LOGGER.debug("Examining streams to refresh") - await self._handle_rtsp_stream_refresh() - await self._handle_webrtc_stream_refresh() - self._schedule_stream_refresh() - - async def _handle_rtsp_stream_refresh(self) -> None: - """Alarm that fires to check if the stream should be refreshed.""" + async def _async_refresh_stream(self) -> None: + """Refresh stream to extend expiration time.""" if not self._rtsp_stream: return - now = utcnow() - refresh_time = self._rtsp_stream.expires_at - STREAM_EXPIRATION_BUFFER - if now < refresh_time: - return _LOGGER.debug("Extending RTSP stream") try: self._rtsp_stream = await self._rtsp_stream.extend_rtsp_stream() @@ -201,8 +202,38 @@ async def _handle_rtsp_stream_refresh(self) -> None: if self.stream: self.stream.update_source(self._rtsp_stream.rtsp_stream_url) - async def _handle_webrtc_stream_refresh(self) -> None: - """Alarm that fires to check if the stream should be refreshed.""" + async def async_will_remove_from_hass(self) -> None: + """Invalidates the RTSP token when unloaded.""" + await super().async_will_remove_from_hass() + if self._rtsp_stream: + try: + await self._rtsp_stream.stop_stream() + except ApiException as err: + _LOGGER.debug("Error stopping stream: %s", err) + self._rtsp_stream = None + + +class NestWebRTCEntity(NestCameraBaseEntity): + """Nest cameras that use WebRTC.""" + + def __init__(self, device: Device) -> None: + """Initialize the camera.""" + super().__init__(device) + self._webrtc_sessions: dict[str, WebRtcStream] = {} + + @property + def frontend_stream_type(self) -> StreamType | None: + """Return the type of stream supported by this camera.""" + return StreamType.WEB_RTC + + def _stream_expires_at(self) -> datetime.datetime | None: + """Next time when a stream expires.""" + if not self._webrtc_sessions: + return None + return min(stream.expires_at for stream in self._webrtc_sessions.values()) + + async def _async_refresh_stream(self) -> None: + """Refresh stream to extend expiration time.""" now = utcnow() for webrtc_stream in list(self._webrtc_sessions.values()): if now < (webrtc_stream.expires_at - STREAM_EXPIRATION_BUFFER): @@ -218,32 +249,10 @@ async def _handle_webrtc_stream_refresh(self) -> None: else: self._webrtc_sessions[webrtc_stream.media_session_id] = webrtc_stream - async def async_will_remove_from_hass(self) -> None: - """Invalidates the RTSP token when unloaded.""" - for stream in self._all_streams(): - _LOGGER.debug("Invalidating stream") - try: - await stream.stop_stream() - except ApiException as err: - _LOGGER.debug("Error stopping stream: %s", err) - self._rtsp_stream = None - self._webrtc_sessions.clear() - - if self._stream_refresh_unsub: - self._stream_refresh_unsub() - - async def async_added_to_hass(self) -> None: - """Run when entity is added to register update signal handler.""" - self.async_on_remove( - self._device.add_update_listener(self.async_write_ha_state) - ) - async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: - """Return bytes of camera image.""" - # Use the thumbnail from RTSP stream, or a placeholder if stream is - # not supported (e.g. WebRTC) as a fallback when 'use_stream_for_stills' if False + """Return a placeholder image for WebRTC cameras that don't support snapshots.""" return await self.hass.async_add_executor_job(self.placeholder_image) @classmethod @@ -257,11 +266,6 @@ async def async_handle_async_webrtc_offer( ) -> None: """Return the source of the stream.""" trait: CameraLiveStreamTrait = self._device.traits[CameraLiveStreamTrait.NAME] - if StreamingProtocol.WEB_RTC not in trait.supported_protocols: - await super().async_handle_async_webrtc_offer( - offer_sdp, session_id, send_message - ) - return try: stream = await trait.generate_web_rtc_stream(offer_sdp) except ApiException as err: @@ -294,3 +298,9 @@ async def stop_stream() -> None: def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration: """Return the WebRTC client configuration adjustable per integration.""" return WebRTCClientConfiguration(data_channel="dataSendChannel") + + async def async_will_remove_from_hass(self) -> None: + """Invalidates the RTSP token when unloaded.""" + await super().async_will_remove_from_hass() + for session_id in list(self._webrtc_sessions.keys()): + self.close_webrtc_session(session_id) diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 6417fa4ebe913..500dbc0f46f05 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -28,7 +28,7 @@ from .conftest import FakeAuth from tests.common import async_fire_time_changed -from tests.typing import WebSocketGenerator +from tests.typing import MockHAClientWebSocket, WebSocketGenerator PLATFORM = "camera" CAMERA_DEVICE_TYPE = "sdm.devices.types.CAMERA" @@ -176,6 +176,30 @@ async def async_get_image( return image.content +def get_frontend_stream_type_attribute( + hass: HomeAssistant, entity_id: str +) -> StreamType: + """Get the frontend_stream_type camera attribute.""" + cam = hass.states.get(entity_id) + assert cam is not None + assert cam.state == CameraState.STREAMING + return cam.attributes.get("frontend_stream_type") + + +async def async_frontend_stream_types( + client: MockHAClientWebSocket, entity_id: str +) -> list[str] | None: + """Get the frontend stream types supported.""" + await client.send_json_auto_id( + {"type": "camera/capabilities", "entity_id": entity_id} + ) + msg = await client.receive_json() + assert msg.get("type") == TYPE_RESULT + assert msg.get("success") + assert msg.get("result") + return msg["result"].get("frontend_stream_types") + + async def fire_alarm(hass: HomeAssistant, point_in_time: datetime.datetime) -> None: """Fire an alarm and wait for callbacks to run.""" with freeze_time(point_in_time): @@ -237,16 +261,21 @@ async def test_camera_stream( camera_device: None, auth: FakeAuth, mock_create_stream: Mock, + hass_ws_client: WebSocketGenerator, ) -> None: """Test a basic camera and fetch its live stream.""" auth.responses = [make_stream_url_response()] await setup_platform() assert len(hass.states.async_all()) == 1 - cam = hass.states.get("camera.my_camera") - assert cam is not None - assert cam.state == CameraState.STREAMING - assert cam.attributes["frontend_stream_type"] == StreamType.HLS + assert ( + get_frontend_stream_type_attribute(hass, "camera.my_camera") == StreamType.HLS + ) + client = await hass_ws_client(hass) + frontend_stream_types = await async_frontend_stream_types( + client, "camera.my_camera" + ) + assert frontend_stream_types == [StreamType.HLS] stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") assert stream_source == "rtsp://some/url?auth=g.0.streamingToken" @@ -265,12 +294,16 @@ async def test_camera_ws_stream( await setup_platform() assert len(hass.states.async_all()) == 1 - cam = hass.states.get("camera.my_camera") - assert cam is not None - assert cam.state == CameraState.STREAMING - assert cam.attributes["frontend_stream_type"] == StreamType.HLS + assert ( + get_frontend_stream_type_attribute(hass, "camera.my_camera") == StreamType.HLS + ) client = await hass_ws_client(hass) + frontend_stream_types = await async_frontend_stream_types( + client, "camera.my_camera" + ) + assert frontend_stream_types == [StreamType.HLS] + await client.send_json( { "id": 2, @@ -322,7 +355,7 @@ async def test_camera_ws_stream_failure( async def test_camera_stream_missing_trait( hass: HomeAssistant, setup_platform, create_device ) -> None: - """Test fetching a video stream when not supported by the API.""" + """Test that cameras missing a live stream are not supported.""" create_device.create( { "sdm.devices.traits.Info": { @@ -338,16 +371,7 @@ async def test_camera_stream_missing_trait( ) await setup_platform() - assert len(hass.states.async_all()) == 1 - cam = hass.states.get("camera.my_camera") - assert cam is not None - assert cam.state == CameraState.IDLE - - stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") - assert stream_source is None - - # Fallback to placeholder image - await async_get_image(hass) + assert len(hass.states.async_all()) == 0 async def test_refresh_expired_stream_token( @@ -655,6 +679,15 @@ async def test_camera_web_rtc_unsupported( assert cam.attributes["frontend_stream_type"] == StreamType.HLS client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "camera/capabilities", "entity_id": "camera.my_camera"} + ) + msg = await client.receive_json() + + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == {"frontend_stream_types": ["hls"]} + await client.send_json_auto_id( { "type": "camera/webrtc/offer", @@ -732,8 +765,6 @@ async def test_camera_multiple_streams( """Test a camera supporting multiple stream types.""" expiration = utcnow() + datetime.timedelta(seconds=100) auth.responses = [ - # RTSP response - make_stream_url_response(), # WebRTC response aiohttp.web.json_response( { @@ -770,9 +801,9 @@ async def test_camera_multiple_streams( # Prefer WebRTC over RTSP/HLS assert cam.attributes["frontend_stream_type"] == StreamType.WEB_RTC - # RTSP stream + # RTSP stream is not supported stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") - assert stream_source == "rtsp://some/url?auth=g.0.streamingToken" + assert not stream_source # WebRTC stream client = await hass_ws_client(hass) diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 101bfae089d3e..2526bfdf975f6 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -48,6 +48,9 @@ "customName": DEVICE_NAME, }, "sdm.devices.traits.CameraImage": {}, + "sdm.devices.traits.CameraLiveStream": { + "supportedProtocols": ["RTSP"], + }, "sdm.devices.traits.CameraEventImage": {}, "sdm.devices.traits.CameraPerson": {}, "sdm.devices.traits.CameraMotion": {}, @@ -57,7 +60,9 @@ "customName": DEVICE_NAME, }, "sdm.devices.traits.CameraClipPreview": {}, - "sdm.devices.traits.CameraLiveStream": {}, + "sdm.devices.traits.CameraLiveStream": { + "supportedProtocols": ["WEB_RTC"], + }, "sdm.devices.traits.CameraPerson": {}, "sdm.devices.traits.CameraMotion": {}, } From 04aee812f87c164c5bc4019a56bed81014ebbc10 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Mon, 4 Nov 2024 15:17:50 +0900 Subject: [PATCH 0075/1070] Bump thinqconnect to 1.0.0 (#129769) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index 52eb3c31aef8c..665a5a9e17953 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==0.9.9"] + "requirements": ["thinqconnect==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 27413878f25a1..bad52c5b87e90 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2828,7 +2828,7 @@ thermopro-ble==0.10.0 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==0.9.9 +thinqconnect==1.0.0 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ede9e480345b5..3917267e6617f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2250,7 +2250,7 @@ thermobeacon-ble==0.7.0 thermopro-ble==0.10.0 # homeassistant.components.lg_thinq -thinqconnect==0.9.9 +thinqconnect==1.0.0 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From eda36512ec909bed9fc2111c4bc04ae70deb9092 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sun, 3 Nov 2024 23:49:48 -0700 Subject: [PATCH 0076/1070] Change alexa arm handler to allow switching arm states unless in armed_away mode (#129701) * Change alexa arm handler to allow switching arm states unless in armed_away mode * Address PR comments --- homeassistant/components/alexa/handlers.py | 8 +- tests/components/alexa/test_smart_home.py | 102 +++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index d2f6c292e6fde..8ea61ddbceb10 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1083,7 +1083,13 @@ async def async_api_arm( arm_state = directive.payload["armState"] data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} - if entity.state != alarm_control_panel.AlarmControlPanelState.DISARMED: + # Per Alexa Documentation: users are not allowed to switch from armed_away + # directly to another armed state without first disarming the system. + # https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-securitypanelcontroller.html#arming + if ( + entity.state == alarm_control_panel.AlarmControlPanelState.ARMED_AWAY + and arm_state != "ARMED_AWAY" + ): msg = "You must disarm the system before you can set the requested arm state." raise AlexaSecurityPanelAuthorizationRequired(msg) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 4ae78421596da..68010a6a7111c 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -3999,6 +3999,108 @@ async def test_alarm_control_panel_code_arm_required(hass: HomeAssistant) -> Non await discovery_test(device, hass, expected_endpoints=0) +async def test_alarm_control_panel_disarm_required(hass: HomeAssistant) -> None: + """Test alarm_control_panel disarm required.""" + device = ( + "alarm_control_panel.test_4", + "armed_away", + { + "friendly_name": "Test Alarm Control Panel 4", + "code_arm_required": False, + "code_format": "FORMAT_NUMBER", + "code": "1234", + "supported_features": 3, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "alarm_control_panel#test_4" + assert appliance["displayCategories"][0] == "SECURITY_PANEL" + assert appliance["friendlyName"] == "Test Alarm Control Panel 4" + assert_endpoint_capabilities( + appliance, "Alexa.SecurityPanelController", "Alexa.EndpointHealth", "Alexa" + ) + + properties = await reported_properties(hass, "alarm_control_panel#test_4") + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_AWAY") + + msg = await assert_request_fails( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_4", + "alarm_control_panel.alarm_arm_home", + hass, + payload={"armState": "ARMED_STAY"}, + ) + assert msg["event"]["payload"]["type"] == "AUTHORIZATION_REQUIRED" + assert ( + msg["event"]["payload"]["message"] + == "You must disarm the system before you can set the requested arm state." + ) + + _, msg = await assert_request_calls_service( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_4", + "alarm_control_panel.alarm_arm_away", + hass, + response_type="Arm.Response", + payload={"armState": "ARMED_AWAY"}, + ) + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_AWAY") + + +async def test_alarm_control_panel_change_arm_type(hass: HomeAssistant) -> None: + """Test alarm_control_panel change arm type.""" + device = ( + "alarm_control_panel.test_5", + "armed_home", + { + "friendly_name": "Test Alarm Control Panel 5", + "code_arm_required": False, + "code_format": "FORMAT_NUMBER", + "code": "1234", + "supported_features": 3, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "alarm_control_panel#test_5" + assert appliance["displayCategories"][0] == "SECURITY_PANEL" + assert appliance["friendlyName"] == "Test Alarm Control Panel 5" + assert_endpoint_capabilities( + appliance, "Alexa.SecurityPanelController", "Alexa.EndpointHealth", "Alexa" + ) + + properties = await reported_properties(hass, "alarm_control_panel#test_5") + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_STAY") + + _, msg = await assert_request_calls_service( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_5", + "alarm_control_panel.alarm_arm_home", + hass, + response_type="Arm.Response", + payload={"armState": "ARMED_STAY"}, + ) + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_STAY") + + _, msg = await assert_request_calls_service( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_5", + "alarm_control_panel.alarm_arm_away", + hass, + response_type="Arm.Response", + payload={"armState": "ARMED_AWAY"}, + ) + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_AWAY") + + async def test_range_unsupported_domain(hass: HomeAssistant) -> None: """Test rangeController with unsupported domain.""" device = ("switch.test", "on", {"friendly_name": "Test switch"}) From 7ab8ff56b31e4a6a96fb80cb64e0e9039ffb2e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Mon, 4 Nov 2024 08:11:18 +0100 Subject: [PATCH 0077/1070] Bump Airthings BLE to 0.9.2 (#129659) Bump airthings ble --- homeassistant/components/airthings_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index 6c00fe79e7bd3..fe2cc0eeb36b0 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", "iot_class": "local_polling", - "requirements": ["airthings-ble==0.9.1"] + "requirements": ["airthings-ble==0.9.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index bad52c5b87e90..8e05edf10dc8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -426,7 +426,7 @@ airgradient==0.9.1 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.9.1 +airthings-ble==0.9.2 # homeassistant.components.airthings airthings-cloud==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3917267e6617f..6479de6cd7dee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -408,7 +408,7 @@ airgradient==0.9.1 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.9.1 +airthings-ble==0.9.2 # homeassistant.components.airthings airthings-cloud==0.2.0 From 595459bfda1bd8d4b7080050022f888e49e113f7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:34:20 +0100 Subject: [PATCH 0078/1070] Use new helper properties in rfxtrx options flow (#129784) --- .../components/rfxtrx/config_flow.py | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index ceb9bea46615c..866d9ecb1bb21 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -87,9 +87,8 @@ class RfxtrxOptionsFlow(OptionsFlow): _device_registry: dr.DeviceRegistry _device_entries: list[dr.DeviceEntry] - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize rfxtrx options flow.""" - self._config_entry = config_entry self._global_options: dict[str, Any] = {} self._selected_device: dict[str, Any] = {} self._selected_device_entry_id: str | None = None @@ -120,9 +119,7 @@ async def async_step_prompt_options( event_code = device_data["event_code"] assert event_code self._selected_device_event_code = event_code - self._selected_device = self._config_entry.data[CONF_DEVICES][ - event_code - ] + self._selected_device = self.config_entry.data[CONF_DEVICES][event_code] self._selected_device_object = get_rfx_object(event_code) return await self.async_step_set_device_options() if CONF_EVENT_CODE in user_input: @@ -148,7 +145,7 @@ async def async_step_prompt_options( device_registry = dr.async_get(self.hass) device_entries = dr.async_entries_for_config_entry( - device_registry, self._config_entry.entry_id + device_registry, self.config_entry.entry_id ) self._device_registry = device_registry self._device_entries = device_entries @@ -162,11 +159,11 @@ async def async_step_prompt_options( options = { vol.Optional( CONF_AUTOMATIC_ADD, - default=self._config_entry.data[CONF_AUTOMATIC_ADD], + default=self.config_entry.data[CONF_AUTOMATIC_ADD], ): bool, vol.Optional( CONF_PROTOCOLS, - default=self._config_entry.data.get(CONF_PROTOCOLS) or [], + default=self.config_entry.data.get(CONF_PROTOCOLS) or [], ): cv.multi_select(RECV_MODES), vol.Optional(CONF_EVENT_CODE): str, vol.Optional(CONF_DEVICE): vol.In(configure_devices), @@ -425,7 +422,7 @@ def _handle_state_added(event: Event[EventStateChangedData]) -> None: def _can_add_device(self, new_rfx_obj: rfxtrxmod.RFXtrxEvent) -> bool: """Check if device does not already exist.""" new_device_id = get_device_id(new_rfx_obj.device) - for packet_id, entity_info in self._config_entry.data[CONF_DEVICES].items(): + for packet_id, entity_info in self.config_entry.data[CONF_DEVICES].items(): rfx_obj = get_rfx_object(packet_id) assert rfx_obj @@ -468,7 +465,7 @@ def _get_device_data(self, entry_id: str) -> DeviceData: assert entry device_id = get_device_tuple_from_identifiers(entry.identifiers) assert device_id - for packet_id, entity_info in self._config_entry.data[CONF_DEVICES].items(): + for packet_id, entity_info in self.config_entry.data[CONF_DEVICES].items(): if tuple(entity_info.get(CONF_DEVICE_ID)) == device_id: event_code = cast(str, packet_id) break @@ -481,8 +478,8 @@ def update_config_data( devices: dict[str, Any] | None = None, ) -> None: """Update data in ConfigEntry.""" - entry_data = self._config_entry.data.copy() - entry_data[CONF_DEVICES] = copy.deepcopy(self._config_entry.data[CONF_DEVICES]) + entry_data = self.config_entry.data.copy() + entry_data[CONF_DEVICES] = copy.deepcopy(self.config_entry.data[CONF_DEVICES]) if global_options: entry_data.update(global_options) if devices: @@ -494,9 +491,9 @@ def update_config_data( entry_data[CONF_DEVICES].pop(event_code, None) else: entry_data[CONF_DEVICES][event_code] = options - self.hass.config_entries.async_update_entry(self._config_entry, data=entry_data) + self.hass.config_entries.async_update_entry(self.config_entry, data=entry_data) self.hass.async_create_task( - self.hass.config_entries.async_reload(self._config_entry.entry_id) + self.hass.config_entries.async_reload(self.config_entry.entry_id) ) @@ -637,9 +634,11 @@ async def async_validate_rfx( @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> RfxtrxOptionsFlow: """Get the options flow for this handler.""" - return RfxtrxOptionsFlow(config_entry) + return RfxtrxOptionsFlow() def _test_transport(host: str | None, port: int | None, device: str | None) -> bool: From 0883b23d0c223755d4e808613f245749d5ba4a01 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:38:11 +0100 Subject: [PATCH 0079/1070] Use new helper properties in yalexs_ble options flow (#129790) --- homeassistant/components/yalexs_ble/config_flow.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index 191ef5a20b2e7..6de7475968638 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -312,16 +312,12 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> YaleXSBLEOptionsFlowHandler: """Get the options flow for this handler.""" - return YaleXSBLEOptionsFlowHandler(config_entry) + return YaleXSBLEOptionsFlowHandler() class YaleXSBLEOptionsFlowHandler(OptionsFlow): """Handle YaleXSBLE options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize YaleXSBLE options flow.""" - self.entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -343,7 +339,9 @@ async def async_step_device_options( { vol.Optional( CONF_ALWAYS_CONNECTED, - default=self.entry.options.get(CONF_ALWAYS_CONNECTED, False), + default=self.config_entry.options.get( + CONF_ALWAYS_CONNECTED, False + ), ): bool, } ), From 6a22a2b867d357bf2daab32579c119908530d1a0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:38:24 +0100 Subject: [PATCH 0080/1070] Use new helper properties in watttime options flow (#129789) --- homeassistant/components/watttime/config_flow.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py index db68738b302db..ad676e166c582 100644 --- a/homeassistant/components/watttime/config_flow.py +++ b/homeassistant/components/watttime/config_flow.py @@ -126,9 +126,11 @@ async def _async_validate_credentials( @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> WattTimeOptionsFlowHandler: """Define the config flow to handle options.""" - return WattTimeOptionsFlowHandler(config_entry) + return WattTimeOptionsFlowHandler() async def async_step_coordinates( self, user_input: dict[str, Any] | None = None @@ -241,10 +243,6 @@ async def async_step_user( class WattTimeOptionsFlowHandler(OptionsFlow): """Handle a WattTime options flow.""" - def __init__(self, entry: ConfigEntry) -> None: - """Initialize.""" - self.entry = entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -258,7 +256,7 @@ async def async_step_init( { vol.Required( CONF_SHOW_ON_MAP, - default=self.entry.options.get(CONF_SHOW_ON_MAP, True), + default=self.config_entry.options.get(CONF_SHOW_ON_MAP, True), ): bool } ), From cdc67aa891a8410dc2f5413fcb2cfd124baf8b77 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:38:41 +0100 Subject: [PATCH 0081/1070] Use new helper properties in verisure options flow (#129788) --- homeassistant/components/verisure/config_flow.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/verisure/config_flow.py b/homeassistant/components/verisure/config_flow.py index 42ce7f9e9fec5..0f1088ccb80d0 100644 --- a/homeassistant/components/verisure/config_flow.py +++ b/homeassistant/components/verisure/config_flow.py @@ -43,9 +43,11 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> VerisureOptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> VerisureOptionsFlowHandler: """Get the options flow for this handler.""" - return VerisureOptionsFlowHandler(config_entry) + return VerisureOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -290,10 +292,6 @@ async def async_step_reauth_mfa( class VerisureOptionsFlowHandler(OptionsFlow): """Handle Verisure options.""" - def __init__(self, entry: ConfigEntry) -> None: - """Initialize Verisure options flow.""" - self.entry = entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -310,7 +308,7 @@ async def async_step_init( vol.Optional( CONF_LOCK_CODE_DIGITS, description={ - "suggested_value": self.entry.options.get( + "suggested_value": self.config_entry.options.get( CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS ) }, From cdd5cb28761787131c7b56c401e20394f3d950f7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:39:13 +0100 Subject: [PATCH 0082/1070] Use new helper properties in tomorrowio options flow (#129787) --- homeassistant/components/tomorrowio/config_flow.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index 90bb488a7c2df..cce41b1749884 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -91,10 +91,6 @@ def _get_unique_id(hass: HomeAssistant, input_dict: dict[str, Any]): class TomorrowioOptionsConfigFlow(OptionsFlow): """Handle Tomorrow.io options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize Tomorrow.io options flow.""" - self._config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -105,7 +101,7 @@ async def async_step_init( options_schema = { vol.Required( CONF_TIMESTEP, - default=self._config_entry.options[CONF_TIMESTEP], + default=self.config_entry.options[CONF_TIMESTEP], ): vol.In([1, 5, 15, 30, 60]), } @@ -125,7 +121,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> TomorrowioOptionsConfigFlow: """Get the options flow for this handler.""" - return TomorrowioOptionsConfigFlow(config_entry) + return TomorrowioOptionsConfigFlow() async def async_step_user( self, user_input: dict[str, Any] | None = None From 4be2cdf90adbc0276c5f9406f14937a8348f1782 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:39:27 +0100 Subject: [PATCH 0083/1070] Use new helper properties in steam_online options flow (#129785) --- .../components/steam_online/config_flow.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index 704eef616f6e4..605f27edb199b 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -40,9 +40,9 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: SteamConfigEntry, - ) -> OptionsFlow: + ) -> SteamOptionsFlowHandler: """Get the options flow for this handler.""" - return SteamOptionsFlowHandler(config_entry) + return SteamOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -121,17 +121,12 @@ def _batch_ids(ids: list[str]) -> Iterator[list[str]]: class SteamOptionsFlowHandler(OptionsFlow): """Handle Steam client options.""" - def __init__(self, entry: SteamConfigEntry) -> None: - """Initialize options flow.""" - self.entry = entry - self.options = dict(entry.options) - async def async_step_init( self, user_input: dict[str, dict[str, str]] | None = None ) -> ConfigFlowResult: """Manage Steam options.""" if user_input is not None: - await self.hass.config_entries.async_unload(self.entry.entry_id) + await self.hass.config_entries.async_unload(self.config_entry.entry_id) for _id in self.options[CONF_ACCOUNTS]: if _id not in user_input[CONF_ACCOUNTS] and ( entity_id := er.async_get(self.hass).async_get_entity_id( @@ -146,7 +141,7 @@ async def async_step_init( if _id in user_input[CONF_ACCOUNTS] } } - await self.hass.config_entries.async_reload(self.entry.entry_id) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) return self.async_create_entry(title="", data=channel_data) error = None try: @@ -176,7 +171,9 @@ def get_accounts(self) -> list[dict[str, str | int]]: """Get accounts.""" interface = steam.api.interface("ISteamUser") try: - friends = interface.GetFriendList(steamid=self.entry.data[CONF_ACCOUNT]) + friends = interface.GetFriendList( + steamid=self.config_entry.data[CONF_ACCOUNT] + ) _users_str = [user["steamid"] for user in friends["friendslist"]["friends"]] except steam.api.HTTPError: return [] From 11ab992dbbb2d504eb45691465471d34da0c344b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:39:41 +0100 Subject: [PATCH 0084/1070] Use new helper properties in recollect_waste options flow (#129783) --- .../components/recollect_waste/config_flow.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recollect_waste/config_flow.py b/homeassistant/components/recollect_waste/config_flow.py index 882eb6a00d26c..299af2609e34e 100644 --- a/homeassistant/components/recollect_waste/config_flow.py +++ b/homeassistant/components/recollect_waste/config_flow.py @@ -34,9 +34,9 @@ class RecollectWasteConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> RecollectWasteOptionsFlowHandler: """Define the config flow to handle options.""" - return RecollectWasteOptionsFlowHandler(config_entry) + return RecollectWasteOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -79,10 +79,6 @@ async def async_step_user( class RecollectWasteOptionsFlowHandler(OptionsFlow): """Handle a Recollect Waste options flow.""" - def __init__(self, entry: ConfigEntry) -> None: - """Initialize.""" - self._entry = entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -96,7 +92,7 @@ async def async_step_init( { vol.Optional( CONF_FRIENDLY_NAME, - default=self._entry.options.get(CONF_FRIENDLY_NAME), + default=self.config_entry.options.get(CONF_FRIENDLY_NAME), ): bool } ), From b48e2127b8ffa370868adc1988b1bd540cf0c8ea Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:39:56 +0100 Subject: [PATCH 0085/1070] Use new helper properties in plaato options flow (#129782) --- homeassistant/components/plaato/config_flow.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py index 74967c417a467..f398a733cd677 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -176,23 +176,19 @@ def _get_error(device_type: PlaatoDeviceType): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> PlaatoOptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> PlaatoOptionsFlowHandler: """Get the options flow for this handler.""" - return PlaatoOptionsFlowHandler(config_entry) + return PlaatoOptionsFlowHandler() class PlaatoOptionsFlowHandler(OptionsFlow): """Handle Plaato options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize domain options flow.""" - super().__init__() - - self._config_entry = config_entry - async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the options.""" - use_webhook = self._config_entry.data.get(CONF_USE_WEBHOOK, False) + use_webhook = self.config_entry.data.get(CONF_USE_WEBHOOK, False) if use_webhook: return await self.async_step_webhook() @@ -211,7 +207,7 @@ async def async_step_user( { vol.Optional( CONF_SCAN_INTERVAL, - default=self._config_entry.options.get( + default=self.config_entry.options.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ), ): cv.positive_int @@ -226,7 +222,7 @@ async def async_step_webhook( if user_input is not None: return self.async_create_entry(title="", data=user_input) - webhook_id = self._config_entry.data.get(CONF_WEBHOOK_ID, None) + webhook_id = self.config_entry.data.get(CONF_WEBHOOK_ID, None) webhook_url = ( "" if webhook_id is None From 461dc13da9b19e1a6a64674c2c9f50a427745dce Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:40:13 +0100 Subject: [PATCH 0086/1070] Use new helper properties in motioneye options flow (#129780) --- .../components/motioneye/config_flow.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index f6d947dab5fc4..80a6449a22de5 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -179,18 +179,16 @@ async def async_step_hassio_confirm( @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> MotionEyeOptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> MotionEyeOptionsFlow: """Get the Hyperion Options flow.""" - return MotionEyeOptionsFlow(config_entry) + return MotionEyeOptionsFlow() class MotionEyeOptionsFlow(OptionsFlow): """motionEye options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize a motionEye options flow.""" - self._config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -201,14 +199,14 @@ async def async_step_init( schema: dict[vol.Marker, type] = { vol.Required( CONF_WEBHOOK_SET, - default=self._config_entry.options.get( + default=self.config_entry.options.get( CONF_WEBHOOK_SET, DEFAULT_WEBHOOK_SET, ), ): bool, vol.Required( CONF_WEBHOOK_SET_OVERWRITE, - default=self._config_entry.options.get( + default=self.config_entry.options.get( CONF_WEBHOOK_SET_OVERWRITE, DEFAULT_WEBHOOK_SET_OVERWRITE, ), @@ -219,9 +217,9 @@ async def async_step_init( # The input URL is not validated as being a URL, to allow for the possibility # the template input won't be a valid URL until after it's rendered description: dict[str, str] | None = None - if CONF_STREAM_URL_TEMPLATE in self._config_entry.options: + if CONF_STREAM_URL_TEMPLATE in self.config_entry.options: description = { - "suggested_value": self._config_entry.options[ + "suggested_value": self.config_entry.options[ CONF_STREAM_URL_TEMPLATE ] } From 9155d561900cbcc8a78cd81df9f8bca4389dddd9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:42:58 +0100 Subject: [PATCH 0087/1070] Use new helper properties in flux_led options flow (#129776) --- homeassistant/components/flux_led/config_flow.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index d78fc699579e9..9a02120f33ada 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -71,9 +71,11 @@ def __init__(self) -> None: @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> FluxLedOptionsFlow: """Get the options flow for the Flux LED component.""" - return FluxLedOptionsFlow(config_entry) + return FluxLedOptionsFlow() async def async_step_dhcp( self, discovery_info: dhcp.DhcpServiceInfo @@ -320,10 +322,6 @@ async def _async_try_connect( class FluxLedOptionsFlow(OptionsFlow): """Handle flux_led options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize the flux_led options flow.""" - self._config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -332,7 +330,7 @@ async def async_step_init( if user_input is not None: return self.async_create_entry(title="", data=user_input) - options = self._config_entry.options + options = self.config_entry.options options_schema = vol.Schema( { vol.Optional( From 3a293c6bc47f0f571a1656c07966b3dfda752515 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:43:10 +0100 Subject: [PATCH 0088/1070] Use new helper properties in dsmr options flow (#129775) --- homeassistant/components/dsmr/config_flow.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 49e1818edcc38..7d6a641b00690 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -171,9 +171,11 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> DSMROptionFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> DSMROptionFlowHandler: """Get the options flow for this handler.""" - return DSMROptionFlowHandler(config_entry) + return DSMROptionFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -311,10 +313,6 @@ async def async_validate_dsmr( class DSMROptionFlowHandler(OptionsFlow): """Handle options.""" - def __init__(self, entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.entry = entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -328,7 +326,7 @@ async def async_step_init( { vol.Optional( CONF_TIME_BETWEEN_UPDATE, - default=self.entry.options.get( + default=self.config_entry.options.get( CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE ), ): vol.All(vol.Coerce(int), vol.Range(min=0)), From 018acc0a3c9e8c4694654524d211c631bdfc03b4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:43:25 +0100 Subject: [PATCH 0089/1070] Use new helper properties in crownstone options flow (#129774) --- .../components/crownstone/config_flow.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py index 7d86fbbd7fb55..4cfbb10a4bd13 100644 --- a/homeassistant/components/crownstone/config_flow.py +++ b/homeassistant/components/crownstone/config_flow.py @@ -143,7 +143,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> CrownstoneOptionsFlowHandler: """Return the Crownstone options.""" - return CrownstoneOptionsFlowHandler(config_entry) + return CrownstoneOptionsFlowHandler() def __init__(self) -> None: """Initialize the flow.""" @@ -210,21 +210,21 @@ def async_create_new_entry(self) -> ConfigFlowResult: class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow): """Handle Crownstone options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize Crownstone options.""" super().__init__(OPTIONS_FLOW, self.async_create_new_entry) - self.entry = config_entry - self.updated_options = config_entry.options.copy() async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage Crownstone options.""" - self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][self.entry.entry_id].cloud + self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][ + self.config_entry.entry_id + ].cloud spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data} - usb_path = self.entry.options.get(CONF_USB_PATH) - usb_sphere = self.entry.options.get(CONF_USB_SPHERE) + usb_path = self.config_entry.options.get(CONF_USB_PATH) + usb_sphere = self.config_entry.options.get(CONF_USB_SPHERE) options_schema = vol.Schema( {vol.Optional(CONF_USE_USB_OPTION, default=usb_path is not None): bool} @@ -243,14 +243,14 @@ async def async_step_init( if user_input[CONF_USE_USB_OPTION] and usb_path is None: return await self.async_step_usb_config() if not user_input[CONF_USE_USB_OPTION] and usb_path is not None: - self.updated_options[CONF_USB_PATH] = None - self.updated_options[CONF_USB_SPHERE] = None + self.options[CONF_USB_PATH] = None + self.options[CONF_USB_SPHERE] = None elif ( CONF_USB_SPHERE_OPTION in user_input and spheres[user_input[CONF_USB_SPHERE_OPTION]] != usb_sphere ): sphere_id = spheres[user_input[CONF_USB_SPHERE_OPTION]] - self.updated_options[CONF_USB_SPHERE] = sphere_id + self.options[CONF_USB_SPHERE] = sphere_id return self.async_create_new_entry() @@ -260,7 +260,7 @@ def async_create_new_entry(self) -> ConfigFlowResult: """Create a new entry.""" # these attributes will only change when a usb was configured if self.usb_path is not None and self.usb_sphere_id is not None: - self.updated_options[CONF_USB_PATH] = self.usb_path - self.updated_options[CONF_USB_SPHERE] = self.usb_sphere_id + self.options[CONF_USB_PATH] = self.usb_path + self.options[CONF_USB_SPHERE] = self.usb_sphere_id - return super().async_create_entry(title="", data=self.updated_options) + return super().async_create_entry(title="", data=self.options) From 0a1ba8a4a382416caf9f41094d9c1010dec85b7f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 09:52:35 +0100 Subject: [PATCH 0090/1070] Small code quality improvement/cleanup in random (#129542) --- homeassistant/components/random/binary_sensor.py | 5 ++--- homeassistant/components/random/config_flow.py | 10 +++++----- homeassistant/components/random/sensor.py | 12 ++++++------ 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index 9d33ad5269213..ae9a5886d5972 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -59,10 +59,9 @@ class RandomBinarySensor(BinarySensorEntity): def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random binary sensor.""" - self._attr_name = config.get(CONF_NAME) + self._attr_name = config[CONF_NAME] self._attr_device_class = config.get(CONF_DEVICE_CLASS) - if entry_id: - self._attr_unique_id = entry_id + self._attr_unique_id = entry_id async def async_update(self) -> None: """Get new state and update the sensor's state.""" diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py index fcbd77916a934..0031416926038 100644 --- a/homeassistant/components/random/config_flow.py +++ b/homeassistant/components/random/config_flow.py @@ -95,7 +95,7 @@ def _generate_schema(domain: str, flow_type: _FlowType) -> vol.Schema: async def choose_options_step(options: dict[str, Any]) -> str: - """Return next step_id for options flow according to template_type.""" + """Return next step_id for options flow according to entity_type.""" return cast(str, options["entity_type"]) @@ -122,7 +122,7 @@ def _validate_unit(options: dict[str, Any]) -> None: def validate_user_input( - template_type: str, + entity_type: str, ) -> Callable[ [SchemaCommonFlowHandler, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]], @@ -136,10 +136,10 @@ async def _validate_user_input( _: SchemaCommonFlowHandler, user_input: dict[str, Any], ) -> dict[str, Any]: - """Add template type to user input.""" - if template_type == Platform.SENSOR: + """Add entity type to user input.""" + if entity_type == Platform.SENSOR: _validate_unit(user_input) - return {"entity_type": template_type} | user_input + return {"entity_type": entity_type} | user_input return _validate_user_input diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 3c6e67c99184d..aad4fcb851cd7 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -70,22 +70,22 @@ class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" _attr_translation_key = "random" + _unrecorded_attributes = frozenset({ATTR_MAXIMUM, ATTR_MINIMUM}) def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random sensor.""" - self._attr_name = config.get(CONF_NAME) - self._minimum = config.get(CONF_MINIMUM, DEFAULT_MIN) - self._maximum = config.get(CONF_MAXIMUM, DEFAULT_MAX) + self._attr_name = config[CONF_NAME] + self._minimum = config[CONF_MINIMUM] + self._maximum = config[CONF_MAXIMUM] self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_extra_state_attributes = { ATTR_MAXIMUM: self._maximum, ATTR_MINIMUM: self._minimum, } - if entry_id: - self._attr_unique_id = entry_id + self._attr_unique_id = entry_id async def async_update(self) -> None: - """Get a new number and updates the states.""" + """Get a new number and update the state.""" self._attr_native_value = randrange(self._minimum, self._maximum + 1) From 0c40fcdaebc91e5cf885ade5e6fc4249df27e0fb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Nov 2024 10:33:08 +0100 Subject: [PATCH 0091/1070] Bump yt-dlp to 2024.11.04 (#129794) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 233fef3c7f3fb..3e4db5d5b042e 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2024.10.22"], + "requirements": ["yt-dlp==2024.11.04"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 8e05edf10dc8e..52cbbe340c160 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3054,7 +3054,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.10.22 +yt-dlp==2024.11.04 # homeassistant.components.zamg zamg==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6479de6cd7dee..fa8c40a6bacb5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2440,7 +2440,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.10.22 +yt-dlp==2024.11.04 # homeassistant.components.zamg zamg==0.3.6 From d75dda0c055b66bde600e9fa428d76c072bdc51f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 4 Nov 2024 10:38:27 +0100 Subject: [PATCH 0092/1070] Use RTCIceCandidate instead of str for candidate (#129793) --- homeassistant/components/camera/__init__.py | 6 ++++-- homeassistant/components/camera/webrtc.py | 19 +++++++++++++---- homeassistant/components/go2rtc/__init__.py | 9 +++++--- tests/components/camera/test_init.py | 3 ++- tests/components/camera/test_webrtc.py | 23 ++++++++++++++------- tests/components/go2rtc/test_init.py | 7 ++++--- 6 files changed, 47 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 58826eb07ce4b..1feb7dffd3bb9 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -20,7 +20,7 @@ import attr from propcache import cached_property, under_cached_property import voluptuous as vol -from webrtc_models import RTCIceServer +from webrtc_models import RTCIceCandidate, RTCIceServer from homeassistant.components import websocket_api from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView @@ -840,7 +840,9 @@ def async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration: return config - async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: """Handle a WebRTC candidate.""" if self._webrtc_provider: await self._webrtc_provider.async_on_webrtc_candidate(session_id, candidate) diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index aca2b8291f19c..0612c96e40c8a 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Protocol import voluptuous as vol -from webrtc_models import RTCConfiguration, RTCIceServer +from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceServer from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback @@ -78,7 +78,14 @@ class WebRTCAnswer(WebRTCMessage): class WebRTCCandidate(WebRTCMessage): """WebRTC candidate.""" - candidate: str + candidate: RTCIceCandidate + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the message.""" + return { + "type": self._get_type(), + "candidate": self.candidate.candidate, + } @dataclass(frozen=True) @@ -138,7 +145,9 @@ async def async_handle_async_webrtc_offer( """Handle the WebRTC offer and return the answer via the provided callback.""" @abstractmethod - async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: """Handle the WebRTC candidate.""" @callback @@ -319,7 +328,9 @@ async def ws_candidate( ) return - await camera.async_on_webrtc_candidate(msg["session_id"], msg["candidate"]) + await camera.async_on_webrtc_candidate( + msg["session_id"], RTCIceCandidate(msg["candidate"]) + ) connection.send_message(websocket_api.result_message(msg["id"])) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 0bf01490a47f5..eeaa35fbbb473 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -15,6 +15,7 @@ WsError, ) import voluptuous as vol +from webrtc_models import RTCIceCandidate from homeassistant.components.camera import ( Camera, @@ -219,7 +220,7 @@ def on_messages(message: ReceiveMessages) -> None: value: WebRTCMessage match message: case WebRTCCandidate(): - value = HAWebRTCCandidate(message.candidate) + value = HAWebRTCCandidate(RTCIceCandidate(message.candidate)) case WebRTCAnswer(): value = HAWebRTCAnswer(message.sdp) case WsError(): @@ -231,11 +232,13 @@ def on_messages(message: ReceiveMessages) -> None: config = camera.async_get_webrtc_client_configuration() await ws_client.send(WebRTCOffer(offer_sdp, config.configuration.ice_servers)) - async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: """Handle the WebRTC candidate.""" if ws_client := self._sessions.get(session_id): - await ws_client.send(WebRTCCandidate(candidate)) + await ws_client.send(WebRTCCandidate(candidate.candidate)) else: _LOGGER.debug("Unknown session %s. Ignoring candidate", session_id) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index e0d4e38fb576e..e7279f6084825 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -7,6 +7,7 @@ import pytest from syrupy.assertion import SnapshotAssertion +from webrtc_models import RTCIceCandidate from homeassistant.components import camera from homeassistant.components.camera import ( @@ -960,7 +961,7 @@ async def async_handle_async_webrtc_offer( send_message(WebRTCAnswer("answer")) async def async_on_webrtc_candidate( - self, session_id: str, candidate: str + self, session_id: str, candidate: RTCIceCandidate ) -> None: """Handle the WebRTC candidate.""" diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index ec096b5f37a1a..27c50848ebfa7 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest +from webrtc_models import RTCIceCandidate, RTCIceServer from homeassistant.components.camera import ( DATA_ICE_SERVERS, @@ -13,7 +14,6 @@ Camera, CameraEntityFeature, CameraWebRTCProvider, - RTCIceServer, StreamType, WebRTCAnswer, WebRTCCandidate, @@ -81,7 +81,9 @@ async def async_handle_async_webrtc_offer( """ send_message(WebRTCAnswer(answer="answer")) - async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: """Handle the WebRTC candidate.""" @callback @@ -503,7 +505,10 @@ async def test_websocket_webrtc_offer( @pytest.mark.parametrize( ("message", "expected_frontend_message"), [ - (WebRTCCandidate("candidate"), {"type": "candidate", "candidate": "candidate"}), + ( + WebRTCCandidate(RTCIceCandidate("candidate")), + {"type": "candidate", "candidate": "candidate"}, + ), ( WebRTCError("webrtc_offer_failed", "error"), {"type": "error", "code": "webrtc_offer_failed", "message": "error"}, @@ -989,7 +994,9 @@ async def test_ws_webrtc_candidate( response = await client.receive_json() assert response["type"] == TYPE_RESULT assert response["success"] - mock_on_webrtc_candidate.assert_called_once_with(session_id, candidate) + mock_on_webrtc_candidate.assert_called_once_with( + session_id, RTCIceCandidate(candidate) + ) @pytest.mark.usefixtures("mock_camera_webrtc") @@ -1039,7 +1046,9 @@ async def test_ws_webrtc_candidate_webrtc_provider( response = await client.receive_json() assert response["type"] == TYPE_RESULT assert response["success"] - mock_on_webrtc_candidate.assert_called_once_with(session_id, candidate) + mock_on_webrtc_candidate.assert_called_once_with( + session_id, RTCIceCandidate(candidate) + ) @pytest.mark.usefixtures("mock_camera_webrtc") @@ -1140,7 +1149,7 @@ async def async_handle_async_webrtc_offer( send_message(WebRTCAnswer(answer="answer")) async def async_on_webrtc_candidate( - self, session_id: str, candidate: str + self, session_id: str, candidate: RTCIceCandidate ) -> None: """Handle the WebRTC candidate.""" @@ -1150,7 +1159,7 @@ async def async_on_webrtc_candidate( await provider.async_handle_async_webrtc_offer( Mock(), "offer_sdp", "session_id", Mock() ) - await provider.async_on_webrtc_candidate("session_id", "candidate") + await provider.async_on_webrtc_candidate("session_id", RTCIceCandidate("candidate")) provider.async_close_session("session_id") diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index c4a23731a93fb..1e73525fbe306 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -17,6 +17,7 @@ WsError, ) import pytest +from webrtc_models import RTCIceCandidate from homeassistant.components.camera import ( DOMAIN as CAMERA_DOMAIN, @@ -379,7 +380,7 @@ async def message_callbacks( [ ( WebRTCCandidate("candidate"), - HAWebRTCCandidate("candidate"), + HAWebRTCCandidate(RTCIceCandidate("candidate")), ), ( WebRTCAnswer(ANSWER_SDP), @@ -415,7 +416,7 @@ async def test_on_candidate( session_id = "session_id" # Session doesn't exist - await camera.async_on_webrtc_candidate(session_id, "candidate") + await camera.async_on_webrtc_candidate(session_id, RTCIceCandidate("candidate")) assert ( "homeassistant.components.go2rtc", logging.DEBUG, @@ -435,7 +436,7 @@ async def test_on_candidate( ) ws_client.reset_mock() - await camera.async_on_webrtc_candidate(session_id, "candidate") + await camera.async_on_webrtc_candidate(session_id, RTCIceCandidate("candidate")) ws_client.send.assert_called_once_with(WebRTCCandidate("candidate")) assert caplog.record_tuples == [] From 274c928ec09f08c331899f140e05752b73619b3a Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Mon, 4 Nov 2024 12:18:12 +0100 Subject: [PATCH 0093/1070] Add coordinator to suez_water (#129242) Co-authored-by: Joost Lekkerkerker --- .../components/suez_water/__init__.py | 30 ++--- homeassistant/components/suez_water/const.py | 4 + .../components/suez_water/coordinator.py | 108 ++++++++++++++++++ homeassistant/components/suez_water/sensor.py | 86 +++++--------- tests/components/suez_water/__init__.py | 14 +++ tests/components/suez_water/conftest.py | 62 +++++++++- .../suez_water/snapshots/test_sensor.ambr | 67 +++++++++++ .../components/suez_water/test_config_flow.py | 8 +- tests/components/suez_water/test_init.py | 35 ++++++ tests/components/suez_water/test_sensor.py | 62 ++++++++++ 10 files changed, 390 insertions(+), 86 deletions(-) create mode 100644 homeassistant/components/suez_water/coordinator.py create mode 100644 tests/components/suez_water/snapshots/test_sensor.ambr create mode 100644 tests/components/suez_water/test_init.py create mode 100644 tests/components/suez_water/test_sensor.py diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py index f5b2880e0117b..06f503b85c26d 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -2,15 +2,12 @@ from __future__ import annotations -from pysuez import SuezClient -from pysuez.client import PySuezError - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from .const import CONF_COUNTER_ID, DOMAIN +from .const import DOMAIN +from .coordinator import SuezWaterCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -18,23 +15,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Suez Water from a config entry.""" - def get_client() -> SuezClient: - try: - client = SuezClient( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_COUNTER_ID], - provider=None, - ) - if not client.check_credentials(): - raise ConfigEntryError - except PySuezError as ex: - raise ConfigEntryNotReady from ex - return client - - hass.data.setdefault(DOMAIN, {})[ - entry.entry_id - ] = await hass.async_add_executor_job(get_client) + coordinator = SuezWaterCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/suez_water/const.py b/homeassistant/components/suez_water/const.py index 7afc0d3ce3ea5..cecd779c22c40 100644 --- a/homeassistant/components/suez_water/const.py +++ b/homeassistant/components/suez_water/const.py @@ -1,5 +1,9 @@ """Constants for the Suez Water integration.""" +from datetime import timedelta + DOMAIN = "suez_water" CONF_COUNTER_ID = "counter_id" + +DATA_REFRESH_INTERVAL = timedelta(hours=12) diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py new file mode 100644 index 0000000000000..adcbd39c01b7f --- /dev/null +++ b/homeassistant/components/suez_water/coordinator.py @@ -0,0 +1,108 @@ +"""Suez water update coordinator.""" + +import asyncio +from dataclasses import dataclass +from datetime import date + +from pysuez import SuezClient +from pysuez.client import PySuezError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import _LOGGER, HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN + + +@dataclass +class AggregatedSensorData: + """Hold suez water aggregated sensor data.""" + + value: float + current_month: dict[date, float] + previous_month: dict[date, float] + previous_year: dict[str, float] + current_year: dict[str, float] + history: dict[date, float] + highest_monthly_consumption: float + attribution: str + + +class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedSensorData]): + """Suez water coordinator.""" + + _sync_client: SuezClient + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize suez water coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=DATA_REFRESH_INTERVAL, + always_update=True, + config_entry=config_entry, + ) + + async def _async_setup(self) -> None: + self._sync_client = await self.hass.async_add_executor_job(self._get_client) + + async def _async_update_data(self) -> AggregatedSensorData: + """Fetch data from API endpoint.""" + async with asyncio.timeout(30): + return await self.hass.async_add_executor_job(self._fetch_data) + + def _fetch_data(self) -> AggregatedSensorData: + """Fetch latest data from Suez.""" + try: + self._sync_client.update() + except PySuezError as err: + raise UpdateFailed( + f"Suez coordinator error communicating with API: {err}" + ) from err + current_month = {} + for item in self._sync_client.attributes["thisMonthConsumption"]: + current_month[item] = self._sync_client.attributes["thisMonthConsumption"][ + item + ] + previous_month = {} + for item in self._sync_client.attributes["previousMonthConsumption"]: + previous_month[item] = self._sync_client.attributes[ + "previousMonthConsumption" + ][item] + highest_monthly_consumption = self._sync_client.attributes[ + "highestMonthlyConsumption" + ] + previous_year = self._sync_client.attributes["lastYearOverAll"] + current_year = self._sync_client.attributes["thisYearOverAll"] + history = {} + for item in self._sync_client.attributes["history"]: + history[item] = self._sync_client.attributes["history"][item] + _LOGGER.debug("Retrieved consumption: " + str(self._sync_client.state)) + return AggregatedSensorData( + self._sync_client.state, + current_month, + previous_month, + previous_year, + current_year, + history, + highest_monthly_consumption, + self._sync_client.attributes["attribution"], + ) + + def _get_client(self) -> SuezClient: + try: + client = SuezClient( + username=self.config_entry.data[CONF_USERNAME], + password=self.config_entry.data[CONF_PASSWORD], + counter_id=self.config_entry.data[CONF_COUNTER_ID], + provider=None, + ) + if not client.check_credentials(): + raise ConfigEntryError + except PySuezError as ex: + raise ConfigEntryNotReady from ex + return client diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 5b00cbf2dc41d..22a61c835e190 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -2,11 +2,8 @@ from __future__ import annotations -from datetime import timedelta -import logging - -from pysuez import SuezClient -from pysuez.client import PySuezError +from collections.abc import Mapping +from typing import Any from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -14,12 +11,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_COUNTER_ID, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(hours=12) +from .coordinator import SuezWaterCoordinator async def async_setup_entry( @@ -28,11 +23,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Suez Water sensor from a config entry.""" - client = hass.data[DOMAIN][entry.entry_id] - async_add_entities([SuezSensor(client, entry.data[CONF_COUNTER_ID])], True) + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SuezAggregatedSensor(coordinator, entry.data[CONF_COUNTER_ID])]) -class SuezSensor(SensorEntity): +class SuezAggregatedSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity): """Representation of a Sensor.""" _attr_has_entity_name = True @@ -40,9 +35,9 @@ class SuezSensor(SensorEntity): _attr_native_unit_of_measurement = UnitOfVolume.LITERS _attr_device_class = SensorDeviceClass.WATER - def __init__(self, client: SuezClient, counter_id: int) -> None: + def __init__(self, coordinator: SuezWaterCoordinator, counter_id: int) -> None: """Initialize the data object.""" - self.client = client + super().__init__(coordinator) self._attr_extra_state_attributes = {} self._attr_unique_id = f"{counter_id}_water_usage_yesterday" self._attr_device_info = DeviceInfo( @@ -51,45 +46,24 @@ def __init__(self, client: SuezClient, counter_id: int) -> None: manufacturer="Suez", ) - def _fetch_data(self) -> None: - """Fetch latest data from Suez.""" - try: - self.client.update() - # _state holds the volume of consumed water during previous day - self._attr_native_value = self.client.state - self._attr_available = True - self._attr_attribution = self.client.attributes["attribution"] - - self._attr_extra_state_attributes["this_month_consumption"] = {} - for item in self.client.attributes["thisMonthConsumption"]: - self._attr_extra_state_attributes["this_month_consumption"][item] = ( - self.client.attributes["thisMonthConsumption"][item] - ) - self._attr_extra_state_attributes["previous_month_consumption"] = {} - for item in self.client.attributes["previousMonthConsumption"]: - self._attr_extra_state_attributes["previous_month_consumption"][ - item - ] = self.client.attributes["previousMonthConsumption"][item] - self._attr_extra_state_attributes["highest_monthly_consumption"] = ( - self.client.attributes["highestMonthlyConsumption"] - ) - self._attr_extra_state_attributes["last_year_overall"] = ( - self.client.attributes["lastYearOverAll"] - ) - self._attr_extra_state_attributes["this_year_overall"] = ( - self.client.attributes["thisYearOverAll"] - ) - self._attr_extra_state_attributes["history"] = {} - for item in self.client.attributes["history"]: - self._attr_extra_state_attributes["history"][item] = ( - self.client.attributes["history"][item] - ) - - except PySuezError: - self._attr_available = False - _LOGGER.warning("Unable to fetch data") - - def update(self) -> None: - """Return the latest collected data from Suez.""" - self._fetch_data() - _LOGGER.debug("Suez data state is: %s", self.native_value) + @property + def native_value(self) -> float: + """Return the current daily usage.""" + return self.coordinator.data.value + + @property + def attribution(self) -> str: + """Return data attribution message.""" + return self.coordinator.data.attribution + + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return aggregated data.""" + return { + "this_month_consumption": self.coordinator.data.current_month, + "previous_month_consumption": self.coordinator.data.previous_month, + "highest_monthly_consumption": self.coordinator.data.highest_monthly_consumption, + "last_year_overall": self.coordinator.data.previous_year, + "this_year_overall": self.coordinator.data.current_year, + "history": self.coordinator.data.history, + } diff --git a/tests/components/suez_water/__init__.py b/tests/components/suez_water/__init__.py index 4605e06344add..a90df7384545e 100644 --- a/tests/components/suez_water/__init__.py +++ b/tests/components/suez_water/__init__.py @@ -1 +1,15 @@ """Tests for the Suez Water integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Init suez water integration.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index f218fb7d833a1..bcb817a502572 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -1,10 +1,31 @@ """Common fixtures for the Suez Water tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from homeassistant.components.suez_water.const import DOMAIN + +from tests.common import MockConfigEntry + +MOCK_DATA = { + "username": "test-username", + "password": "test-password", + "counter_id": "test-counter", +} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create mock config_entry needed by suez_water integration.""" + return MockConfigEntry( + unique_id=MOCK_DATA["username"], + domain=DOMAIN, + title="Suez mock device", + data=MOCK_DATA, + ) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +34,42 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.suez_water.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture(name="suez_client") +def mock_suez_client() -> Generator[MagicMock]: + """Create mock for suez_water external api.""" + with ( + patch( + "homeassistant.components.suez_water.coordinator.SuezClient", autospec=True + ) as mock_client, + patch( + "homeassistant.components.suez_water.config_flow.SuezClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.check_credentials.return_value = True + client.update.return_value = None + client.state = 160 + client.attributes = { + "thisMonthConsumption": { + "2024-01-01": 130, + "2024-01-02": 145, + }, + "previousMonthConsumption": { + "2024-12-01": 154, + "2024-12-02": 166, + }, + "highestMonthlyConsumption": 2558, + "lastYearOverAll": 1000, + "thisYearOverAll": 1500, + "history": { + "2024-01-01": 130, + "2024-01-02": 145, + "2024-12-01": 154, + "2024-12-02": 166, + }, + "attribution": "suez water mock test", + } + yield client diff --git a/tests/components/suez_water/snapshots/test_sensor.ambr b/tests/components/suez_water/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..acc3042f93b71 --- /dev/null +++ b/tests/components/suez_water/snapshots/test_sensor.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.suez_mock_device_water_usage_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water usage yesterday', + 'platform': 'suez_water', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_usage_yesterday', + 'unique_id': 'test-counter_water_usage_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'suez water mock test', + 'device_class': 'water', + 'friendly_name': 'Suez mock device Water usage yesterday', + 'highest_monthly_consumption': 2558, + 'history': dict({ + '2024-01-01': 130, + '2024-01-02': 145, + '2024-12-01': 154, + '2024-12-02': 166, + }), + 'last_year_overall': 1000, + 'previous_month_consumption': dict({ + '2024-12-01': 154, + '2024-12-02': 166, + }), + 'this_month_consumption': dict({ + '2024-01-01': 130, + '2024-01-02': 145, + }), + 'this_year_overall': 1500, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.suez_mock_device_water_usage_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '160', + }) +# --- diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py index 3170a6779f0be..ddf7bcd3d8012 100644 --- a/tests/components/suez_water/test_config_flow.py +++ b/tests/components/suez_water/test_config_flow.py @@ -10,13 +10,9 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from .conftest import MOCK_DATA -MOCK_DATA = { - "username": "test-username", - "password": "test-password", - "counter_id": "test-counter", -} +from tests.common import MockConfigEntry async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: diff --git a/tests/components/suez_water/test_init.py b/tests/components/suez_water/test_init.py new file mode 100644 index 0000000000000..b9a8875a8a12f --- /dev/null +++ b/tests/components/suez_water/test_init.py @@ -0,0 +1,35 @@ +"""Test Suez_water integration initialization.""" + +from homeassistant.components.suez_water.coordinator import PySuezError +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_initialization_invalid_credentials( + hass: HomeAssistant, + suez_client, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that suez_water can't be loaded with invalid credentials.""" + + suez_client.check_credentials.return_value = False + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_initialization_setup_api_error( + hass: HomeAssistant, + suez_client, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that suez_water needs to retry loading if api failed to connect.""" + + suez_client.check_credentials.side_effect = PySuezError("Test failure") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/suez_water/test_sensor.py b/tests/components/suez_water/test_sensor.py new file mode 100644 index 0000000000000..d3da159ee2846 --- /dev/null +++ b/tests/components/suez_water/test_sensor.py @@ -0,0 +1,62 @@ +"""Test Suez_water sensor platform.""" + +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.suez_water.const import DATA_REFRESH_INTERVAL +from homeassistant.components.suez_water.coordinator import PySuezError +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_sensors_valid_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + suez_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that suez_water sensor is loaded and in a valid state.""" + with patch("homeassistant.components.suez_water.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensors_failed_update( + hass: HomeAssistant, + suez_client, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that suez_water sensor reflect failure when api fails.""" + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + entity_ids = await hass.async_add_executor_job(hass.states.entity_ids) + assert len(entity_ids) == 1 + + state = hass.states.get(entity_ids[0]) + assert entity_ids[0] + assert state.state != STATE_UNAVAILABLE + + suez_client.update.side_effect = PySuezError("Should fail to update") + + freezer.tick(DATA_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(True) + + state = hass.states.get(entity_ids[0]) + assert state + assert state.state == STATE_UNAVAILABLE From 08a53362a78cb7bb5c8502080afef1ae81598662 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Nov 2024 12:26:34 +0100 Subject: [PATCH 0094/1070] Fix stringification of discovered hassio uuid (#129797) --- homeassistant/components/hassio/discovery.py | 4 ++-- tests/components/hassio/test_discovery.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 6181fe4624ca4..b51b8e5a8f2da 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -131,11 +131,11 @@ async def async_process_new(self, data: Discovery) -> None: config=data.config, name=addon_info.name, slug=data.addon, - uuid=str(data.uuid), + uuid=data.uuid.hex, ), discovery_key=discovery_flow.DiscoveryKey( domain=DOMAIN, - key=str(data.uuid), + key=data.uuid.hex, version=1, ), ) diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index bb3a101d1f97b..ba6338f84e29c 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -91,7 +91,7 @@ async def test_hassio_discovery_startup( }, name="Mosquitto Test", slug="mosquitto", - uuid=str(uuid), + uuid=uuid.hex, ) ) @@ -153,7 +153,7 @@ async def test_hassio_discovery_startup_done( }, name="Mosquitto Test", slug="mosquitto", - uuid=str(uuid), + uuid=uuid.hex, ) ) @@ -203,7 +203,7 @@ async def test_hassio_discovery_webhook( }, name="Mosquitto Test", slug="mosquitto", - uuid=str(uuid), + uuid=uuid.hex, ) ) @@ -286,7 +286,7 @@ async def test_hassio_rediscover( ) expected_context = { - "discovery_key": DiscoveryKey(domain="hassio", key=str(uuid), version=1), + "discovery_key": DiscoveryKey(domain="hassio", key=uuid.hex, version=1), "source": config_entries.SOURCE_HASSIO, } From ae06f734ce7c8e9557afdcaf6b467ab541faad1b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Nov 2024 12:34:00 +0100 Subject: [PATCH 0095/1070] Improve error handling in Spotify (#129799) --- .../components/spotify/coordinator.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index 4a8c6885f9fd2..9e62d5f137e41 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -75,7 +75,10 @@ async def _async_setup(self) -> None: raise UpdateFailed("Error communicating with Spotify API") from err async def _async_update_data(self) -> SpotifyCoordinatorData: - current = await self.client.get_playback() + try: + current = await self.client.get_playback() + except SpotifyConnectionError as err: + raise UpdateFailed("Error communicating with Spotify API") from err if not current: return SpotifyCoordinatorData( current_playback=None, @@ -90,8 +93,17 @@ async def _async_update_data(self) -> SpotifyCoordinatorData: audio_features: AudioFeatures | None = None if (item := current.item) is not None and item.type == ItemType.TRACK: if item.uri != self._currently_loaded_track: - self._currently_loaded_track = item.uri - audio_features = await self.client.get_audio_features(item.uri) + try: + audio_features = await self.client.get_audio_features(item.uri) + except SpotifyConnectionError: + _LOGGER.debug( + "Unable to load audio features for track '%s'. " + "Continuing without audio features", + item.uri, + ) + audio_features = None + else: + self._currently_loaded_track = item.uri else: audio_features = self.data.audio_features dj_playlist = False From 3cadc1796fc3ed89afbe13d3a077a2e4758bf05c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 4 Nov 2024 13:07:11 +0100 Subject: [PATCH 0096/1070] Use JSON as format for .HA_RESTORE (#129792) * Use JSON as format for .HA_RESTORE * Adjust bakup manager test --- homeassistant/backup_restore.py | 6 +++--- homeassistant/components/backup/manager.py | 2 +- tests/components/backup/test_manager.py | 2 +- tests/test_backup_restore.py | 9 ++------- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 6cf96fdfa91d1..32991dfb2d3b1 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -30,11 +30,11 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | """Return the contents of the restore backup file.""" instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE) try: - instruction_content = instruction_path.read_text(encoding="utf-8") + instruction_content = json.loads(instruction_path.read_text(encoding="utf-8")) return RestoreBackupFileContent( - backup_file_path=Path(instruction_content.split(";")[0]) + backup_file_path=Path(instruction_content["path"]) ) - except FileNotFoundError: + except (FileNotFoundError, json.JSONDecodeError): return None diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 8120e3a6e66a6..b3cb69861b987 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -308,7 +308,7 @@ async def async_restore_backup(self, slug: str, **kwargs: Any) -> None: def _write_restore_file() -> None: """Write the restore file.""" Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text( - f"{backup.path.as_posix()};", + json.dumps({"path": backup.path.as_posix()}), encoding="utf-8", ) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index a269a3f2f1793..a4dba5c6936d6 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -350,7 +350,7 @@ async def test_async_trigger_restore( patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, ): await manager.async_restore_backup(TEST_BACKUP.slug) - assert mocked_write_text.call_args[0][0] == "abc123.tar;" + assert mocked_write_text.call_args[0][0] == '{"path": "abc123.tar"}' assert mocked_service_call.called diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index fabb403468d9a..44a05c0540e52 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -15,15 +15,10 @@ ("side_effect", "content", "expected"), [ (FileNotFoundError, "", None), - (None, "", backup_restore.RestoreBackupFileContent(backup_file_path=Path(""))), + (None, "", None), ( None, - "test;", - backup_restore.RestoreBackupFileContent(backup_file_path=Path("test")), - ), - ( - None, - "test;;;;", + '{"path": "test"}', backup_restore.RestoreBackupFileContent(backup_file_path=Path("test")), ), ], From 57eeaf1f7526f1493caa21744ac131d0aab83291 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Nov 2024 13:42:42 +0100 Subject: [PATCH 0097/1070] Add watchdog to monitor and respawn go2rtc server (#129497) --- homeassistant/components/go2rtc/__init__.py | 4 +- homeassistant/components/go2rtc/const.py | 1 + homeassistant/components/go2rtc/server.py | 113 +++++++++++++++++++- tests/components/go2rtc/conftest.py | 1 + tests/components/go2rtc/test_server.py | 97 +++++++++++++++++ 5 files changed, 210 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index eeaa35fbbb473..013c094dc23eb 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -38,7 +38,7 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN +from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DEFAULT_URL, DOMAIN from .server import Server _LOGGER = logging.getLogger(__name__) @@ -121,7 +121,7 @@ async def on_stop(event: Event) -> None: hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) - url = "http://localhost:1984/" + url = DEFAULT_URL hass.data[_DATA_GO2RTC] = url discovery_flow.async_create_flow( diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index b0d52e4fd3906..cb03e224e5250 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -4,3 +4,4 @@ CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." +DEFAULT_URL = "http://localhost:1984/" diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index df4b5b7f13eed..b2aa19d527586 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -1,17 +1,25 @@ """Go2rtc server.""" import asyncio +from contextlib import suppress import logging from tempfile import NamedTemporaryFile +from go2rtc_client import Go2RtcRestClient + from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_URL _LOGGER = logging.getLogger(__name__) _TERMINATE_TIMEOUT = 5 _SETUP_TIMEOUT = 30 _SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr=" _LOCALHOST_IP = "127.0.0.1" +_RESPAWN_COOLDOWN = 1 + # Default configuration for HA # - Api is listening only on localhost # - Disable rtsp listener @@ -29,6 +37,16 @@ """ +class Go2RTCServerStartError(HomeAssistantError): + """Raised when server does not start.""" + + _message = "Go2rtc server didn't start correctly" + + +class Go2RTCWatchdogError(HomeAssistantError): + """Raised on watchdog error.""" + + def _create_temp_file(api_ip: str) -> str: """Create temporary config file.""" # Set delete=False to prevent the file from being deleted when the file is closed @@ -53,8 +71,17 @@ def __init__( if enable_ui: # Listen on all interfaces for allowing access from all ips self._api_ip = "" + self._watchdog_task: asyncio.Task | None = None + self._watchdog_tasks: list[asyncio.Task] = [] async def start(self) -> None: + """Start the server.""" + await self._start() + self._watchdog_task = asyncio.create_task( + self._watchdog(), name="Go2rtc respawn" + ) + + async def _start(self) -> None: """Start the server.""" _LOGGER.debug("Starting go2rtc server") config_file = await self._hass.async_add_executor_job( @@ -82,8 +109,8 @@ async def start(self) -> None: except TimeoutError as err: msg = "Go2rtc server didn't start correctly" _LOGGER.exception(msg) - await self.stop() - raise HomeAssistantError("Go2rtc server didn't start correctly") from err + await self._stop() + raise Go2RTCServerStartError from err async def _log_output(self, process: asyncio.subprocess.Process) -> None: """Log the output of the process.""" @@ -95,17 +122,95 @@ async def _log_output(self, process: asyncio.subprocess.Process) -> None: if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg: self._startup_complete.set() + async def _watchdog(self) -> None: + """Keep respawning go2rtc servers. + + A new go2rtc server is spawned if the process terminates or the API + stops responding. + """ + while True: + try: + monitor_process_task = asyncio.create_task(self._monitor_process()) + self._watchdog_tasks.append(monitor_process_task) + monitor_process_task.add_done_callback(self._watchdog_tasks.remove) + monitor_api_task = asyncio.create_task(self._monitor_api()) + self._watchdog_tasks.append(monitor_api_task) + monitor_api_task.add_done_callback(self._watchdog_tasks.remove) + try: + await asyncio.gather(monitor_process_task, monitor_api_task) + except Go2RTCWatchdogError: + _LOGGER.debug("Caught Go2RTCWatchdogError") + for task in self._watchdog_tasks: + if task.done(): + if not task.cancelled(): + task.exception() + continue + task.cancel() + await asyncio.sleep(_RESPAWN_COOLDOWN) + try: + await self._stop() + _LOGGER.debug("Spawning new go2rtc server") + with suppress(Go2RTCServerStartError): + await self._start() + except Exception: + _LOGGER.exception( + "Unexpected error when restarting go2rtc server" + ) + except Exception: + _LOGGER.exception("Unexpected error in go2rtc server watchdog") + + async def _monitor_process(self) -> None: + """Raise if the go2rtc process terminates.""" + _LOGGER.debug("Monitoring go2rtc server process") + if self._process: + await self._process.wait() + _LOGGER.debug("go2rtc server terminated") + raise Go2RTCWatchdogError("Process ended") + + async def _monitor_api(self) -> None: + """Raise if the go2rtc process terminates.""" + client = Go2RtcRestClient(async_get_clientsession(self._hass), DEFAULT_URL) + + _LOGGER.debug("Monitoring go2rtc API") + try: + while True: + await client.streams.list() + await asyncio.sleep(10) + except Exception as err: + _LOGGER.debug("go2rtc API did not reply", exc_info=True) + raise Go2RTCWatchdogError("API error") from err + + async def _stop_watchdog(self) -> None: + """Handle watchdog stop request.""" + tasks: list[asyncio.Task] = [] + if watchdog_task := self._watchdog_task: + self._watchdog_task = None + tasks.append(watchdog_task) + watchdog_task.cancel() + for task in self._watchdog_tasks: + tasks.append(task) + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + async def stop(self) -> None: + """Stop the server and abort the watchdog task.""" + _LOGGER.debug("Server stop requested") + await self._stop_watchdog() + await self._stop() + + async def _stop(self) -> None: """Stop the server.""" if self._process: _LOGGER.debug("Stopping go2rtc server") process = self._process self._process = None - process.terminate() + with suppress(ProcessLookupError): + process.terminate() try: await asyncio.wait_for(process.wait(), timeout=_TERMINATE_TIMEOUT) except TimeoutError: _LOGGER.warning("Go2rtc server didn't terminate gracefully. Killing it") - process.kill() + with suppress(ProcessLookupError): + process.kill() else: _LOGGER.debug("Go2rtc server has been stopped") diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index b299c28c557bb..495d42114f119 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -18,6 +18,7 @@ def rest_client() -> Generator[AsyncMock]: patch( "homeassistant.components.go2rtc.Go2RtcRestClient", ) as mock_client, + patch("homeassistant.components.go2rtc.server.Go2RtcRestClient", mock_client), ): client = mock_client.return_value client.streams = Mock(spec_set=_StreamClient) diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index 42f3f5e098d33..1410fbeb6c331 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -161,3 +161,100 @@ async def test_server_failed_to_start( stderr=subprocess.STDOUT, close_fds=False, ) + + +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_server_restart_process_exit( + hass: HomeAssistant, + mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, + server: Server, +) -> None: + """Test that the server is restarted when it exits.""" + evt = asyncio.Event() + + async def wait_event() -> None: + await evt.wait() + + mock_create_subprocess.return_value.wait.side_effect = wait_event + + await server.start() + mock_create_subprocess.assert_awaited_once() + mock_create_subprocess.reset_mock() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + mock_create_subprocess.assert_not_awaited() + + evt.set() + await asyncio.sleep(0.1) + mock_create_subprocess.assert_awaited_once() + + await server.stop() + + +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_server_restart_process_error( + hass: HomeAssistant, + mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, + server: Server, +) -> None: + """Test that the server is restarted on error.""" + mock_create_subprocess.return_value.wait.side_effect = [Exception, None, None, None] + + await server.start() + mock_create_subprocess.assert_awaited_once() + mock_create_subprocess.reset_mock() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + mock_create_subprocess.assert_awaited_once() + + await server.stop() + + +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_server_restart_api_error( + hass: HomeAssistant, + mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, + server: Server, +) -> None: + """Test that the server is restarted on error.""" + rest_client.streams.list.side_effect = Exception + + await server.start() + mock_create_subprocess.assert_awaited_once() + mock_create_subprocess.reset_mock() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + mock_create_subprocess.assert_awaited_once() + + await server.stop() + + +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_server_restart_error( + hass: HomeAssistant, + mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error handling when exception is raised during restart.""" + rest_client.streams.list.side_effect = Exception + mock_create_subprocess.return_value.terminate.side_effect = [Exception, None] + + await server.start() + mock_create_subprocess.assert_awaited_once() + mock_create_subprocess.reset_mock() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + mock_create_subprocess.assert_awaited_once() + + assert "Unexpected error when restarting go2rtc server" in caplog.text + + await server.stop() From df35c8e707a6a1d8c31a0cc20604645857e20127 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 4 Nov 2024 13:58:12 +0100 Subject: [PATCH 0098/1070] Update go2rtc stream if stream_source is not matching (#129804) --- homeassistant/components/go2rtc/__init__.py | 18 ++++++++++-------- tests/components/go2rtc/conftest.py | 3 ++- tests/components/go2rtc/test_init.py | 12 ++++++++++++ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 013c094dc23eb..5be1dbc1a4841 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -203,15 +203,17 @@ async def async_handle_async_webrtc_offer( self._session, self._url, source=camera.entity_id ) + if not (stream_source := await camera.stream_source()): + send_message( + WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") + ) + return + streams = await self._rest_client.streams.list() - if camera.entity_id not in streams: - if not (stream_source := await camera.stream_source()): - send_message( - WebRTCError( - "go2rtc_webrtc_offer_failed", "Camera has no stream source" - ) - ) - return + + if (stream := streams.get(camera.entity_id)) is None or not any( + stream_source == producer.url for producer in stream.producers + ): await self._rest_client.streams.add(camera.entity_id, stream_source) @callback diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 495d42114f119..87c68989fd284 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -21,7 +21,8 @@ def rest_client() -> Generator[AsyncMock]: patch("homeassistant.components.go2rtc.server.Go2RtcRestClient", mock_client), ): client = mock_client.return_value - client.streams = Mock(spec_set=_StreamClient) + client.streams = streams = Mock(spec_set=_StreamClient) + streams.list.return_value = {} client.webrtc = Mock(spec_set=_WebRTCClient) yield client diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 1e73525fbe306..847de248aaf4f 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -239,6 +239,18 @@ async def test() -> None: rest_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream") + # Stream exists but the source is different + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + entity_id: Stream([Producer("rtsp://different")]) + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + rest_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream") + # If the stream is already added, the stream should not be added again. rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { From 4784199038e1b8b090770fcaec2d3cb8815b1a88 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Nov 2024 13:59:10 +0100 Subject: [PATCH 0099/1070] Fix aborting flows for single config entry integrations (#129805) --- homeassistant/config_entries.py | 1 + tests/test_config_entries.py | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f533a62e75361..ec0a559c76f3f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1457,6 +1457,7 @@ async def async_finish_flow( or progress_unique_id == DEFAULT_DISCOVERY_UNIQUE_ID ): self.async_abort(progress_flow_id) + continue # Abort any flows in progress for the same handler # when integration allows only one config entry diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index e3f1d110ac011..822dca559a807 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5843,8 +5843,20 @@ async def async_step_user(self, user_input=None): assert result["translation_domain"] == HOMEASSISTANT_DOMAIN +@pytest.mark.parametrize( + ("flow_1_unique_id", "flow_2_unique_id"), + [ + (None, None), + ("very_unique", "very_unique"), + (None, config_entries.DEFAULT_DISCOVERY_UNIQUE_ID), + ("very_unique", config_entries.DEFAULT_DISCOVERY_UNIQUE_ID), + ], +) async def test_in_progress_get_canceled_when_entry_is_created( - hass: HomeAssistant, manager: config_entries.ConfigEntries + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + flow_1_unique_id: str | None, + flow_2_unique_id: str | None, ) -> None: """Test that we abort all in progress flows when a new entry is created on a single instance only integration.""" integration = loader.Integration( @@ -5872,6 +5884,15 @@ async def async_step_user(self, user_input=None): if user_input is not None: return self.async_create_entry(title="Test Title", data=user_input) + await self.async_set_unique_id(flow_1_unique_id, raise_on_progress=False) + return self.async_show_form(step_id="user") + + async def async_step_zeroconfg(self, user_input=None): + """Test user step.""" + if user_input is not None: + return self.async_create_entry(title="Test Title", data=user_input) + + await self.async_set_unique_id(flow_2_unique_id, raise_on_progress=False) return self.async_show_form(step_id="user") with ( From 6d561a9796a91d4e28976e6ebd177d61e60bd5c9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 14:21:26 +0100 Subject: [PATCH 0100/1070] Remove deprecated property setters in option flows (#129773) --- homeassistant/components/anthropic/config_flow.py | 1 - homeassistant/components/cast/config_flow.py | 5 ++--- homeassistant/components/deconz/config_flow.py | 11 ++++------- homeassistant/components/demo/config_flow.py | 7 +------ homeassistant/components/generic/config_flow.py | 5 ++--- .../google_generative_ai_conversation/config_flow.py | 1 - .../components/here_travel_time/config_flow.py | 5 ++--- homeassistant/components/hive/config_flow.py | 1 - homeassistant/components/homekit/config_flow.py | 5 ++--- .../components/hvv_departures/config_flow.py | 6 ++---- homeassistant/components/iss/config_flow.py | 11 ++++------- .../components/keenetic_ndms2/config_flow.py | 5 ++--- homeassistant/components/knx/config_flow.py | 1 - homeassistant/components/nina/config_flow.py | 3 +-- homeassistant/components/nmap_tracker/config_flow.py | 8 ++------ homeassistant/components/ollama/config_flow.py | 5 ++--- .../components/openai_conversation/config_flow.py | 1 - homeassistant/components/plex/config_flow.py | 2 -- homeassistant/components/purpleair/config_flow.py | 5 ++--- homeassistant/components/risco/config_flow.py | 1 - homeassistant/components/sia/config_flow.py | 6 ++---- homeassistant/components/somfy_mylink/config_flow.py | 7 ++----- .../components/speedtestdotnet/config_flow.py | 5 ++--- homeassistant/components/tankerkoenig/config_flow.py | 5 ++--- homeassistant/components/unifi/config_flow.py | 8 +------- homeassistant/components/zha/config_flow.py | 2 -- homeassistant/components/zwave_js/config_flow.py | 5 ++--- 27 files changed, 39 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 5ea167090c611..fa43a3c4bccc1 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -121,7 +121,6 @@ class AnthropicOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.config_entry = config_entry self.last_rendered_recommended = config_entry.options.get( CONF_RECOMMENDED, False ) diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index 0ebfa553f629a..03a3f2ea1f84f 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -41,7 +41,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> CastOptionsFlowHandler: """Get the options flow for this handler.""" - return CastOptionsFlowHandler(config_entry) + return CastOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -109,9 +109,8 @@ def _get_data(self): class CastOptionsFlowHandler(OptionsFlow): """Handle Google Cast options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize Google Cast options flow.""" - self.config_entry = config_entry self.updated_config: dict[str, Any] = {} async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 3fb025b4d99d6..6332c56a08a8d 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -74,9 +74,11 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> DeconzOptionsFlowHandler: """Get the options flow for this handler.""" - return DeconzOptionsFlowHandler(config_entry) + return DeconzOptionsFlowHandler() def __init__(self) -> None: """Initialize the deCONZ config flow.""" @@ -299,11 +301,6 @@ class DeconzOptionsFlowHandler(OptionsFlow): gateway: DeconzHub - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize deCONZ options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py index 241f62bed69d2..2b27689bdaf81 100644 --- a/homeassistant/components/demo/config_flow.py +++ b/homeassistant/components/demo/config_flow.py @@ -35,7 +35,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Set the config entry up from yaml.""" @@ -45,11 +45,6 @@ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResu class OptionsFlowHandler(OptionsFlow): """Handle options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 7b10cdfb64b93..8bd238fd0e645 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -324,7 +324,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> GenericOptionsFlowHandler: """Get the options flow for this handler.""" - return GenericOptionsFlowHandler(config_entry) + return GenericOptionsFlowHandler() def check_for_existing(self, options: dict[str, Any]) -> bool: """Check whether an existing entry is using the same URLs.""" @@ -409,9 +409,8 @@ async def async_step_user_confirm_still( class GenericOptionsFlowHandler(OptionsFlow): """Handle Generic IP Camera options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize Generic IP Camera options flow.""" - self.config_entry = config_entry self.preview_cam: dict[str, Any] = {} self.user_input: dict[str, Any] = {} diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index bccc7d1fb8470..83eec25ed1520 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -163,7 +163,6 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.config_entry = config_entry self.last_rendered_recommended = config_entry.options.get( CONF_RECOMMENDED, False ) diff --git a/homeassistant/components/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py index 4376ae793c050..c2b70de148c43 100644 --- a/homeassistant/components/here_travel_time/config_flow.py +++ b/homeassistant/components/here_travel_time/config_flow.py @@ -113,7 +113,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> HERETravelTimeOptionsFlow: """Get the options flow.""" - return HERETravelTimeOptionsFlow(config_entry) + return HERETravelTimeOptionsFlow() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -297,9 +297,8 @@ async def async_step_destination_entity( class HERETravelTimeOptionsFlow(OptionsFlow): """Handle HERE Travel Time options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize HERE Travel Time options flow.""" - self.config_entry = config_entry self._config: dict[str, Any] = {} async def async_step_init( diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index d6be2d1efabd5..a997954f4ccd4 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -182,7 +182,6 @@ class HiveOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Hive options flow.""" self.hive = None - self.config_entry = config_entry self.interval = config_entry.options.get(CONF_SCAN_INTERVAL, 120) async def async_step_init( diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index a63e365ead7c3..53db777482161 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -362,15 +362,14 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle a option flow for homekit.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize options flow.""" - self.config_entry = config_entry self.hk_options: dict[str, Any] = {} self.included_cameras: list[str] = [] diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index 3e1b98d9a3827..536b8f18259e2 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -141,16 +141,14 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Options flow handler.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize HVV Departures options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) self.departure_filters: dict[str, Any] = {} async def async_step_init( diff --git a/homeassistant/components/iss/config_flow.py b/homeassistant/components/iss/config_flow.py index 9cc533f5cc574..567618a768067 100644 --- a/homeassistant/components/iss/config_flow.py +++ b/homeassistant/components/iss/config_flow.py @@ -1,5 +1,7 @@ """Config flow to configure iss component.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.config_entries import ( @@ -23,9 +25,9 @@ class ISSConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_user(self, user_input=None) -> ConfigFlowResult: """Handle a flow initialized by the user.""" @@ -42,11 +44,6 @@ async def async_step_user(self, user_input=None) -> ConfigFlowResult: class OptionsFlowHandler(OptionsFlow): """Config flow options handler for iss.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - async def async_step_init(self, user_input=None) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index 69e81bf292db0..d11fedac38556 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -55,7 +55,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> KeeneticOptionsFlowHandler: """Get the options flow for this handler.""" - return KeeneticOptionsFlowHandler(config_entry) + return KeeneticOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -138,9 +138,8 @@ async def async_step_ssdp( class KeeneticOptionsFlowHandler(OptionsFlow): """Handle options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize options flow.""" - self.config_entry = config_entry self._interface_options: dict[str, str] = {} async def async_step_init( diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 4a71c60082410..feeb7626577ba 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -770,7 +770,6 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize KNX options flow.""" - self.config_entry = config_entry super().__init__(initial_data=config_entry.data) # type: ignore[arg-type] @callback diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index dd4319d566b83..a1ba9ae0c61cf 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -171,8 +171,7 @@ class OptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.config_entry = config_entry - self.data = dict(self.config_entry.data) + self.data = dict(config_entry.data) self._all_region_codes_sorted: dict[str, str] = {} self.regions: dict[str, dict[str, Any]] = {} diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index b724dca1a8151..36645278baeb5 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -141,10 +141,6 @@ async def _async_build_schema_with_user_input( class OptionsFlowHandler(OptionsFlow): """Handle a option flow for homekit.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.options = dict(config_entry.options) - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -213,6 +209,6 @@ def _async_is_unique_host_list(self, user_input: dict[str, Any]) -> bool: @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 65b8efaf525c6..1024a824c25bf 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -207,9 +207,8 @@ class OllamaOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.config_entry = config_entry - self.url: str = self.config_entry.data[CONF_URL] - self.model: str = self.config_entry.data[CONF_MODEL] + self.url: str = config_entry.data[CONF_URL] + self.model: str = config_entry.data[CONF_MODEL] async def async_step_init( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index c6b8487ad0de4..2a1764e6b5e94 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -115,7 +115,6 @@ class OpenAIOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.config_entry = config_entry self.last_rendered_recommended = config_entry.options.get( CONF_RECOMMENDED, False ) diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index fcd5751effb35..2206931080422 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -import copy import logging from typing import TYPE_CHECKING, Any @@ -385,7 +384,6 @@ class PlexOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Plex options flow.""" - self.options = copy.deepcopy(dict(config_entry.options)) self.server_id = config_entry.data[CONF_SERVER_IDENTIFIER] async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 6337431ecea31..3ca7870b3cb37 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -209,7 +209,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> PurpleAirOptionsFlowHandler: """Define the config flow to handle options.""" - return PurpleAirOptionsFlowHandler(config_entry) + return PurpleAirOptionsFlowHandler() async def async_step_by_coordinates( self, user_input: dict[str, Any] | None = None @@ -315,10 +315,9 @@ async def async_step_user( class PurpleAirOptionsFlowHandler(OptionsFlow): """Handle a PurpleAir options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize.""" self._flow_data: dict[str, Any] = {} - self.config_entry = config_entry @property def settings_schema(self) -> vol.Schema: diff --git a/homeassistant/components/risco/config_flow.py b/homeassistant/components/risco/config_flow.py index 8f88c7c30a388..f7365d354147b 100644 --- a/homeassistant/components/risco/config_flow.py +++ b/homeassistant/components/risco/config_flow.py @@ -220,7 +220,6 @@ class RiscoOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize.""" - self.config_entry = config_entry self._data = {**DEFAULT_OPTIONS, **config_entry.options} def _options_schema(self) -> vol.Schema: diff --git a/homeassistant/components/sia/config_flow.py b/homeassistant/components/sia/config_flow.py index cb451133d418f..c421151f7bb6a 100644 --- a/homeassistant/components/sia/config_flow.py +++ b/homeassistant/components/sia/config_flow.py @@ -103,7 +103,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> SIAOptionsFlowHandler: """Get the options flow for this handler.""" - return SIAOptionsFlowHandler(config_entry) + return SIAOptionsFlowHandler() def __init__(self) -> None: """Initialize the config flow.""" @@ -179,10 +179,8 @@ def _update_data(self, user_input: dict[str, Any]) -> None: class SIAOptionsFlowHandler(OptionsFlow): """Handle SIA options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize SIA options flow.""" - self.config_entry = config_entry - self.options = deepcopy(dict(config_entry.options)) self.hub: SIAHub | None = None self.accounts_todo: list = [] diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index 705db43362eef..f92c4909dd5d0 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -from copy import deepcopy import logging from typing import Any @@ -122,16 +121,14 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle a option flow for somfy_mylink.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize options flow.""" - self.config_entry = config_entry - self.options = deepcopy(dict(config_entry.options)) self._target_id: str | None = None @callback diff --git a/homeassistant/components/speedtestdotnet/config_flow.py b/homeassistant/components/speedtestdotnet/config_flow.py index dc64448bbefac..3bfd4eb6e4a05 100644 --- a/homeassistant/components/speedtestdotnet/config_flow.py +++ b/homeassistant/components/speedtestdotnet/config_flow.py @@ -30,7 +30,7 @@ def async_get_options_flow( config_entry: SpeedTestConfigEntry, ) -> SpeedTestOptionsFlowHandler: """Get the options flow for this handler.""" - return SpeedTestOptionsFlowHandler(config_entry) + return SpeedTestOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -48,9 +48,8 @@ async def async_step_user( class SpeedTestOptionsFlowHandler(OptionsFlow): """Handle SpeedTest options.""" - def __init__(self, config_entry: SpeedTestConfigEntry) -> None: + def __init__(self) -> None: """Initialize options flow.""" - self.config_entry = config_entry self._servers: dict = {} async def async_step_init( diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index b13bfa1fa36cb..509f293665dcc 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -74,7 +74,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -236,9 +236,8 @@ def _create_entry( class OptionsFlowHandler(OptionsFlow): """Handle an options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize options flow.""" - self.config_entry = config_entry self._stations: dict[str, str] = {} async def async_step_init( diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index f36edc8a8885f..44969191fe67e 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -38,7 +38,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac -from . import UnifiConfigEntry from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, @@ -82,7 +81,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> UnifiOptionsFlowHandler: """Get the options flow for this handler.""" - return UnifiOptionsFlowHandler(config_entry) + return UnifiOptionsFlowHandler() def __init__(self) -> None: """Initialize the UniFi Network flow.""" @@ -248,11 +247,6 @@ class UnifiOptionsFlowHandler(OptionsFlow): hub: UnifiHub - def __init__(self, config_entry: UnifiConfigEntry) -> None: - """Initialize UniFi Network options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 20eb006eb74c9..1c7e0d105c419 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -680,8 +680,6 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" super().__init__() - self.config_entry = config_entry - self._radio_mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] self._radio_mgr.device_settings = config_entry.data[CONF_DEVICE] self._radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]] diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 7eb887c8dcf53..36f208e18d57e 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -366,7 +366,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Return the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -725,10 +725,9 @@ def _async_create_entry_from_vars(self) -> ConfigFlowResult: class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): """Handle an options flow for Z-Wave JS.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Set up the options flow.""" super().__init__() - self.config_entry = config_entry self.original_addon_config: dict[str, Any] | None = None self.revert_reason: str | None = None From ff621d5bf3406213f87a09515cd5e74843145fd4 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Mon, 4 Nov 2024 14:45:20 +0100 Subject: [PATCH 0101/1070] Bump lcn-frontend to 0.2.1 (#129457) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 8f499adabe038..6ce41a2d08d59 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.7.24", "lcn-frontend==0.2.0"] + "requirements": ["pypck==0.7.24", "lcn-frontend==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 52cbbe340c160..cea9be138dca1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1265,7 +1265,7 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.0 +lcn-frontend==0.2.1 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa8c40a6bacb5..866d9de4cb93d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1061,7 +1061,7 @@ lacrosse-view==1.0.3 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.0 +lcn-frontend==0.2.1 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 From 41a81cbf1506a00d44cd8aa2807b6919e391c1cb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 4 Nov 2024 14:48:28 +0100 Subject: [PATCH 0102/1070] Switch back to av 13.1.0 (#129699) --- .../components/generic/manifest.json | 2 +- homeassistant/components/stream/core.py | 8 ++- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/stream/recorder.py | 16 +++--- homeassistant/components/stream/worker.py | 50 +++++++++---------- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 8 +-- requirements_test_all.txt | 8 +-- 8 files changed, 47 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index b19d6d6293ec5..b02a8fa25203c 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["ha-av==10.1.1", "Pillow==10.4.0"] + "requirements": ["av==13.1.0", "Pillow==10.4.0"] } diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index bce16ff4c8713..4184b23b9a07e 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -27,8 +27,7 @@ ) if TYPE_CHECKING: - from av import Packet - from av.video.codeccontext import VideoCodecContext + from av import Packet, VideoCodecContext from homeassistant.components.camera import DynamicStreamSettings @@ -509,9 +508,8 @@ def _generate_image(self, width: int | None, height: int | None) -> None: frames = self._codec_context.decode(None) break except EOFError: - _LOGGER.debug("Codec context needs flushing, attempting to reopen") - self._codec_context.close() - self._codec_context.open() + _LOGGER.debug("Codec context needs flushing") + self._codec_context.flush_buffers() else: _LOGGER.debug("Unable to decode keyframe") return diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 00387d97b8350..23494a067442a 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.5", "ha-av==10.1.1", "numpy==1.26.4"] + "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==1.26.4"] } diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index d28982ea30de6..a24440e6d19c0 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -107,7 +107,7 @@ def write_segment(segment: Segment) -> None: # Create output on first segment if not output: container_options: dict[str, str] = { - "video_track_timescale": str(int(1 / source_v.time_base)), + "video_track_timescale": str(int(1 / source_v.time_base)), # type: ignore[operator] "movflags": "frag_keyframe+empty_moov", "min_frag_duration": str(self.stream_settings.min_segment_duration), } @@ -132,21 +132,23 @@ def write_segment(segment: Segment) -> None: last_stream_id = segment.stream_id pts_adjuster["video"] = int( (running_duration - source.start_time) - / (av.time_base * source_v.time_base) + / (av.time_base * source_v.time_base) # type: ignore[operator] ) if source_a: pts_adjuster["audio"] = int( (running_duration - source.start_time) - / (av.time_base * source_a.time_base) + / (av.time_base * source_a.time_base) # type: ignore[operator] ) # Remux video for packet in source.demux(): - if packet.dts is None: + if packet.pts is None: continue - packet.pts += pts_adjuster[packet.stream.type] - packet.dts += pts_adjuster[packet.stream.type] - packet.stream = output_v if packet.stream.type == "video" else output_a + packet.pts += pts_adjuster[packet.stream.type] # type: ignore[operator] + packet.dts += pts_adjuster[packet.stream.type] # type: ignore[operator] + stream = output_v if packet.stream.type == "video" else output_a + assert stream + packet.stream = stream output.mux(packet) running_duration += source.duration - source.start_time diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 42bfa13f13ec7..8c9bb1b8e9e29 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -16,7 +16,6 @@ import av.audio import av.container import av.stream -import av.video from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -53,8 +52,8 @@ class StreamWorkerError(Exception): def redact_av_error_string(err: av.FFmpegError) -> str: """Return an error string with credentials redacted from the url.""" - parts = [str(err.type), err.strerror] - if err.filename is not None: + parts = [str(err.type), err.strerror] # type: ignore[attr-defined] + if err.filename: parts.append(redact_credentials(err.filename)) return ", ".join(parts) @@ -130,19 +129,19 @@ class StreamMuxer: _segment_start_dts: int _memory_file: BytesIO _av_output: av.container.OutputContainer - _output_video_stream: av.video.VideoStream + _output_video_stream: av.VideoStream _output_audio_stream: av.audio.AudioStream | None _segment: Segment | None # the following 2 member variables are used for Part formation _memory_file_pos: int - _part_start_dts: int + _part_start_dts: float def __init__( self, hass: HomeAssistant, - video_stream: av.video.VideoStream, + video_stream: av.VideoStream, audio_stream: av.audio.AudioStream | None, - audio_bsf: av.BitStreamFilter | None, + audio_bsf: str | None, stream_state: StreamState, stream_settings: StreamSettings, ) -> None: @@ -161,11 +160,11 @@ def make_new_av( self, memory_file: BytesIO, sequence: int, - input_vstream: av.video.VideoStream, + input_vstream: av.VideoStream, input_astream: av.audio.AudioStream | None, ) -> tuple[ av.container.OutputContainer, - av.video.VideoStream, + av.VideoStream, av.audio.AudioStream | None, ]: """Make a new av OutputContainer and add output streams.""" @@ -182,7 +181,7 @@ def make_new_av( # in test_durations "avoid_negative_ts": "make_non_negative", "fragment_index": str(sequence + 1), - "video_track_timescale": str(int(1 / input_vstream.time_base)), + "video_track_timescale": str(int(1 / input_vstream.time_base)), # type: ignore[operator] # Only do extra fragmenting if we are using ll_hls # Let ffmpeg do the work using frag_duration # Fragment durations may exceed the 15% allowed variance but it seems ok @@ -233,12 +232,11 @@ def make_new_av( output_astream = None if input_astream: if self._audio_bsf: - self._audio_bsf_context = self._audio_bsf.create() - self._audio_bsf_context.set_input_stream(input_astream) - output_astream = container.add_stream( - template=self._audio_bsf_context or input_astream - ) - return container, output_vstream, output_astream + self._audio_bsf_context = av.BitStreamFilterContext( + self._audio_bsf, input_astream + ) + output_astream = container.add_stream(template=input_astream) + return container, output_vstream, output_astream # type: ignore[return-value] def reset(self, video_dts: int) -> None: """Initialize a new stream segment.""" @@ -279,11 +277,11 @@ def mux_packet(self, packet: av.Packet) -> None: self._part_has_keyframe |= packet.is_keyframe elif packet.stream == self._input_audio_stream: + assert self._output_audio_stream if self._audio_bsf_context: - self._audio_bsf_context.send(packet) - while packet := self._audio_bsf_context.recv(): - packet.stream = self._output_audio_stream - self._av_output.mux(packet) + for audio_packet in self._audio_bsf_context.filter(packet): + audio_packet.stream = self._output_audio_stream + self._av_output.mux(audio_packet) return packet.stream = self._output_audio_stream self._av_output.mux(packet) @@ -465,7 +463,7 @@ def is_valid(self, packet: av.Packet) -> bool: """Validate the packet timestamp based on ordering within the stream.""" # Discard packets missing DTS. Terminate if too many are missing. if packet.dts is None: - if self._missing_dts >= MAX_MISSING_DTS: + if self._missing_dts >= MAX_MISSING_DTS: # type: ignore[unreachable] raise StreamWorkerError( f"No dts in {MAX_MISSING_DTS+1} consecutive packets" ) @@ -492,7 +490,7 @@ def is_keyframe(packet: av.Packet) -> Any: def get_audio_bitstream_filter( packets: Iterator[av.Packet], audio_stream: Any -) -> av.BitStreamFilterContext | None: +) -> str | None: """Return the aac_adtstoasc bitstream filter if ADTS AAC is detected.""" if not audio_stream: return None @@ -509,7 +507,7 @@ def get_audio_bitstream_filter( _LOGGER.debug( "ADTS AAC detected. Adding aac_adtstoaac bitstream filter" ) - return av.BitStreamFilter("aac_adtstoasc") + return "aac_adtstoasc" break return None @@ -547,7 +545,7 @@ def stream_worker( audio_stream = None # Some audio streams do not have a profile and throw errors when remuxing if audio_stream and audio_stream.profile is None: - audio_stream = None + audio_stream = None # type: ignore[unreachable] # Disable ll-hls for hls inputs if container.format.name == "hls": for field in fields(StreamSettings): @@ -562,8 +560,8 @@ def stream_worker( stream_state.diagnostics.set_value("audio_codec", audio_stream.name) dts_validator = TimestampValidator( - int(1 / video_stream.time_base), - int(1 / audio_stream.time_base) if audio_stream else 1, + int(1 / video_stream.time_base), # type: ignore[operator] + int(1 / audio_stream.time_base) if audio_stream else 1, # type: ignore[operator] ) container_packets = PeekIterator( filter(dts_validator.is_valid, container.demux((video_stream, audio_stream))) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 42bda4d3c4027..aa8fecc73a5e6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,6 +13,7 @@ async-interrupt==1.2.0 async-upnp-client==0.41.0 atomicwrites-homeassistant==1.4.1 attrs==24.2.0 +av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 bleak-retry-connector==3.6.0 @@ -27,7 +28,6 @@ cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 go2rtc-client==0.0.1b3 -ha-av==10.1.1 ha-ffmpeg==3.2.1 habluetooth==3.6.0 hass-nabucasa==0.83.0 diff --git a/requirements_all.txt b/requirements_all.txt index cea9be138dca1..10e4dd4fefbf4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -526,6 +526,10 @@ autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble automower-ble==0.2.0 +# homeassistant.components.generic +# homeassistant.components.stream +av==13.1.0 + # homeassistant.components.avea # avea==1.5.1 @@ -1064,10 +1068,6 @@ guppy3==3.1.4.post1 # homeassistant.components.iaqualink h2==4.1.0 -# homeassistant.components.generic -# homeassistant.components.stream -ha-av==10.1.1 - # homeassistant.components.ffmpeg ha-ffmpeg==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 866d9de4cb93d..fb67a3f12ca9f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -481,6 +481,10 @@ autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble automower-ble==0.2.0 +# homeassistant.components.generic +# homeassistant.components.stream +av==13.1.0 + # homeassistant.components.axis axis==63 @@ -902,10 +906,6 @@ guppy3==3.1.4.post1 # homeassistant.components.iaqualink h2==4.1.0 -# homeassistant.components.generic -# homeassistant.components.stream -ha-av==10.1.1 - # homeassistant.components.ffmpeg ha-ffmpeg==3.2.1 From 02750452dfd2f8392ea07e40c2a3ecef5f87e08d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Nov 2024 15:01:37 +0100 Subject: [PATCH 0103/1070] Update Spotify state after mutation (#129607) --- .../components/spotify/media_player.py | 29 +++++++++++++++++-- tests/components/spotify/conftest.py | 7 +++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index dce200bc59883..7687936fe4cd9 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -2,10 +2,11 @@ from __future__ import annotations -from collections.abc import Callable +import asyncio +from collections.abc import Awaitable, Callable, Coroutine import datetime as dt import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Concatenate from spotifyaio import ( Device, @@ -63,6 +64,7 @@ REPEAT_MODE_MAPPING_TO_SPOTIFY = { value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items() } +AFTER_REQUEST_SLEEP = 1 async def async_setup_entry( @@ -93,6 +95,19 @@ def wrapper(self: SpotifyMediaPlayer) -> _R | None: return wrapper +def async_refresh_after[_T: SpotifyEntity, **_P]( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Define a wrapper to yield and refresh after.""" + + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + await func(self, *args, **kwargs) + await asyncio.sleep(AFTER_REQUEST_SLEEP) + await self.coordinator.async_refresh() + + return _async_wrap + + class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity): """Representation of a Spotify controller.""" @@ -267,30 +282,37 @@ def repeat(self) -> RepeatMode | None: return None return REPEAT_MODE_MAPPING_TO_HA.get(self.currently_playing.repeat_mode) + @async_refresh_after async def async_set_volume_level(self, volume: float) -> None: """Set the volume level.""" await self.coordinator.client.set_volume(int(volume * 100)) + @async_refresh_after async def async_media_play(self) -> None: """Start or resume playback.""" await self.coordinator.client.start_playback() + @async_refresh_after async def async_media_pause(self) -> None: """Pause playback.""" await self.coordinator.client.pause_playback() + @async_refresh_after async def async_media_previous_track(self) -> None: """Skip to previous track.""" await self.coordinator.client.previous_track() + @async_refresh_after async def async_media_next_track(self) -> None: """Skip to next track.""" await self.coordinator.client.next_track() + @async_refresh_after async def async_media_seek(self, position: float) -> None: """Send seek command.""" await self.coordinator.client.seek_track(int(position * 1000)) + @async_refresh_after async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: @@ -334,6 +356,7 @@ async def async_play_media( await self.coordinator.client.start_playback(**kwargs) + @async_refresh_after async def async_select_source(self, source: str) -> None: """Select playback device.""" for device in self.devices.data: @@ -341,10 +364,12 @@ async def async_select_source(self, source: str) -> None: await self.coordinator.client.transfer_playback(device.device_id) return + @async_refresh_after async def async_set_shuffle(self, shuffle: bool) -> None: """Enable/Disable shuffle mode.""" await self.coordinator.client.set_shuffle(state=shuffle) + @async_refresh_after async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" if repeat not in REPEAT_MODE_MAPPING_TO_SPOTIFY: diff --git a/tests/components/spotify/conftest.py b/tests/components/spotify/conftest.py index 5d86045e5a846..d3fc418f1cd5d 100644 --- a/tests/components/spotify/conftest.py +++ b/tests/components/spotify/conftest.py @@ -84,6 +84,13 @@ async def setup_credentials(hass: HomeAssistant) -> None: ) +@pytest.fixture(autouse=True) +async def patch_sleep() -> Generator[AsyncMock]: + """Fixture to setup credentials.""" + with patch("homeassistant.components.spotify.media_player.AFTER_REQUEST_SLEEP", 0): + yield + + @pytest.fixture def mock_spotify() -> Generator[AsyncMock]: """Mock the Spotify API.""" From d0c45b18573c80530f381fe467d673878b578839 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 4 Nov 2024 15:31:44 +0100 Subject: [PATCH 0104/1070] Bump python-bsblan to 1.2.1 (#129635) * Bump python-bsblan dependency to version 1.1.0 * Bump python-bsblan dependency to version 1.2.0 * Bump python-bsblan dependency to version 1.2.1 * Update test diagnostics snapshots to use numeric values and add error handling --- homeassistant/components/bsblan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bsblan/snapshots/test_diagnostics.ambr | 78 ++++++++++++++++--- 4 files changed, 70 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 5b10f46bf1311..aa9c03abf4ad2 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==1.0.0"] + "requirements": ["python-bsblan==1.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 10e4dd4fefbf4..80db6a022d208 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2308,7 +2308,7 @@ python-awair==0.2.4 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==1.0.0 +python-bsblan==1.2.1 # homeassistant.components.clementine python-clementine-remote==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb67a3f12ca9f..324321456e9c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1859,7 +1859,7 @@ python-MotionMount==2.2.0 python-awair==0.2.4 # homeassistant.components.bsblan -python-bsblan==1.0.0 +python-bsblan==1.2.1 # homeassistant.components.ecobee python-ecobee-api==0.2.20 diff --git a/tests/components/bsblan/snapshots/test_diagnostics.ambr b/tests/components/bsblan/snapshots/test_diagnostics.ambr index e033b2417d261..9fabd373205cf 100644 --- a/tests/components/bsblan/snapshots/test_diagnostics.ambr +++ b/tests/components/bsblan/snapshots/test_diagnostics.ambr @@ -6,67 +6,103 @@ 'current_temperature': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Room temp 1 actual value', + 'precision': None, + 'readonly': 1, + 'readwrite': 0, 'unit': '°C', - 'value': '18.6', + 'value': 18.6, }), 'outside_temperature': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Outside temp sensor local', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '°C', - 'value': '6.1', + 'value': 6.1, }), }), 'state': dict({ 'current_temperature': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Room temp 1 actual value', + 'precision': None, + 'readonly': 1, + 'readwrite': 0, 'unit': '°C', - 'value': '18.6', + 'value': 18.6, }), 'hvac_action': dict({ 'data_type': 1, 'desc': 'Raumtemp’begrenzung', + 'error': 0, 'name': 'Status heating circuit 1', + 'precision': None, + 'readonly': 1, + 'readwrite': 0, 'unit': '', - 'value': '122', + 'value': 122, }), 'hvac_mode': dict({ 'data_type': 1, 'desc': 'Komfort', + 'error': 0, 'name': 'Operating mode', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '', 'value': 'heat', }), 'hvac_mode2': dict({ 'data_type': 1, 'desc': 'Reduziert', + 'error': 0, 'name': 'Operating mode', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '', - 'value': '2', + 'value': 2, }), 'room1_temp_setpoint_boost': dict({ 'data_type': 1, 'desc': 'Boost', + 'error': 0, 'name': 'Room 1 Temp Setpoint Boost', + 'precision': None, + 'readonly': 1, + 'readwrite': 0, 'unit': '°C', 'value': '22.5', }), 'room1_thermostat_mode': dict({ 'data_type': 1, 'desc': 'Kein Bedarf', + 'error': 0, 'name': 'Raumthermostat 1', + 'precision': None, + 'readonly': 1, + 'readwrite': 0, 'unit': '', - 'value': '0', + 'value': 0, }), 'target_temperature': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Room temperature Comfort setpoint', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '°C', - 'value': '18.5', + 'value': 18.5, }), }), }), @@ -80,21 +116,33 @@ 'controller_family': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Device family', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '', - 'value': '211', + 'value': 211, }), 'controller_variant': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Device variant', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '', - 'value': '127', + 'value': 127, }), 'device_identification': dict({ 'data_type': 7, 'desc': '', + 'error': 0, 'name': 'Gerte-Identifikation', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '', 'value': 'RVS21.831F/127', }), @@ -103,16 +151,24 @@ 'max_temp': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Summer/winter changeover temp heat circuit 1', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '°C', - 'value': '20.0', + 'value': 20.0, }), 'min_temp': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Room temp frost protection setpoint', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '°C', - 'value': '8.0', + 'value': 8.0, }), }), }) From 7691991a93cdc598aa8cf2e95b69fbbedf8258ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Nov 2024 08:33:15 -0600 Subject: [PATCH 0105/1070] Small cleanups to the websocket command phase (#129712) * Small cleanups to the websocket command phase - Remove unused argument - Avoid multiple NamedTuple property lookups * Update homeassistant/components/websocket_api/http.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Apply suggestions from code review * touch ups --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/websocket_api/http.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 29dc611335065..11aca19bab9ef 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -36,6 +36,8 @@ from .messages import message_to_json_bytes from .util import describe_request +CLOSE_MSG_TYPES = {WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING} + if TYPE_CHECKING: from .connection import ActiveConnection @@ -344,7 +346,7 @@ async def async_handle(self) -> web.WebSocketResponse: try: connection = await self._async_handle_auth_phase(auth, send_bytes_text) self._async_increase_writer_limit(writer) - await self._async_websocket_command_phase(connection, send_bytes_text) + await self._async_websocket_command_phase(connection) except asyncio.CancelledError: logger.debug("%s: Connection cancelled", self.description) raise @@ -454,9 +456,7 @@ def _async_increase_writer_limit(self, writer: WebSocketWriter) -> None: writer._limit = 2**20 # noqa: SLF001 async def _async_websocket_command_phase( - self, - connection: ActiveConnection, - send_bytes_text: Callable[[bytes], Coroutine[Any, Any, None]], + self, connection: ActiveConnection ) -> None: """Handle the command phase of the websocket connection.""" wsock = self._wsock @@ -467,24 +467,26 @@ async def _async_websocket_command_phase( # Command phase while not wsock.closed: msg = await wsock.receive() + msg_type = msg.type + msg_data = msg.data - if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): + if msg_type in CLOSE_MSG_TYPES: break - if msg.type is WSMsgType.BINARY: - if len(msg.data) < 1: + if msg_type is WSMsgType.BINARY: + if len(msg_data) < 1: raise Disconnect("Received invalid binary message.") - handler = msg.data[0] - payload = msg.data[1:] + handler = msg_data[0] + payload = msg_data[1:] async_handle_binary(handler, payload) continue - if msg.type is not WSMsgType.TEXT: + if msg_type is not WSMsgType.TEXT: raise Disconnect("Received non-Text message.") try: - command_msg_data = json_loads(msg.data) + command_msg_data = json_loads(msg_data) except ValueError as ex: raise Disconnect("Received invalid JSON.") from ex From 4ac35d40cd47071a52207ca1ecb69c695a2e196c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Nov 2024 15:45:29 +0100 Subject: [PATCH 0106/1070] Fix create flow logic for single config entry integrations (#129807) * Fix create flow logic for single config entry integrations * Adjust MQTT test --- homeassistant/config_entries.py | 8 +++++++- tests/components/mqtt/test_config_flow.py | 2 +- tests/test_config_entries.py | 8 ++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ec0a559c76f3f..f9e72a723a44d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1278,7 +1278,13 @@ async def async_init( # a single config entry, but which already has an entry if ( source not in {SOURCE_IGNORE, SOURCE_REAUTH, SOURCE_RECONFIGURE} - and self.config_entries.async_has_entries(handler, include_ignore=False) + and ( + self.config_entries.async_has_entries(handler, include_ignore=False) + or ( + self.config_entries.async_has_entries(handler, include_ignore=True) + and source != SOURCE_USER + ) + ) and await _support_single_config_entry_only(self.hass, handler) ): return ConfigFlowResult( diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 5a95b9c571258..e99063b088b99 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -444,7 +444,7 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: ) assert result assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result.get("reason") == "single_instance_allowed" async def test_hassio_confirm( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 822dca559a807..700840eb90edf 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5729,6 +5729,14 @@ async def test_starting_config_flow_on_single_config_entry( None, {"type": data_entry_flow.FlowResultType.ABORT, "reason": "not_implemented"}, ), + ( + {"source": config_entries.SOURCE_ZEROCONF}, + None, + { + "type": data_entry_flow.FlowResultType.ABORT, + "reason": "single_instance_allowed", + }, + ), ], ) async def test_starting_config_flow_on_single_config_entry_2( From 365f8046ace7a4d7aa401fcf0aba54dd8347f3e3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:09:50 +0100 Subject: [PATCH 0107/1070] Use new helper properties in yeelight options flow (#129791) --- homeassistant/components/yeelight/config_flow.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 5438414ea6163..7a3a0a2f10000 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -58,9 +58,11 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlowHandler: """Return the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() def __init__(self) -> None: """Initialize the config flow.""" @@ -296,16 +298,12 @@ async def _async_try_connect( class OptionsFlowHandler(OptionsFlow): """Handle a option flow for Yeelight.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize the option flow.""" - self._config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - data = self._config_entry.data - options = self._config_entry.options + data = self.config_entry.data + options = self.config_entry.options detected_model = data.get(CONF_DETECTED_MODEL) model = options[CONF_MODEL] or detected_model From a5f3c434e079a24037052cd854ff06a67820ad51 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:46:38 +0100 Subject: [PATCH 0108/1070] Improve exceptions in habitica cast skill action (#129603) * Raise a different exception when entry not loaded * adjust type hints * move `get_config_entry` to services module --- homeassistant/components/habitica/services.py | 25 +++++++++++++------ .../components/habitica/strings.json | 5 +++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 8ca80ff63ad5f..440e2d4fb2365 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -9,6 +9,7 @@ from aiohttp import ClientResponseError import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_NAME, CONF_NAME from homeassistant.core import ( HomeAssistant, @@ -54,6 +55,21 @@ ) +def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: + """Return config entry or raise if not found or not loaded.""" + if not (entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_found", + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + ) + return entry + + def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Habitica integration.""" @@ -86,14 +102,7 @@ async def handle_api_call(call: ServiceCall) -> None: async def cast_skill(call: ServiceCall) -> ServiceResponse: """Skill action.""" - entry: HabiticaConfigEntry | None - if not ( - entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY]) - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="entry_not_found", - ) + entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data skill = { "pickpocket": {"spellId": "pickPocket", "cost": "10 MP"}, diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 62b01260010a3..390dc3ba9ae18 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -219,7 +219,10 @@ "message": "Unable to cast skill, your character does not have the skill or spell {skill}." }, "entry_not_found": { - "message": "The selected character is currently not configured or loaded in Home Assistant." + "message": "The selected character is not configured in Home Assistant." + }, + "entry_not_loaded": { + "message": "The selected character is currently not loaded or disabled in Home Assistant." }, "task_not_found": { "message": "Unable to cast skill, could not find the task {task}" From 400b377aa82016464bcd436c0e42f572b9ec5bd7 Mon Sep 17 00:00:00 2001 From: Jake Martin Date: Mon, 4 Nov 2024 15:55:02 +0000 Subject: [PATCH 0109/1070] Bump monzopy to 1.4.2 (#129726) * Bump monzopy to 1.4.0 * Bump to 1.4.2 --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/monzo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/monzo/manifest.json b/homeassistant/components/monzo/manifest.json index d9d17eb8abcf3..7038cecd7ea0f 100644 --- a/homeassistant/components/monzo/manifest.json +++ b/homeassistant/components/monzo/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/monzo", "iot_class": "cloud_polling", - "requirements": ["monzopy==1.3.2"] + "requirements": ["monzopy==1.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 80db6a022d208..7e9e3810c699f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1385,7 +1385,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.3.1 # homeassistant.components.monzo -monzopy==1.3.2 +monzopy==1.4.2 # homeassistant.components.mopeka mopeka-iot-ble==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 324321456e9c9..27712f445111a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1154,7 +1154,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.3.1 # homeassistant.components.monzo -monzopy==1.3.2 +monzopy==1.4.2 # homeassistant.components.mopeka mopeka-iot-ble==0.8.0 From 0c25252d9f7d2d5e5bc101712b6566df8d59a4e7 Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Mon, 4 Nov 2024 11:20:15 -0500 Subject: [PATCH 0110/1070] Bump ayla-iot-unofficial to 1.4.3 (#129743) Upgrade to ayla-iot-unofficial v1.4.3 --- homeassistant/components/fujitsu_fglair/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json index 1c7b9b0b469af..f7f3af8d03750 100644 --- a/homeassistant/components/fujitsu_fglair/manifest.json +++ b/homeassistant/components/fujitsu_fglair/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair", "iot_class": "cloud_polling", - "requirements": ["ayla-iot-unofficial==1.4.2"] + "requirements": ["ayla-iot-unofficial==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7e9e3810c699f..522d81c2e0a6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -540,7 +540,7 @@ av==13.1.0 axis==63 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.2 +ayla-iot-unofficial==1.4.3 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27712f445111a..dbe3c7dd37b86 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -489,7 +489,7 @@ av==13.1.0 axis==63 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.2 +ayla-iot-unofficial==1.4.3 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 From f141f5f9088c585052bdf42508c42dcb440c13ec Mon Sep 17 00:00:00 2001 From: Max Muth Date: Mon, 4 Nov 2024 17:26:12 +0100 Subject: [PATCH 0111/1070] Update codeowners of Fritz integration (#129595) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- CODEOWNERS | 4 ++-- homeassistant/components/fritz/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 99cfefa81c604..d039097fc82fd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -496,8 +496,8 @@ build.json @home-assistant/supervisor /tests/components/freebox/ @hacf-fr @Quentame /homeassistant/components/freedompro/ @stefano055415 /tests/components/freedompro/ @stefano055415 -/homeassistant/components/fritz/ @mammuth @AaronDavidSchneider @chemelli74 @mib1185 -/tests/components/fritz/ @mammuth @AaronDavidSchneider @chemelli74 @mib1185 +/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185 +/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185 /homeassistant/components/fritzbox/ @mib1185 @flabbamann /tests/components/fritzbox/ @mib1185 @flabbamann /homeassistant/components/fritzbox_callmonitor/ @cdce8p diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 35250d9d34d23..27aa42d9b2c1c 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -1,7 +1,7 @@ { "domain": "fritz", "name": "AVM FRITZ!Box Tools", - "codeowners": ["@mammuth", "@AaronDavidSchneider", "@chemelli74", "@mib1185"], + "codeowners": ["@AaronDavidSchneider", "@chemelli74", "@mib1185"], "config_flow": true, "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/fritz", From 0579d565dd90f71958fba6f4f28f181ee474a6b8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:35:47 +0100 Subject: [PATCH 0112/1070] Fix incorrect description placeholders in azure event hub (#129803) --- homeassistant/components/azure_event_hub/config_flow.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/azure_event_hub/config_flow.py b/homeassistant/components/azure_event_hub/config_flow.py index 046851e6926bc..60ac9bff8cd49 100644 --- a/homeassistant/components/azure_event_hub/config_flow.py +++ b/homeassistant/components/azure_event_hub/config_flow.py @@ -124,7 +124,9 @@ async def async_step_conn_string( step_id=STEP_CONN_STRING, data_schema=CONN_STRING_SCHEMA, errors=errors, - description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME], + description_placeholders={ + "event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME] + }, last_step=True, ) @@ -144,7 +146,9 @@ async def async_step_sas( step_id=STEP_SAS, data_schema=SAS_SCHEMA, errors=errors, - description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME], + description_placeholders={ + "event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME] + }, last_step=True, ) From f1a2c8be4bd6e4a3928c7c95024766f83caf0894 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 17:36:25 +0100 Subject: [PATCH 0113/1070] Stop recording of non-changing attributes in threshold (#129541) --- homeassistant/components/threshold/binary_sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 5f1639ff2e1ee..da7d92f7051e8 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -151,6 +151,9 @@ class ThresholdSensor(BinarySensorEntity): """Representation of a Threshold sensor.""" _attr_should_poll = False + _unrecorded_attributes = frozenset( + {ATTR_ENTITY_ID, ATTR_HYSTERESIS, ATTR_LOWER, ATTR_TYPE, ATTR_UPPER} + ) def __init__( self, From 689260f581bb9b62652f1739d1258529d808a4b9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 Nov 2024 11:37:14 -0500 Subject: [PATCH 0114/1070] Fix ESPHome dashboard check (#129812) --- homeassistant/components/esphome/manager.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index afbe109d5bc4a..007b4e791e17e 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -570,8 +570,10 @@ def _async_setup_device_registry( configuration_url = None if device_info.webserver_port > 0: configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" - elif (dashboard := async_get_dashboard(hass)) and dashboard.data.get( - device_info.name + elif ( + (dashboard := async_get_dashboard(hass)) + and dashboard.data + and dashboard.data.get(device_info.name) ): configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}" From 2626a74840d7d625867c97e67dc57ac70b526282 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 18:00:31 +0100 Subject: [PATCH 0115/1070] Fix translations in honeywell (#129823) --- homeassistant/components/honeywell/strings.json | 3 +++ tests/components/honeywell/test_config_flow.py | 4 ---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index aa6e53620a55b..a64f1a6fce06a 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -16,6 +16,9 @@ } } }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" diff --git a/tests/components/honeywell/test_config_flow.py b/tests/components/honeywell/test_config_flow.py index b1c0b28f5372f..ed9c86f5e1038 100644 --- a/tests/components/honeywell/test_config_flow.py +++ b/tests/components/honeywell/test_config_flow.py @@ -120,10 +120,6 @@ async def test_create_option_entry( } -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.honeywell.config.abort.reauth_successful"], -) async def test_reauth_flow(hass: HomeAssistant) -> None: """Test a successful reauth flow.""" From a2a3f59e658fb308c5bc67f2968c1f28f1b02f80 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 18:01:39 +0100 Subject: [PATCH 0116/1070] Fix missing translation in jewish_calendar (#129822) --- homeassistant/components/jewish_calendar/strings.json | 3 ++- tests/components/jewish_calendar/test_config_flow.py | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/jewish_calendar/strings.json b/homeassistant/components/jewish_calendar/strings.json index e5367b5819e71..1b7b86c005687 100644 --- a/homeassistant/components/jewish_calendar/strings.json +++ b/homeassistant/components/jewish_calendar/strings.json @@ -27,7 +27,8 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "options": { diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index 2a490270fdfce..dbd4ecd802d9e 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -168,10 +168,6 @@ async def test_options_reconfigure( ) -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.jewish_calendar.config.abort.reconfigure_successful"], -) async def test_reconfigure( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: From 6897b24c1093077a9ab7952b5e2c6c59fc768013 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 18:03:37 +0100 Subject: [PATCH 0117/1070] Fix translations in homeworks (#129824) --- homeassistant/components/homeworks/strings.json | 3 +++ tests/components/homeworks/test_config_flow.py | 8 -------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json index a9dcab2f1e0a2..977e6be8afdd2 100644 --- a/homeassistant/components/homeworks/strings.json +++ b/homeassistant/components/homeworks/strings.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, "error": { "connection_error": "Could not connect to the controller.", "credentials_needed": "The controller needs credentials.", diff --git a/tests/components/homeworks/test_config_flow.py b/tests/components/homeworks/test_config_flow.py index cca09c10e70de..e8c4ab15b3def 100644 --- a/tests/components/homeworks/test_config_flow.py +++ b/tests/components/homeworks/test_config_flow.py @@ -235,10 +235,6 @@ async def test_user_flow_cannot_connect( assert result["step_id"] == "user" -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.homeworks.config.abort.reconfigure_successful"], -) async def test_reconfigure_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock ) -> None: @@ -326,10 +322,6 @@ async def test_reconfigure_flow_flow_duplicate( assert result["errors"] == {"base": "duplicated_host_port"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.homeworks.config.abort.reconfigure_successful"], -) async def test_reconfigure_flow_flow_no_change( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock ) -> None: From 9c8d8fef16dbffeaa8913c74f4c96e11161e7ad0 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:06:45 -0800 Subject: [PATCH 0118/1070] Suggest area for NUT based on device location (#129770) --- homeassistant/components/nut/__init__.py | 5 ++++- tests/components/nut/test_init.py | 27 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 6bbe19e8f3c1e..b4e53c1380ca0 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -132,6 +132,7 @@ async def async_update_data() -> dict[str, str]: model=data.device_info.model, sw_version=data.device_info.firmware, serial_number=data.device_info.serial, + suggested_area=data.device_info.device_location, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -211,6 +212,7 @@ class NUTDeviceInfo: model: str | None = None firmware: str | None = None serial: str | None = None + device_location: str | None = None class PyNUTData: @@ -271,7 +273,8 @@ def _get_device_info(self) -> NUTDeviceInfo | None: model = _model_from_status(self._status) firmware = _firmware_from_status(self._status) serial = _serial_from_status(self._status) - return NUTDeviceInfo(manufacturer, model, firmware, serial) + device_location: str | None = self._status.get("device.location") + return NUTDeviceInfo(manufacturer, model, firmware, serial, device_location) async def _async_get_status(self) -> dict[str, str]: """Get the ups status from NUT.""" diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index cd56c209a368e..d5d85daa3362c 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -120,3 +120,30 @@ async def test_serial_number(hass: HomeAssistant) -> None: assert device_entry is not None assert device_entry.serial_number == mock_serial_number + + +async def test_device_location(hass: HomeAssistant) -> None: + """Test for suggested location on device.""" + mock_serial_number = "A00000000000" + mock_device_location = "XYZ Location" + await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={ + "ups.serial": mock_serial_number, + "device.location": mock_device_location, + }, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value=[], + ) + + device_registry = dr.async_get(hass) + assert device_registry is not None + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_serial_number)} + ) + + assert device_entry is not None + assert device_entry.suggested_area == mock_device_location From 0278735dbfc4e64b146faed2e3ac3c997703e782 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 4 Nov 2024 12:07:11 -0500 Subject: [PATCH 0119/1070] Use translated errors in Russound RIO (#129820) --- homeassistant/components/russound_rio/__init__.py | 11 +++++++++-- homeassistant/components/russound_rio/entity.py | 7 ++++++- homeassistant/components/russound_rio/strings.json | 8 ++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index ba53f6794e3ca..784629ea0bc2a 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONNECT_TIMEOUT, RUSSOUND_RIO_EXCEPTIONS +from .const import CONNECT_TIMEOUT, DOMAIN, RUSSOUND_RIO_EXCEPTIONS PLATFORMS = [Platform.MEDIA_PLAYER] @@ -43,7 +43,14 @@ async def _connection_update_callback( async with asyncio.timeout(CONNECT_TIMEOUT): await client.connect() except RUSSOUND_RIO_EXCEPTIONS as err: - raise ConfigEntryNotReady(f"Error while connecting to {host}:{port}") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="entry_cannot_connect", + translation_placeholders={ + "host": host, + "port": port, + }, + ) from err entry.runtime_data = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py index 23b196ecb2f7b..0233305bb1f56 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -26,7 +26,12 @@ async def decorator(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None await func(self, *args, **kwargs) except RUSSOUND_RIO_EXCEPTIONS as exc: raise HomeAssistantError( - f"Error executing {func.__name__} on entity {self.entity_id}," + translation_domain=DOMAIN, + translation_key="command_error", + translation_placeholders={ + "function_name": func.__name__, + "entity_id": self.entity_id, + }, ) from exc return decorator diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json index c105dcafae214..b8c29c08301a7 100644 --- a/homeassistant/components/russound_rio/strings.json +++ b/homeassistant/components/russound_rio/strings.json @@ -33,5 +33,13 @@ "title": "[%key:component::russound_rio::issues::deprecated_yaml_import_issue_cannot_connect::title%]", "description": "[%key:component::russound_rio::issues::deprecated_yaml_import_issue_cannot_connect::description%]" } + }, + "exceptions": { + "entry_cannot_connect": { + "message": "Error while connecting to {host}:{port}" + }, + "command_error": { + "message": "Error executing {function_name} on entity {entity_id}" + } } } From f6e36615d6d87b0752d7da907f083554c3b14469 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:39:39 +0000 Subject: [PATCH 0120/1070] Bump python-kasa to 0.7.7 (#129817) Bump tplink dependency python-kasa to 0.7.7 --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index a79857e9e7e42..cb8a55b3db21a 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -301,5 +301,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.7.6"] + "requirements": ["python-kasa[speedups]==0.7.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 522d81c2e0a6b..b35b82cf3c372 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2356,7 +2356,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.6 +python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay python-linkplay==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dbe3c7dd37b86..5d2d1875c196f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1883,7 +1883,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.6 +python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay python-linkplay==0.0.17 From df796d432e2e7ef9f6c0ab3af5d54d196830cceb Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 4 Nov 2024 18:41:37 +0100 Subject: [PATCH 0121/1070] Remove all ice_servers on native sync WebRTC cameras (#129819) --- homeassistant/components/camera/__init__.py | 21 +++--- tests/components/camera/conftest.py | 75 ++++++++++++++++++++- tests/components/camera/test_init.py | 60 +---------------- tests/components/camera/test_webrtc.py | 23 +++++++ 4 files changed, 110 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 1feb7dffd3bb9..47d8b9dfbd0b0 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -827,16 +827,17 @@ def async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration: """Return the WebRTC client configuration and extend it with the registered ice servers.""" config = self._async_get_webrtc_client_configuration() - ice_servers = [ - server - for servers in self.hass.data.get(DATA_ICE_SERVERS, []) - for server in servers() - ] - config.configuration.ice_servers.extend(ice_servers) - - config.get_candidates_upfront = ( - self._webrtc_sync_offer or self._legacy_webrtc_provider is not None - ) + if not self._webrtc_sync_offer: + # Until 2024.11, the frontend was not resolving any ice servers + # The async approach was added 2024.11 and new integrations need to use it + ice_servers = [ + server + for servers in self.hass.data.get(DATA_ICE_SERVERS, []) + for server in servers() + ] + config.configuration.ice_servers.extend(ice_servers) + + config.get_candidates_upfront = self._legacy_webrtc_provider is not None return config diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index bec44704ec2bc..a88cd898e335a 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -1,13 +1,14 @@ """Test helpers for camera.""" from collections.abc import AsyncGenerator, Generator -from unittest.mock import AsyncMock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, PropertyMock, patch import pytest from homeassistant.components import camera from homeassistant.components.camera.const import StreamType from homeassistant.components.camera.webrtc import WebRTCAnswer, WebRTCSendMessage +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -15,6 +16,15 @@ from .common import STREAM_SOURCE, WEBRTC_ANSWER +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, + setup_test_component_platform, +) + @pytest.fixture(autouse=True) async def setup_homeassistant(hass: HomeAssistant) -> None: @@ -142,3 +152,66 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]: return_value=STREAM_SOURCE, ) as mock_stream_source: yield mock_stream_source + + +@pytest.fixture +async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None: + """Initialize a test camera with native sync WebRTC support.""" + + # Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer + # and native support is checked by verify the function "async_handle_web_rtc_offer" was + # overwritten(implemented) or not + class MockCamera(camera.Camera): + """Mock Camera Entity.""" + + _attr_name = "Test" + _attr_supported_features: camera.CameraEntityFeature = ( + camera.CameraEntityFeature.STREAM + ) + _attr_frontend_stream_type: camera.StreamType = camera.StreamType.WEB_RTC + + async def stream_source(self) -> str | None: + return STREAM_SOURCE + + async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: + return WEBRTC_ANSWER + + domain = "test" + + entry = MockConfigEntry(domain=domain) + entry.add_to_hass(hass) + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [camera.DOMAIN] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload test config entry.""" + await hass.config_entries.async_forward_entry_unload( + config_entry, camera.DOMAIN + ) + return True + + mock_integration( + hass, + MockModule( + domain, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + setup_test_component_platform( + hass, camera.DOMAIN, [MockCamera()], from_config_entry=True + ) + mock_platform(hass, f"{domain}.config_flow", Mock()) + + with mock_config_flow(domain, ConfigFlow): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index e7279f6084825..0a173065564e5 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -25,7 +25,6 @@ ) from homeassistant.components.camera.helper import get_camera_from_entity_id from homeassistant.components.websocket_api import TYPE_RESULT -from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STARTED, @@ -38,18 +37,12 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, WEBRTC_ANSWER, mock_turbo_jpeg +from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg from tests.common import ( - MockConfigEntry, - MockModule, async_fire_time_changed, help_test_all, import_and_test_deprecated_constant_enum, - mock_config_flow, - mock_integration, - mock_platform, - setup_test_component_platform, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -986,62 +979,13 @@ async def test_camera_capabilities_hls( ) +@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer") async def test_camera_capabilities_webrtc( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: """Test WebRTC camera capabilities.""" - # Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer - # Camera capabilities are determined by by checking if the function was overwritten(implemented) or not - class MockCamera(camera.Camera): - """Mock Camera Entity.""" - - _attr_name = "Test" - _attr_supported_features: camera.CameraEntityFeature = ( - camera.CameraEntityFeature.STREAM - ) - - async def stream_source(self) -> str | None: - return STREAM_SOURCE - - async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: - return WEBRTC_ANSWER - - domain = "test" - - entry = MockConfigEntry(domain=domain) - entry.add_to_hass(hass) - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_unload_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Unload test config entry.""" - await hass.config_entries.async_forward_entry_unload(config_entry, DOMAIN) - return True - - mock_integration( - hass, - MockModule( - domain, - async_setup_entry=async_setup_entry_init, - async_unload_entry=async_unload_entry_init, - ), - ) - setup_test_component_platform(hass, DOMAIN, [MockCamera()], from_config_entry=True) - mock_platform(hass, f"{domain}.config_flow", Mock()) - - with mock_config_flow(domain, ConfigFlow): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await _test_capabilities( hass, hass_ws_client, "camera.test", {StreamType.WEB_RTC}, {StreamType.WEB_RTC} ) diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 27c50848ebfa7..2970a41408c98 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -393,6 +393,29 @@ def get_ice_server() -> list[RTCIceServer]: } +@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer") +async def test_ws_get_client_config_sync_offer( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test get WebRTC client config, when camera is supporting sync offer.""" + await async_setup_component(hass, "camera", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "camera/webrtc/get_client_config", "entity_id": "camera.test"} + ) + msg = await client.receive_json() + + # Assert WebSocket response + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == { + "configuration": {}, + "getCandidatesUpfront": False, + } + + @pytest.mark.usefixtures("mock_camera_webrtc") async def test_ws_get_client_config_custom_config( hass: HomeAssistant, hass_ws_client: WebSocketGenerator From 7fd261347b72e7f17c02e518b127e49eaaa92835 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:49:19 +0100 Subject: [PATCH 0122/1070] Update charset-normalizer to 3.4.0 (#129821) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index aa8fecc73a5e6..ec1976c802cd8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -164,7 +164,7 @@ get-mac==1000000000.0.0 # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. -charset-normalizer==3.2.0 +charset-normalizer==3.4.0 # dacite: Ensure we have a version that is able to handle type unions for # Roborock, NAM, Brother, and GIOS. diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 36962ce1fe947..0f8354e1f6006 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -179,7 +179,7 @@ # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. -charset-normalizer==3.2.0 +charset-normalizer==3.4.0 # dacite: Ensure we have a version that is able to handle type unions for # Roborock, NAM, Brother, and GIOS. From 81735b7b47959326b35312e38fd91fb07cd6a757 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:50:00 +0100 Subject: [PATCH 0123/1070] Use new helper properties in konnected options flow (#129778) --- homeassistant/components/konnected/config_flow.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index 3f1ef99c6fb2f..65dd7cf39b3d4 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -402,9 +402,10 @@ class OptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.entry = config_entry - self.model = self.entry.data[CONF_MODEL] - self.current_opt = self.entry.options or self.entry.data[CONF_DEFAULT_OPTIONS] + self.model = config_entry.data[CONF_MODEL] + self.current_opt = ( + config_entry.options or config_entry.data[CONF_DEFAULT_OPTIONS] + ) # as config proceeds we'll build up new options and then replace what's in the config entry self.new_opt: dict[str, Any] = {CONF_IO: {}} @@ -475,7 +476,7 @@ async def async_step_options_io( ), description_placeholders={ "model": KONN_PANEL_MODEL_NAMES[self.model], - "host": self.entry.data[CONF_HOST], + "host": self.config_entry.data[CONF_HOST], }, errors=errors, ) @@ -511,7 +512,7 @@ async def async_step_options_io( ), description_placeholders={ "model": KONN_PANEL_MODEL_NAMES[self.model], - "host": self.entry.data[CONF_HOST], + "host": self.config_entry.data[CONF_HOST], }, errors=errors, ) @@ -571,7 +572,7 @@ async def async_step_options_io_ext( ), description_placeholders={ "model": KONN_PANEL_MODEL_NAMES[self.model], - "host": self.entry.data[CONF_HOST], + "host": self.config_entry.data[CONF_HOST], }, errors=errors, ) From 8870b657d1815c6fd04559616c5b6116d3e5b464 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:54:22 +0100 Subject: [PATCH 0124/1070] Use new helper properties in hyperion options flow (#129777) --- .../components/hyperion/config_flow.py | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 161c531328de5..b2b7dbdf53110 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -424,24 +424,22 @@ async def async_step_confirm( @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> HyperionOptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> HyperionOptionsFlow: """Get the Hyperion Options flow.""" - return HyperionOptionsFlow(config_entry) + return HyperionOptionsFlow() class HyperionOptionsFlow(OptionsFlow): """Hyperion options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize a Hyperion options flow.""" - self._config_entry = config_entry - def _create_client(self) -> client.HyperionClient: """Create and connect a client instance.""" return create_hyperion_client( - self._config_entry.data[CONF_HOST], - self._config_entry.data[CONF_PORT], - token=self._config_entry.data.get(CONF_TOKEN), + self.config_entry.data[CONF_HOST], + self.config_entry.data[CONF_PORT], + token=self.config_entry.data.get(CONF_TOKEN), ) async def async_step_init( @@ -470,8 +468,7 @@ async def async_step_init( return self.async_create_entry(title="", data=user_input) default_effect_show_list = list( - set(effects) - - set(self._config_entry.options.get(CONF_EFFECT_HIDE_LIST, [])) + set(effects) - set(self.config_entry.options.get(CONF_EFFECT_HIDE_LIST, [])) ) return self.async_show_form( @@ -480,7 +477,7 @@ async def async_step_init( { vol.Optional( CONF_PRIORITY, - default=self._config_entry.options.get( + default=self.config_entry.options.get( CONF_PRIORITY, DEFAULT_PRIORITY ), ): vol.All(vol.Coerce(int), vol.Range(min=0, max=255)), From d180ff417dcdd56b02105d9136deec47969ba58f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:55:01 +0100 Subject: [PATCH 0125/1070] Cleanup deprecated OptionsFlowWithConfigEntry (part 3) (#129756) --- homeassistant/config_entries.py | 8 ++++++-- homeassistant/helpers/schema_config_entry_flow.py | 9 +++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f9e72a723a44d..0682d46924d99 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3127,6 +3127,10 @@ def config_entry(self, value: ConfigEntry) -> None: ) self._config_entry = value + def initialize_options(self, config_entry: ConfigEntry) -> None: + """Initialize the options to a mutable copy of the config entry options.""" + self._options = deepcopy(dict(config_entry.options)) + @property def options(self) -> dict[str, Any]: """Return a mutable copy of the config entry options. @@ -3135,7 +3139,7 @@ def options(self) -> dict[str, Any]: can only be referenced after initialisation. """ if not hasattr(self, "_options"): - self._options = deepcopy(dict(self.config_entry.options)) + self.initialize_options(self.config_entry) return self._options @options.setter @@ -3161,7 +3165,7 @@ def __init__(self, config_entry: ConfigEntry) -> None: "inherits from OptionsFlowWithConfigEntry, which is deprecated " "and will stop working in 2025.12", error_if_integration=False, - error_if_core=False, + error_if_core=True, ) diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 7463c9945b24b..58a44f9682d52 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -16,7 +16,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.data_entry_flow import UnknownHandler @@ -403,7 +402,7 @@ def async_create_entry( ) -class SchemaOptionsFlowHandler(OptionsFlowWithConfigEntry): +class SchemaOptionsFlowHandler(OptionsFlow): """Handle a schema based options flow.""" def __init__( @@ -422,10 +421,8 @@ def __init__( options, which is the union of stored options and user input from the options flow steps. """ - super().__init__(config_entry) - self._common_handler = SchemaCommonFlowHandler( - self, options_flow, self._options - ) + self.initialize_options(config_entry) + self._common_handler = SchemaCommonFlowHandler(self, options_flow, self.options) self._async_options_flow_finished = async_options_flow_finished for step in options_flow: From cc4fae10f5c7e58cd894b84fd72308b2feb9af44 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:55:49 +0100 Subject: [PATCH 0126/1070] Cleanup deprecated OptionsFlowWithConfigEntry (part 2) (#129754) --- homeassistant/components/androidtv/config_flow.py | 7 +++---- homeassistant/components/androidtv_remote/config_flow.py | 6 +++--- homeassistant/components/elevenlabs/config_flow.py | 6 ++---- homeassistant/components/onkyo/config_flow.py | 6 ++---- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index af6f1d14dcd47..132ed96a96f18 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -13,7 +13,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_PORT from homeassistant.core import callback @@ -186,13 +186,12 @@ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: return OptionsFlowHandler(config_entry) -class OptionsFlowHandler(OptionsFlowWithConfigEntry): +class OptionsFlowHandler(OptionsFlow): """Handle an option flow for Android Debug Bridge.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - super().__init__(config_entry) - + self.initialize_options(config_entry) self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {}) self._state_det_rules: dict[str, Any] = self.options.setdefault( CONF_STATE_DETECTION_RULES, {} diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 3512dd5ea65de..962b1c09f1f60 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -20,7 +20,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import callback @@ -221,12 +221,12 @@ def async_get_options_flow( return AndroidTVRemoteOptionsFlowHandler(config_entry) -class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithConfigEntry): +class AndroidTVRemoteOptionsFlowHandler(OptionsFlow): """Android TV Remote options flow.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - super().__init__(config_entry) + self.initialize_options(config_entry) self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {}) self._conf_app_id: str | None = None diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index b596ec05b00c5..6419b1c973ca4 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -14,7 +14,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant @@ -103,13 +102,12 @@ def async_get_options_flow( return ElevenLabsOptionsFlow(config_entry) -class ElevenLabsOptionsFlow(OptionsFlowWithConfigEntry): +class ElevenLabsOptionsFlow(OptionsFlow): """ElevenLabs options flow.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - super().__init__(config_entry) - self.api_key: str = self.config_entry.data[CONF_API_KEY] + self.api_key: str = config_entry.data[CONF_API_KEY] # id -> name self.voices: dict[str, str] = {} self.models: dict[str, str] = {} diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 4c5de362172fc..9ab01b3d9046c 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -11,7 +11,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback @@ -323,13 +322,12 @@ def async_get_options_flow( return OnkyoOptionsFlowHandler(config_entry) -class OnkyoOptionsFlowHandler(OptionsFlowWithConfigEntry): +class OnkyoOptionsFlowHandler(OptionsFlow): """Handle an options flow for Onkyo.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - super().__init__(config_entry) - + self.initialize_options(config_entry) sources_store: dict[str, str] = self.options[OPTION_INPUT_SOURCES] sources = {InputSource(k): v for k, v in sources_store.items()} self.options[OPTION_INPUT_SOURCES] = sources From 91157c21efb76e226510e8c83195214f73fc788d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:59:27 +0100 Subject: [PATCH 0127/1070] Reapply "Fix unused snapshots not triggering failure in CI" (#129311) --- .github/workflows/ci.yaml | 4 + tests/conftest.py | 8 +- tests/syrupy.py | 169 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 02e8b4f180d9f..cae9795d71540 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -949,6 +949,7 @@ jobs: --timeout=9 \ --durations=10 \ --numprocesses auto \ + --snapshot-details \ --dist=loadfile \ ${cov_params[@]} \ -o console_output_style=count \ @@ -1071,6 +1072,7 @@ jobs: -qq \ --timeout=20 \ --numprocesses 1 \ + --snapshot-details \ ${cov_params[@]} \ -o console_output_style=count \ --durations=10 \ @@ -1199,6 +1201,7 @@ jobs: -qq \ --timeout=9 \ --numprocesses 1 \ + --snapshot-details \ ${cov_params[@]} \ -o console_output_style=count \ --durations=0 \ @@ -1345,6 +1348,7 @@ jobs: -qq \ --timeout=9 \ --numprocesses auto \ + --snapshot-details \ ${cov_params[@]} \ -o console_output_style=count \ --durations=0 \ diff --git a/tests/conftest.py b/tests/conftest.py index 10c9a74025602..c60018413e75c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,6 +36,7 @@ import requests_mock import respx from syrupy.assertion import SnapshotAssertion +from syrupy.session import SnapshotSession from homeassistant import block_async_io from homeassistant.exceptions import ServiceNotFound @@ -92,7 +93,7 @@ from homeassistant.util.json import json_loads from .ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS -from .syrupy import HomeAssistantSnapshotExtension +from .syrupy import HomeAssistantSnapshotExtension, override_syrupy_finish from .typing import ( ClientSessionGenerator, MockHAClientWebSocket, @@ -149,6 +150,11 @@ def pytest_configure(config: pytest.Config) -> None: if config.getoption("verbose") > 0: logging.getLogger().setLevel(logging.DEBUG) + # Override default finish to detect unused snapshots despite xdist + # Temporary workaround until it is finalised inside syrupy + # See https://github.com/syrupy-project/syrupy/pull/901 + SnapshotSession.finish = override_syrupy_finish + def pytest_runtest_setup() -> None: """Prepare pytest_socket and freezegun. diff --git a/tests/syrupy.py b/tests/syrupy.py index 268ee59243f0e..a3b3f76306369 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -5,14 +5,22 @@ from contextlib import suppress import dataclasses from enum import IntFlag +import json +import os from pathlib import Path from typing import Any import attr import attrs +import pytest +from syrupy.constants import EXIT_STATUS_FAIL_UNUSED +from syrupy.data import Snapshot, SnapshotCollection, SnapshotCollections from syrupy.extensions.amber import AmberDataSerializer, AmberSnapshotExtension from syrupy.location import PyTestLocation +from syrupy.report import SnapshotReport +from syrupy.session import ItemStatus, SnapshotSession from syrupy.types import PropertyFilter, PropertyMatcher, PropertyPath, SerializableData +from syrupy.utils import is_xdist_controller, is_xdist_worker import voluptuous as vol import voluptuous_serialize @@ -246,3 +254,164 @@ def dirname(cls, *, test_location: PyTestLocation) -> str: """ test_dir = Path(test_location.filepath).parent return str(test_dir.joinpath("snapshots")) + + +# Classes and Methods to override default finish behavior in syrupy +# This is needed to handle the xdist plugin in pytest +# The default implementation does not handle the xdist plugin +# and will not work correctly when running tests in parallel +# with pytest-xdist. +# Temporary workaround until it is finalised inside syrupy +# See https://github.com/syrupy-project/syrupy/pull/901 + + +class _FakePytestObject: + """Fake object.""" + + def __init__(self, collected_item: dict[str, str]) -> None: + """Initialise fake object.""" + self.__module__ = collected_item["modulename"] + self.__name__ = collected_item["methodname"] + + +class _FakePytestItem: + """Fake pytest.Item object.""" + + def __init__(self, collected_item: dict[str, str]) -> None: + """Initialise fake pytest.Item object.""" + self.nodeid = collected_item["nodeid"] + self.name = collected_item["name"] + self.path = Path(collected_item["path"]) + self.obj = _FakePytestObject(collected_item) + + +def _serialize_collections(collections: SnapshotCollections) -> dict[str, Any]: + return { + k: [c.name for c in v] for k, v in collections._snapshot_collections.items() + } + + +def _serialize_report( + report: SnapshotReport, + collected_items: set[pytest.Item], + selected_items: dict[str, ItemStatus], +) -> dict[str, Any]: + return { + "discovered": _serialize_collections(report.discovered), + "created": _serialize_collections(report.created), + "failed": _serialize_collections(report.failed), + "matched": _serialize_collections(report.matched), + "updated": _serialize_collections(report.updated), + "used": _serialize_collections(report.used), + "_collected_items": [ + { + "nodeid": c.nodeid, + "name": c.name, + "path": str(c.path), + "modulename": c.obj.__module__, + "methodname": c.obj.__name__, + } + for c in list(collected_items) + ], + "_selected_items": { + key: status.value for key, status in selected_items.items() + }, + } + + +def _merge_serialized_collections( + collections: SnapshotCollections, json_data: dict[str, list[str]] +) -> None: + if not json_data: + return + for location, names in json_data.items(): + snapshot_collection = SnapshotCollection(location=location) + for name in names: + snapshot_collection.add(Snapshot(name)) + collections.update(snapshot_collection) + + +def _merge_serialized_report(report: SnapshotReport, json_data: dict[str, Any]) -> None: + _merge_serialized_collections(report.discovered, json_data["discovered"]) + _merge_serialized_collections(report.created, json_data["created"]) + _merge_serialized_collections(report.failed, json_data["failed"]) + _merge_serialized_collections(report.matched, json_data["matched"]) + _merge_serialized_collections(report.updated, json_data["updated"]) + _merge_serialized_collections(report.used, json_data["used"]) + for collected_item in json_data["_collected_items"]: + custom_item = _FakePytestItem(collected_item) + if not any( + t.nodeid == custom_item.nodeid and t.name == custom_item.nodeid + for t in report.collected_items + ): + report.collected_items.add(custom_item) + for key, selected_item in json_data["_selected_items"].items(): + if key in report.selected_items: + status = ItemStatus(selected_item) + if status != ItemStatus.NOT_RUN: + report.selected_items[key] = status + else: + report.selected_items[key] = ItemStatus(selected_item) + + +def override_syrupy_finish(self: SnapshotSession) -> int: + """Override the finish method to allow for custom handling.""" + exitstatus = 0 + self.flush_snapshot_write_queue() + self.report = SnapshotReport( + base_dir=self.pytest_session.config.rootpath, + collected_items=self._collected_items, + selected_items=self._selected_items, + assertions=self._assertions, + options=self.pytest_session.config.option, + ) + + needs_xdist_merge = self.update_snapshots or bool( + self.pytest_session.config.option.include_snapshot_details + ) + + if is_xdist_worker(): + if not needs_xdist_merge: + return exitstatus + with open(".pytest_syrupy_worker_count", "w", encoding="utf-8") as f: + f.write(os.getenv("PYTEST_XDIST_WORKER_COUNT")) + with open( + f".pytest_syrupy_{os.getenv("PYTEST_XDIST_WORKER")}_result", + "w", + encoding="utf-8", + ) as f: + json.dump( + _serialize_report( + self.report, self._collected_items, self._selected_items + ), + f, + indent=2, + ) + return exitstatus + if is_xdist_controller(): + return exitstatus + + if needs_xdist_merge: + worker_count = None + try: + with open(".pytest_syrupy_worker_count", encoding="utf-8") as f: + worker_count = f.read() + os.remove(".pytest_syrupy_worker_count") + except FileNotFoundError: + pass + + if worker_count: + for i in range(int(worker_count)): + with open(f".pytest_syrupy_gw{i}_result", encoding="utf-8") as f: + _merge_serialized_report(self.report, json.load(f)) + os.remove(f".pytest_syrupy_gw{i}_result") + + if self.report.num_unused: + if self.update_snapshots: + self.remove_unused_snapshots( + unused_snapshot_collections=self.report.unused, + used_snapshot_collections=self.report.used, + ) + elif not self.warn_unused_snapshots: + exitstatus |= EXIT_STATUS_FAIL_UNUSED + return exitstatus From ca0be3ec8a4fba97c51d7c63645e9537d84754bf Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 19:16:22 +0100 Subject: [PATCH 0128/1070] Use coordinator async_setup in vizio (#129450) --- homeassistant/components/vizio/coordinator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/vizio/coordinator.py b/homeassistant/components/vizio/coordinator.py index 1930828b595d9..a7ca7d7f9ed01 100644 --- a/homeassistant/components/vizio/coordinator.py +++ b/homeassistant/components/vizio/coordinator.py @@ -34,10 +34,9 @@ def __init__(self, hass: HomeAssistant, store: Store[list[dict[str, Any]]]) -> N self.fail_threshold = 10 self.store = store - async def async_config_entry_first_refresh(self) -> None: + async def _async_setup(self) -> None: """Refresh data for the first time when a config entry is setup.""" self.data = await self.store.async_load() or APPS - await super().async_config_entry_first_refresh() async def _async_update_data(self) -> list[dict[str, Any]]: """Update data via library.""" From 6323a078e139b499b5957a2d07da94eb18c7b883 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 19:17:07 +0100 Subject: [PATCH 0129/1070] Set config_entry explicitly in wled coordinator (#129425) --- homeassistant/components/wled/coordinator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index cb39fde5e5a42..8e2855e9f0543 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -49,6 +49,7 @@ def __init__( super().__init__( hass, LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) @@ -133,6 +134,7 @@ def __init__(self, hass: HomeAssistant) -> None: super().__init__( hass, LOGGER, + config_entry=None, name=DOMAIN, update_interval=RELEASES_SCAN_INTERVAL, ) From b8f2583bc3b907efc105e1852b133f018f62ce38 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 19:17:53 +0100 Subject: [PATCH 0130/1070] Set config_entry explicitly in caldav coordinator (#129424) --- homeassistant/components/caldav/calendar.py | 6 +++++- .../components/caldav/coordinator.py | 21 ++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index d9ebe8e73fd50..fb53947a7237c 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -109,6 +109,7 @@ async def async_setup_platform( entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) coordinator = CalDavUpdateCoordinator( hass, + None, calendar=calendar, days=days, include_all_day=True, @@ -126,6 +127,7 @@ async def async_setup_platform( entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) coordinator = CalDavUpdateCoordinator( hass, + None, calendar=calendar, days=days, include_all_day=False, @@ -152,6 +154,7 @@ async def async_setup_entry( async_generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass), CalDavUpdateCoordinator( hass, + entry, calendar=calendar, days=CONFIG_ENTRY_DEFAULT_DAYS, include_all_day=True, @@ -204,7 +207,8 @@ def _handle_coordinator_update(self) -> None: if self._supports_offset: self._attr_extra_state_attributes = { "offset_reached": is_offset_reached( - self._event.start_datetime_local, self.coordinator.offset + self._event.start_datetime_local, + self.coordinator.offset, # type: ignore[arg-type] ) if self._event else False diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index 3a10b56716710..eb09e3f545222 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -6,6 +6,9 @@ from functools import partial import logging import re +from typing import TYPE_CHECKING + +import caldav from homeassistant.components.calendar import CalendarEvent, extract_offset from homeassistant.core import HomeAssistant @@ -14,6 +17,9 @@ from .api import get_attr_value +if TYPE_CHECKING: + from . import CalDavConfigEntry + _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) @@ -23,11 +29,20 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): """Class to utilize the calendar dav client object to get next event.""" - def __init__(self, hass, calendar, days, include_all_day, search): + def __init__( + self, + hass: HomeAssistant, + entry: CalDavConfigEntry | None, + calendar: caldav.Calendar, + days: int, + include_all_day: bool, + search: str | None, + ) -> None: """Set up how we are going to search the WebDav calendar.""" super().__init__( hass, _LOGGER, + config_entry=entry, name=f"CalDAV {calendar.name}", update_interval=MIN_TIME_BETWEEN_UPDATES, ) @@ -35,7 +50,7 @@ def __init__(self, hass, calendar, days, include_all_day, search): self.days = days self.include_all_day = include_all_day self.search = search - self.offset = None + self.offset: timedelta | None = None async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime @@ -109,7 +124,7 @@ async def _async_update_data(self) -> CalendarEvent | None: _start_of_tomorrow = start_of_tomorrow if _start_of_today <= start_dt < _start_of_tomorrow: new_event = event.copy() - new_vevent = new_event.instance.vevent + new_vevent = new_event.instance.vevent # type: ignore[attr-defined] if hasattr(new_vevent, "dtend"): dur = new_vevent.dtend.value - new_vevent.dtstart.value new_vevent.dtend.value = start_dt + dur From 2052579efcd43e3f029aa1a00e30df51ce33d499 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 19:18:36 +0100 Subject: [PATCH 0131/1070] Set config_entry explicitly in todoist coordinator (#129421) --- homeassistant/components/todoist/__init__.py | 2 +- homeassistant/components/todoist/calendar.py | 2 +- homeassistant/components/todoist/coordinator.py | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 60c40b1c03c07..2e30856d0dff9 100644 --- a/homeassistant/components/todoist/__init__.py +++ b/homeassistant/components/todoist/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: token = entry.data[CONF_TOKEN] api = TodoistAPIAsync(token) - coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token) + coordinator = TodoistCoordinator(hass, _LOGGER, entry, SCAN_INTERVAL, api, token) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 31470633cc67e..62f9fafc02afe 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -142,7 +142,7 @@ async def async_setup_platform( project_id_lookup = {} api = TodoistAPIAsync(token) - coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token) + coordinator = TodoistCoordinator(hass, _LOGGER, None, SCAN_INTERVAL, api, token) await coordinator.async_refresh() async def _shutdown_coordinator(_: Event) -> None: diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py index b55680907ac58..2f35741c5ab42 100644 --- a/homeassistant/components/todoist/coordinator.py +++ b/homeassistant/components/todoist/coordinator.py @@ -6,6 +6,7 @@ from todoist_api_python.api_async import TodoistAPIAsync from todoist_api_python.models import Label, Project, Section, Task +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,12 +18,19 @@ def __init__( self, hass: HomeAssistant, logger: logging.Logger, + entry: ConfigEntry | None, update_interval: timedelta, api: TodoistAPIAsync, token: str, ) -> None: """Initialize the Todoist coordinator.""" - super().__init__(hass, logger, name="Todoist", update_interval=update_interval) + super().__init__( + hass, + logger, + config_entry=entry, + name="Todoist", + update_interval=update_interval, + ) self.api = api self._projects: list[Project] | None = None self._labels: list[Label] | None = None From 22f8f117fb40941b06f3794a9afe2f2ec773f403 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Nov 2024 19:22:12 +0100 Subject: [PATCH 0132/1070] Add basic testing framework to LG ThinQ (#127785) Co-authored-by: jangwon.lee Co-authored-by: Joostlek Co-authored-by: YunseonPark-LGE <34848373+YunseonPark-LGE@users.noreply.github.com> Co-authored-by: LG-ThinQ-Integration Co-authored-by: Franck Nijhof --- tests/components/lg_thinq/__init__.py | 14 +- tests/components/lg_thinq/conftest.py | 34 ++- .../fixtures/air_conditioner/device.json | 9 + .../fixtures/air_conditioner/profile.json | 154 +++++++++++++ .../fixtures/air_conditioner/status.json | 43 ++++ .../lg_thinq/snapshots/test_climate.ambr | 86 ++++++++ .../lg_thinq/snapshots/test_event.ambr | 55 +++++ .../lg_thinq/snapshots/test_number.ambr | 113 ++++++++++ .../lg_thinq/snapshots/test_sensor.ambr | 205 ++++++++++++++++++ tests/components/lg_thinq/test_climate.py | 29 +++ tests/components/lg_thinq/test_config_flow.py | 5 +- tests/components/lg_thinq/test_event.py | 29 +++ tests/components/lg_thinq/test_init.py | 26 +++ tests/components/lg_thinq/test_number.py | 29 +++ tests/components/lg_thinq/test_sensor.py | 29 +++ 15 files changed, 853 insertions(+), 7 deletions(-) create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner/device.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner/profile.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner/status.json create mode 100644 tests/components/lg_thinq/snapshots/test_climate.ambr create mode 100644 tests/components/lg_thinq/snapshots/test_event.ambr create mode 100644 tests/components/lg_thinq/snapshots/test_number.ambr create mode 100644 tests/components/lg_thinq/snapshots/test_sensor.ambr create mode 100644 tests/components/lg_thinq/test_climate.py create mode 100644 tests/components/lg_thinq/test_event.py create mode 100644 tests/components/lg_thinq/test_init.py create mode 100644 tests/components/lg_thinq/test_number.py create mode 100644 tests/components/lg_thinq/test_sensor.py diff --git a/tests/components/lg_thinq/__init__.py b/tests/components/lg_thinq/__init__.py index 68ffb960f71e1..a5ba55ab1c938 100644 --- a/tests/components/lg_thinq/__init__.py +++ b/tests/components/lg_thinq/__init__.py @@ -1 +1,13 @@ -"""Tests for the lgthinq integration.""" +"""Tests for the LG ThinQ integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index cae2de61fa4f8..05cb316413790 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -11,7 +11,7 @@ from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT, MOCK_UUID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture def mock_thinq_api_response( @@ -45,6 +45,15 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.lg_thinq.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture def mock_uuid() -> Generator[AsyncMock]: """Mock a uuid.""" @@ -59,22 +68,37 @@ def mock_uuid() -> Generator[AsyncMock]: @pytest.fixture -def mock_thinq_api() -> Generator[AsyncMock]: +def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]: """Mock a thinq api.""" with ( - patch("thinqconnect.ThinQApi", autospec=True) as mock_api, + patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api, patch( "homeassistant.components.lg_thinq.config_flow.ThinQApi", new=mock_api, ), ): thinq_api = mock_api.return_value - thinq_api.async_get_device_list = AsyncMock( - return_value=mock_thinq_api_response(status=200, body={}) + thinq_api.async_get_device_list.return_value = [ + load_json_object_fixture("air_conditioner/device.json", DOMAIN) + ] + thinq_api.async_get_device_profile.return_value = load_json_object_fixture( + "air_conditioner/profile.json", DOMAIN + ) + thinq_api.async_get_device_status.return_value = load_json_object_fixture( + "air_conditioner/status.json", DOMAIN ) yield thinq_api +@pytest.fixture +def mock_thinq_mqtt_client() -> Generator[AsyncMock]: + """Mock a thinq api.""" + with patch( + "homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient", autospec=True + ) as mock_api: + yield mock_api + + @pytest.fixture def mock_invalid_thinq_api(mock_thinq_api: AsyncMock) -> AsyncMock: """Mock an invalid thinq api.""" diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/device.json b/tests/components/lg_thinq/fixtures/air_conditioner/device.json new file mode 100644 index 0000000000000..fb931c6992979 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/device.json @@ -0,0 +1,9 @@ +{ + "deviceId": "MW2-2E247F93-B570-46A6-B827-920E9E10F966", + "deviceInfo": { + "deviceType": "DEVICE_AIR_CONDITIONER", + "modelName": "PAC_910604_WW", + "alias": "Test air conditioner", + "reportable": true + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json new file mode 100644 index 0000000000000..0d45dc5c9f44b --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json @@ -0,0 +1,154 @@ +{ + "notification": { + "push": ["WATER_IS_FULL"] + }, + "property": { + "airConJobMode": { + "currentJobMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["AIR_CLEAN", "COOL", "AIR_DRY"], + "w": ["AIR_CLEAN", "COOL", "AIR_DRY"] + } + } + }, + "airFlow": { + "windStrength": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["LOW", "HIGH", "MID"], + "w": ["LOW", "HIGH", "MID"] + } + } + }, + "airQualitySensor": { + "PM1": { + "mode": ["r"], + "type": "number" + }, + "PM10": { + "mode": ["r"], + "type": "number" + }, + "PM2": { + "mode": ["r"], + "type": "number" + }, + "humidity": { + "mode": ["r"], + "type": "number" + }, + "monitoringEnabled": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["ON_WORKING", "ALWAYS"], + "w": ["ON_WORKING", "ALWAYS"] + } + }, + "oder": { + "mode": ["r"], + "type": "number" + }, + "totalPollution": { + "mode": ["r"], + "type": "number" + } + }, + "operation": { + "airCleanOperationMode": { + "mode": ["w"], + "type": "enum", + "value": { + "w": ["START", "STOP"] + } + }, + "airConOperationMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["POWER_ON", "POWER_OFF"], + "w": ["POWER_ON", "POWER_OFF"] + } + } + }, + "powerSave": { + "powerSaveEnabled": { + "mode": ["r", "w"], + "type": "boolean", + "value": { + "r": [false, true], + "w": [false, true] + } + } + }, + "temperature": { + "coolTargetTemperature": { + "mode": ["w"], + "type": "range", + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "currentTemperature": { + "mode": ["r"], + "type": "number" + }, + "targetTemperature": { + "mode": ["r", "w"], + "type": "range", + "value": { + "r": { + "max": 30, + "min": 18, + "step": 1 + }, + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": { + "mode": ["r"], + "type": "enum", + "value": { + "r": ["C", "F"] + } + } + }, + "timer": { + "relativeHourToStart": { + "mode": ["r", "w"], + "type": "number" + }, + "relativeHourToStop": { + "mode": ["r", "w"], + "type": "number" + }, + "relativeMinuteToStart": { + "mode": ["r", "w"], + "type": "number" + }, + "relativeMinuteToStop": { + "mode": ["r", "w"], + "type": "number" + }, + "absoluteHourToStart": { + "mode": ["r", "w"], + "type": "number" + }, + "absoluteMinuteToStart": { + "mode": ["r", "w"], + "type": "number" + } + } + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/status.json b/tests/components/lg_thinq/fixtures/air_conditioner/status.json new file mode 100644 index 0000000000000..90d15d1ae16d2 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/status.json @@ -0,0 +1,43 @@ +{ + "airConJobMode": { + "currentJobMode": "COOL" + }, + "airFlow": { + "windStrength": "MID" + }, + "airQualitySensor": { + "PM1": 12, + "PM10": 7, + "PM2": 24, + "humidity": 40, + "monitoringEnabled": "ON_WORKING", + "totalPollution": 3, + "totalPollutionLevel": "GOOD" + }, + "filterInfo": { + "filterLifetime": 540, + "usedTime": 180 + }, + "operation": { + "airConOperationMode": "POWER_ON" + }, + "powerSave": { + "powerSaveEnabled": false + }, + "sleepTimer": { + "relativeStopTimer": "UNSET" + }, + "temperature": { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + "timer": { + "relativeStartTimer": "UNSET", + "relativeStopTimer": "UNSET", + "absoluteStartTimer": "SET", + "absoluteStopTimer": "UNSET", + "absoluteHourToStart": 13, + "absoluteMinuteToStart": 14 + } +} diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr new file mode 100644 index 0000000000000..e9470c3de031d --- /dev/null +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -0,0 +1,86 @@ +# serializer version: 1 +# name: test_all_entities[climate.test_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'low', + 'high', + 'mid', + ]), + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 18, + 'preset_modes': list([ + 'air_clean', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_air_conditioner', + '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': None, + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[climate.test_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 40, + 'current_temperature': 25, + 'fan_mode': 'mid', + 'fan_modes': list([ + 'low', + 'high', + 'mid', + ]), + 'friendly_name': 'Test air conditioner', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 18, + 'preset_mode': None, + 'preset_modes': list([ + 'air_clean', + ]), + 'supported_features': , + 'target_temp_step': 1, + 'temperature': 19, + }), + 'context': , + 'entity_id': 'climate.test_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/lg_thinq/snapshots/test_event.ambr b/tests/components/lg_thinq/snapshots/test_event.ambr new file mode 100644 index 0000000000000..025f4496aeb54 --- /dev/null +++ b/tests/components/lg_thinq/snapshots/test_event.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_all_entities[event.test_air_conditioner_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'water_is_full', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.test_air_conditioner_notification', + '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': 'Notification', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[event.test_air_conditioner_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'water_is_full', + ]), + 'friendly_name': 'Test air conditioner Notification', + }), + 'context': , + 'entity_id': 'event.test_air_conditioner_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/lg_thinq/snapshots/test_number.ambr b/tests/components/lg_thinq/snapshots/test_number.ambr new file mode 100644 index 0000000000000..68f0185450116 --- /dev/null +++ b/tests/components/lg_thinq/snapshots/test_number.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_all_entities[number.test_air_conditioner_schedule_turn_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_air_conditioner_schedule_turn_off', + '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': 'Schedule turn-off', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_hour_to_stop', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_air_conditioner_schedule_turn_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Schedule turn-off', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_air_conditioner_schedule_turn_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[number.test_air_conditioner_schedule_turn_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_air_conditioner_schedule_turn_on', + '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': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_hour_to_start', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_air_conditioner_schedule_turn_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Schedule turn-on', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_air_conditioner_schedule_turn_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..387df916eba02 --- /dev/null +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -0,0 +1,205 @@ +# serializer version: 1 +# name: test_all_entities[sensor.test_air_conditioner_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test air conditioner Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm1', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm1', + 'friendly_name': 'Test air conditioner PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm10', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Test air conditioner PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm2', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Test air conditioner PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py new file mode 100644 index 0000000000000..24ed3ad230dbb --- /dev/null +++ b/tests/components/lg_thinq/test_climate.py @@ -0,0 +1,29 @@ +"""Tests for the LG Thinq climate platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import 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 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/lg_thinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py index db0e2d2945083..e7ee632810eef 100644 --- a/tests/components/lg_thinq/test_config_flow.py +++ b/tests/components/lg_thinq/test_config_flow.py @@ -14,7 +14,10 @@ async def test_config_flow( - hass: HomeAssistant, mock_thinq_api: AsyncMock, mock_uuid: AsyncMock + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_uuid: AsyncMock, + mock_setup_entry: AsyncMock, ) -> None: """Test that an thinq entry is normally created.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/lg_thinq/test_event.py b/tests/components/lg_thinq/test_event.py new file mode 100644 index 0000000000000..bea758cb943ad --- /dev/null +++ b/tests/components/lg_thinq/test_event.py @@ -0,0 +1,29 @@ +"""Tests for the LG Thinq event platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import 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 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.EVENT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/lg_thinq/test_init.py b/tests/components/lg_thinq/test_init.py new file mode 100644 index 0000000000000..7da7e79fec07a --- /dev/null +++ b/tests/components/lg_thinq/test_init.py @@ -0,0 +1,26 @@ +"""Tests for the LG ThinQ integration.""" + +from unittest.mock import AsyncMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/lg_thinq/test_number.py b/tests/components/lg_thinq/test_number.py new file mode 100644 index 0000000000000..e578e4eba7a09 --- /dev/null +++ b/tests/components/lg_thinq/test_number.py @@ -0,0 +1,29 @@ +"""Tests for the LG Thinq number platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import 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 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py new file mode 100644 index 0000000000000..02b91b4771b96 --- /dev/null +++ b/tests/components/lg_thinq/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the LG Thinq sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import 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 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From fc0547ccdf547d3e1f3eff2c6824d20a6bb2ab5d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 19:23:48 +0100 Subject: [PATCH 0133/1070] Pass the config entry explicitly in aemet coordinator (#128097) --- homeassistant/components/aemet/__init__.py | 15 ++------------- homeassistant/components/aemet/coordinator.py | 14 ++++++++++++++ homeassistant/components/aemet/diagnostics.py | 2 +- homeassistant/components/aemet/sensor.py | 3 +-- homeassistant/components/aemet/weather.py | 3 +-- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index e242d62a58060..29bc044c67d26 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -1,6 +1,5 @@ """The AEMET OpenData component.""" -from dataclasses import dataclass import logging from aemet_opendata.exceptions import AemetError, TownNotFound @@ -13,20 +12,10 @@ from homeassistant.helpers import aiohttp_client from .const import CONF_STATION_UPDATES, PLATFORMS -from .coordinator import WeatherUpdateCoordinator +from .coordinator import AemetConfigEntry, AemetData, WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) -type AemetConfigEntry = ConfigEntry[AemetData] - - -@dataclass -class AemetData: - """Aemet runtime data.""" - - name: str - coordinator: WeatherUpdateCoordinator - async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> bool: """Set up AEMET OpenData as config entry.""" @@ -46,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> boo except AemetError as err: raise ConfigEntryNotReady(err) from err - weather_coordinator = WeatherUpdateCoordinator(hass, aemet) + weather_coordinator = WeatherUpdateCoordinator(hass, entry, aemet) await weather_coordinator.async_config_entry_first_refresh() entry.runtime_data = AemetData(name=name, coordinator=weather_coordinator) diff --git a/homeassistant/components/aemet/coordinator.py b/homeassistant/components/aemet/coordinator.py index 8d179ccdb02b1..2e8534c746630 100644 --- a/homeassistant/components/aemet/coordinator.py +++ b/homeassistant/components/aemet/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations from asyncio import timeout +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, Final, cast @@ -19,6 +20,7 @@ from aemet_opendata.interface import AEMET from homeassistant.components.weather import Forecast +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -29,6 +31,16 @@ API_TIMEOUT: Final[int] = 120 WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) +type AemetConfigEntry = ConfigEntry[AemetData] + + +@dataclass +class AemetData: + """Aemet runtime data.""" + + name: str + coordinator: WeatherUpdateCoordinator + class WeatherUpdateCoordinator(DataUpdateCoordinator): """Weather data update coordinator.""" @@ -36,6 +48,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, + entry: AemetConfigEntry, aemet: AEMET, ) -> None: """Initialize coordinator.""" @@ -44,6 +57,7 @@ def __init__( super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL, ) diff --git a/homeassistant/components/aemet/diagnostics.py b/homeassistant/components/aemet/diagnostics.py index 2379bd34bc080..bc366fc6d4475 100644 --- a/homeassistant/components/aemet/diagnostics.py +++ b/homeassistant/components/aemet/diagnostics.py @@ -15,7 +15,7 @@ ) from homeassistant.core import HomeAssistant -from . import AemetConfigEntry +from .coordinator import AemetConfigEntry TO_REDACT_CONFIG = [ CONF_API_KEY, diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index e55344490aae1..88eb34b6f8445 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -55,7 +55,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import AemetConfigEntry from .const import ( ATTR_API_CONDITION, ATTR_API_FORECAST_CONDITION, @@ -87,7 +86,7 @@ ATTR_API_WIND_SPEED, CONDITIONS_MAP, ) -from .coordinator import WeatherUpdateCoordinator +from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator from .entity import AemetEntity diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index 341b81d71c4c2..a156652eadd67 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -27,9 +27,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AemetConfigEntry from .const import CONDITIONS_MAP -from .coordinator import WeatherUpdateCoordinator +from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator from .entity import AemetEntity From 9fcf757021f6a7853b86ac36be32cd49a912505e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 19:35:35 +0100 Subject: [PATCH 0134/1070] Fix translations in landisgyr (#129831) --- .../components/landisgyr_heat_meter/strings.json | 3 +++ tests/components/landisgyr_heat_meter/test_config_flow.py | 8 -------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/strings.json b/homeassistant/components/landisgyr_heat_meter/strings.json index 4bae2490006fe..31f08ded79f82 100644 --- a/homeassistant/components/landisgyr_heat_meter/strings.json +++ b/homeassistant/components/landisgyr_heat_meter/strings.json @@ -12,6 +12,9 @@ } } }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } diff --git a/tests/components/landisgyr_heat_meter/test_config_flow.py b/tests/components/landisgyr_heat_meter/test_config_flow.py index 79088508e61f5..fe62d5307198d 100644 --- a/tests/components/landisgyr_heat_meter/test_config_flow.py +++ b/tests/components/landisgyr_heat_meter/test_config_flow.py @@ -101,10 +101,6 @@ async def test_list_entry(mock_port, mock_heat_meter, hass: HomeAssistant) -> No } -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.landisgyr_heat_meter.config.error.cannot_connect"], -) @patch(API_HEAT_METER_SERVICE) async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: """Test manual entry fails.""" @@ -135,10 +131,6 @@ async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.landisgyr_heat_meter.config.error.cannot_connect"], -) @patch(API_HEAT_METER_SERVICE) @patch("serial.tools.list_ports.comports", return_value=[mock_serial_port()]) async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant) -> None: From 7863927c3a322aca4fdde7a6e855d766d123ba24 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 4 Nov 2024 19:39:46 +0100 Subject: [PATCH 0135/1070] Update frontend to 20241104.0 (#129829) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 52eee7db199f8..89cd93227a450 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241031.0"] + "requirements": ["home-assistant-frontend==20241104.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ec1976c802cd8..c71bd19b3ee2d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.83.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241031.0 +home-assistant-frontend==20241104.0 home-assistant-intents==2024.10.30 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b35b82cf3c372..5873954031108 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241031.0 +home-assistant-frontend==20241104.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d2d1875c196f..89619b18b89bd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241031.0 +home-assistant-frontend==20241104.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 From 90bd9bb626d4496b9c3772db7363a2cd73324b87 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 19:57:00 +0100 Subject: [PATCH 0136/1070] Fix translations in hydrawise (#129834) --- homeassistant/components/hydrawise/strings.json | 3 ++- tests/components/hydrawise/test_config_flow.py | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index b6df36ad4ff39..4d50f10bcb2b8 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -13,7 +13,8 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index e2eaaa51dc24d..e85b1b9b24905 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -93,10 +93,6 @@ async def test_form_connect_timeout( assert result2["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.hydrawise.config.error.invalid_auth"], -) async def test_form_not_authorized_error( hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User ) -> None: From 0b56ef5699a00608b969a469658258ac060a1f2f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 19:57:49 +0100 Subject: [PATCH 0137/1070] Fix translation in ovo energy (#129833) --- .../components/ovo_energy/strings.json | 7 ++++++- .../components/ovo_energy/test_config_flow.py | 18 ------------------ 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/ovo_energy/strings.json b/homeassistant/components/ovo_energy/strings.json index a9f7c9056b722..3dc11e3a6017c 100644 --- a/homeassistant/components/ovo_energy/strings.json +++ b/homeassistant/components/ovo_energy/strings.json @@ -1,10 +1,15 @@ { "config": { "flow_title": "{username}", + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "connection_error": "[%key:common::config_flow::error::cannot_connect%]", + "authorization_error": "[%key:common::config_flow::error::invalid_auth%]" }, "step": { "user": { diff --git a/tests/components/ovo_energy/test_config_flow.py b/tests/components/ovo_energy/test_config_flow.py index b6250a95492c8..cfe679a254acd 100644 --- a/tests/components/ovo_energy/test_config_flow.py +++ b/tests/components/ovo_energy/test_config_flow.py @@ -3,7 +3,6 @@ from unittest.mock import patch import aiohttp -import pytest from homeassistant import config_entries from homeassistant.components.ovo_energy.const import CONF_ACCOUNT, DOMAIN @@ -121,10 +120,6 @@ async def test_full_flow_implementation(hass: HomeAssistant) -> None: assert result2["data"][CONF_ACCOUNT] == FIXTURE_USER_INPUT[CONF_ACCOUNT] -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.ovo_energy.config.error.authorization_error"], -) async def test_reauth_authorization_error(hass: HomeAssistant) -> None: """Test we show user form on authorization error.""" mock_config = MockConfigEntry( @@ -150,10 +145,6 @@ async def test_reauth_authorization_error(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "authorization_error"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.ovo_energy.config.error.connection_error"], -) async def test_reauth_connection_error(hass: HomeAssistant) -> None: """Test we show user form on connection error.""" mock_config = MockConfigEntry( @@ -181,15 +172,6 @@ async def test_reauth_connection_error(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "connection_error"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - [ - [ - "component.ovo_energy.config.abort.reauth_successful", - "component.ovo_energy.config.error.authorization_error", - ] - ], -) async def test_reauth_flow(hass: HomeAssistant) -> None: """Test reauth works.""" mock_config = MockConfigEntry( From 3584c710b96b9ccce8521ba4b4cd06a61e0c2af9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Nov 2024 15:13:56 -0600 Subject: [PATCH 0138/1070] Fix unifiprotect supported features being set too late (#129850) --- .../components/unifiprotect/camera.py | 25 +++---- tests/components/unifiprotect/test_camera.py | 69 ++++++++++++++++++- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 62c35d00171be..ccf9bf1df0fc6 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -156,7 +156,8 @@ def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: async_add_entities(_async_camera_entities(hass, entry, data)) -_EMPTY_CAMERA_FEATURES = CameraEntityFeature(0) +_DISABLE_FEATURE = CameraEntityFeature(0) +_ENABLE_FEATURE = CameraEntityFeature.STREAM class ProtectCamera(ProtectDeviceEntity, Camera): @@ -195,24 +196,20 @@ def __init__( self._attr_name = f"{camera_name} (insecure)" # only the default (first) channel is enabled by default self._attr_entity_registry_enabled_default = is_default and secure + # Set the stream source before finishing the init + # because async_added_to_hass is too late and camera + # integration uses async_internal_added_to_hass to access + # the stream source which is called before async_added_to_hass + self._async_set_stream_source() @callback def _async_set_stream_source(self) -> None: - disable_stream = self._disable_stream channel = self.channel - - if not channel.is_rtsp_enabled: - disable_stream = False - + enable_stream = not self._disable_stream and channel.is_rtsp_enabled rtsp_url = channel.rtsps_url if self._secure else channel.rtsp_url - - # _async_set_stream_source called by __init__ - # pylint: disable-next=attribute-defined-outside-init - self._stream_source = None if disable_stream else rtsp_url - if self._stream_source: - self._attr_supported_features = CameraEntityFeature.STREAM - else: - self._attr_supported_features = _EMPTY_CAMERA_FEATURES + source = rtsp_url if enable_stream else None + self._attr_supported_features = _ENABLE_FEATURE if source else _DISABLE_FEATURE + self._stream_source = source @callback def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 75a0beb23d9aa..e86bc42f06c1f 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, Mock +import pytest from uiprotect.api import DEVICE_UPDATE_INTERVAL from uiprotect.data import Camera as ProtectCamera, CameraChannel, StateType from uiprotect.exceptions import NvrError @@ -12,8 +13,13 @@ from homeassistant.components.camera import ( CameraEntityFeature, CameraState, + CameraWebRTCProvider, + RTCIceCandidate, + StreamType, + WebRTCSendMessage, async_get_image, async_get_stream_source, + async_register_webrtc_provider, ) from homeassistant.components.unifiprotect.const import ( ATTR_BITRATE, @@ -22,6 +28,7 @@ ATTR_HEIGHT, ATTR_WIDTH, DEFAULT_ATTRIBUTION, + DOMAIN, ) from homeassistant.components.unifiprotect.utils import get_camera_base_name from homeassistant.const import ( @@ -31,11 +38,12 @@ STATE_UNAVAILABLE, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from .utils import ( + Camera, MockUFPFixture, adopt_devices, assert_entity_counts, @@ -46,6 +54,45 @@ ) +class MockWebRTCProvider(CameraWebRTCProvider): + """WebRTC provider.""" + + @property + def domain(self) -> str: + """Return the integration domain of the provider.""" + return DOMAIN + + @callback + def async_is_supported(self, stream_source: str) -> bool: + """Return if this provider is supports the Camera as source.""" + return True + + async def async_handle_async_webrtc_offer( + self, + camera: Camera, + offer_sdp: str, + session_id: str, + send_message: WebRTCSendMessage, + ) -> None: + """Handle the WebRTC offer and return the answer via the provided callback.""" + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: + """Handle the WebRTC candidate.""" + + @callback + def async_close_session(self, session_id: str) -> None: + """Close the session.""" + + +@pytest.fixture +async def web_rtc_provider(hass: HomeAssistant) -> None: + """Fixture to enable WebRTC provider for camera entities.""" + await async_setup_component(hass, "camera", {}) + async_register_webrtc_provider(hass, MockWebRTCProvider()) + + def validate_default_camera_entity( hass: HomeAssistant, camera_obj: ProtectCamera, @@ -283,6 +330,26 @@ async def test_basic_setup( await validate_no_stream_camera_state(hass, doorbell, 3, entity_id, features=0) +@pytest.mark.usefixtures("web_rtc_provider") +async def test_webrtc_support( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera_all: ProtectCamera, +) -> None: + """Test webrtc support is available.""" + camera_high_only = camera_all.copy() + camera_high_only.channels = [c.copy() for c in camera_all.channels] + camera_high_only.name = "Test Camera 1" + camera_high_only.channels[0].is_rtsp_enabled = True + camera_high_only.channels[1].is_rtsp_enabled = False + camera_high_only.channels[2].is_rtsp_enabled = False + await init_entry(hass, ufp, [camera_high_only]) + entity_id = validate_default_camera_entity(hass, camera_high_only, 0) + state = hass.states.get(entity_id) + assert state + assert StreamType.WEB_RTC in state.attributes["frontend_stream_type"] + + async def test_adopt( hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ) -> None: From e5263dc0c81e09d4b0cf4d79ecb49dc25af7159c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Nov 2024 15:43:22 -0600 Subject: [PATCH 0139/1070] Bump uiprotect to 6.4.0 (#129851) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 4617a8aae80f6..85867b5c87cf0 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==6.3.2", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==6.4.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 5873954031108..e9a335875f405 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2888,7 +2888,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.3.2 +uiprotect==6.4.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89619b18b89bd..fe5ce5673b81f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2301,7 +2301,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.3.2 +uiprotect==6.4.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From e8c3539709dafbdd19109bc2b93b7a17867084c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Nov 2024 16:13:52 -0600 Subject: [PATCH 0140/1070] Disable SRTP for unifiprotect RTSPS stream (#129852) --- homeassistant/components/unifiprotect/camera.py | 4 +++- tests/components/unifiprotect/test_camera.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index ccf9bf1df0fc6..a40939be9177f 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -206,7 +206,9 @@ def __init__( def _async_set_stream_source(self) -> None: channel = self.channel enable_stream = not self._disable_stream and channel.is_rtsp_enabled - rtsp_url = channel.rtsps_url if self._secure else channel.rtsp_url + # SRTP disabled because go2rtc does not support it + # https://github.com/AlexxIT/go2rtc/#source-rtsp + rtsp_url = channel.rtsps_no_srtp_url if self._secure else channel.rtsp_url source = rtsp_url if enable_stream else None self._attr_supported_features = _ENABLE_FEATURE if source else _DISABLE_FEATURE self._stream_source = source diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index e86bc42f06c1f..379f443923a27 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -196,7 +196,7 @@ async def validate_rtsps_camera_state( """Validate a camera's state.""" channel = camera_obj.channels[channel_id] - assert await async_get_stream_source(hass, entity_id) == channel.rtsps_url + assert await async_get_stream_source(hass, entity_id) == channel.rtsps_no_srtp_url validate_common_camera_state(hass, channel, entity_id, features) From dafd54ba2b34a861dd8cd5cac25c19b493f4b020 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 5 Nov 2024 03:34:40 +0100 Subject: [PATCH 0141/1070] Bump reolink-aio to 0.10.3 (#129841) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 282fe908e4c92..5fd87c2ccb178 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.10.2"] + "requirements": ["reolink-aio==0.10.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index e9a335875f405..0c2eaebbd2753 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2550,7 +2550,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.2 +reolink-aio==0.10.3 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe5ce5673b81f..78154cec9f6e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.2 +reolink-aio==0.10.3 # homeassistant.components.rflink rflink==0.0.66 From 617e87e02ccc0748b805f915da4023fd70b2a33f Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 5 Nov 2024 03:56:47 +0100 Subject: [PATCH 0142/1070] Fix source mapping in Onkyo (#129716) * Fix source mapping * Fix copy paste --- .../components/onkyo/media_player.py | 64 +++++++++++-------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 99f872e7fadf2..41e36a7f23793 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -128,13 +128,27 @@ type InputLibValue = str | tuple[str, ...] -_cmds: dict[str, InputLibValue] = { - k: v["name"] - for k, v in { - **PYEISCP_COMMANDS["main"]["SLI"]["values"], - **PYEISCP_COMMANDS["zone2"]["SLZ"]["values"], - }.items() -} + +def _input_lib_cmds(zone: str) -> dict[InputSource, InputLibValue]: + match zone: + case "main": + cmds = PYEISCP_COMMANDS["main"]["SLI"] + case "zone2": + cmds = PYEISCP_COMMANDS["zone2"]["SLZ"] + case "zone3": + cmds = PYEISCP_COMMANDS["zone3"]["SL3"] + case "zone4": + cmds = PYEISCP_COMMANDS["zone4"]["SL4"] + + result: dict[InputSource, InputLibValue] = {} + for k, v in cmds["values"].items(): + try: + source = InputSource(k) + except ValueError: + continue + result[source] = v["name"] + + return result async def async_setup_platform( @@ -147,16 +161,13 @@ async def async_setup_platform( host = config.get(CONF_HOST) source_mapping: dict[str, InputSource] = {} - for value, source_lib in _cmds.items(): - try: - source = InputSource(value) - except ValueError: - continue - if isinstance(source_lib, str): - source_mapping.setdefault(source_lib, source) - else: - for source_lib_single in source_lib: - source_mapping.setdefault(source_lib_single, source) + for zone in ZONES: + for source, source_lib in _input_lib_cmds(zone).items(): + if isinstance(source_lib, str): + source_mapping.setdefault(source_lib, source) + else: + for source_lib_single in source_lib: + source_mapping.setdefault(source_lib_single, source) sources: dict[InputSource, str] = {} for source_lib_single, source_name in config[CONF_SOURCES].items(): @@ -340,9 +351,12 @@ def __init__( self._volume_resolution = volume_resolution self._max_volume = max_volume - self._source_mapping = sources - self._reverse_mapping = {value: key for key, value in sources.items()} - self._lib_mapping = {_cmds[source.value]: source for source in InputSource} + self._name_mapping = sources + self._reverse_name_mapping = {value: key for key, value in sources.items()} + self._lib_mapping = _input_lib_cmds(zone) + self._reverse_lib_mapping = { + value: key for key, value in self._lib_mapping.items() + } self._attr_source_list = list(sources.values()) self._attr_extra_state_attributes = {} @@ -414,7 +428,7 @@ async def async_mute_volume(self, mute: bool) -> None: async def async_select_source(self, source: str) -> None: """Select input source.""" if self.source_list and source in self.source_list: - source_lib = _cmds[self._reverse_mapping[source].value] + source_lib = self._lib_mapping[self._reverse_name_mapping[source]] if isinstance(source_lib, str): source_lib_single = source_lib else: @@ -432,7 +446,7 @@ async def async_play_media( ) -> None: """Play radio station by preset number.""" if self.source is not None: - source = self._reverse_mapping[self.source] + source = self._reverse_name_mapping[self.source] if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES: self._update_receiver("preset", media_id) @@ -505,9 +519,9 @@ def process_update(self, update: tuple[str, str, Any]) -> None: @callback def _parse_source(self, source_lib: InputLibValue) -> None: - source = self._lib_mapping[source_lib] - if source in self._source_mapping: - self._attr_source = self._source_mapping[source] + source = self._reverse_lib_mapping[source_lib] + if source in self._name_mapping: + self._attr_source = self._name_mapping[source] return source_meaning = source.value_meaning From f7ce4ff25c4fbc8e32947ba580dc1c4dc7a9a9ec Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Tue, 5 Nov 2024 20:15:42 +1300 Subject: [PATCH 0143/1070] Update snapshot for lg thinq (#129856) update snapshot for lg thinq Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../lg_thinq/snapshots/test_sensor.ambr | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 387df916eba02..aa50ae5b03e77 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -203,3 +203,95 @@ 'state': '24', }) # --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + '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': 'Schedule turn-off', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Schedule turn-off', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + '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': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Schedule turn-on', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- From e1e731eb4828eaf3888afc11a930085b13d20833 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 08:56:58 +0100 Subject: [PATCH 0144/1070] Drop use of initialize_options in onkyo (#129869) * Drop use of initialize_options in onkyo * Apply suggestions from code review Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --------- Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- homeassistant/components/onkyo/config_flow.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 9ab01b3d9046c..623fa9b2a9018 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -327,10 +327,8 @@ class OnkyoOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.initialize_options(config_entry) - sources_store: dict[str, str] = self.options[OPTION_INPUT_SOURCES] - sources = {InputSource(k): v for k, v in sources_store.items()} - self.options[OPTION_INPUT_SOURCES] = sources + sources_store: dict[str, str] = config_entry.options[OPTION_INPUT_SOURCES] + self._input_sources = {InputSource(k): v for k, v in sources_store.items()} async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -360,15 +358,12 @@ async def async_step_init( ) ) - sources: dict[InputSource, str] = self.options[OPTION_INPUT_SOURCES] - for source in sources: - schema_dict[vol.Required(source.value_meaning, default=sources[source])] = ( + for source, source_name in self._input_sources.items(): + schema_dict[vol.Required(source.value_meaning, default=source_name)] = ( TextSelector() ) - schema = vol.Schema(schema_dict) - return self.async_show_form( step_id="init", - data_schema=schema, + data_schema=vol.Schema(schema_dict), ) From 95eefbac20f683016367b76faed420369d675e58 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 09:01:29 +0100 Subject: [PATCH 0145/1070] Drop use of initialize_options in androidtv (#129854) * Drop use of initialize_options in androidtv * Initialize instance attribute in init method * Adjust --- homeassistant/components/androidtv/config_flow.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index 132ed96a96f18..a41a113268e13 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -191,10 +191,9 @@ class OptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.initialize_options(config_entry) - self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {}) - self._state_det_rules: dict[str, Any] = self.options.setdefault( - CONF_STATE_DETECTION_RULES, {} + self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {})) + self._state_det_rules: dict[str, Any] = dict( + config_entry.options.get(CONF_STATE_DETECTION_RULES, {}) ) self._conf_app_id: str | None = None self._conf_rule_id: str | None = None From 3858400a6f89f04942bb859bb7437a775b0a9f0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 5 Nov 2024 10:10:23 +0100 Subject: [PATCH 0146/1070] Bump hass-nabucasa from 0.83.0 to 0.84.0 (#129873) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 8d2b40ff8ba86..4201cb1b2d44e 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,6 +8,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.83.0"], + "requirements": ["hass-nabucasa==0.84.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c71bd19b3ee2d..56155d53fd51f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ fnv-hash-fast==1.0.2 go2rtc-client==0.0.1b3 ha-ffmpeg==3.2.1 habluetooth==3.6.0 -hass-nabucasa==0.83.0 +hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241104.0 diff --git a/pyproject.toml b/pyproject.toml index 0c9c825e535a6..4a2857b5065d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "fnv-hash-fast==1.0.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.83.0", + "hass-nabucasa==0.84.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.27.2", diff --git a/requirements.txt b/requirements.txt index e90164ed272cf..a5beecec8ff9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.1 fnv-hash-fast==1.0.2 -hass-nabucasa==0.83.0 +hass-nabucasa==0.84.0 httpx==0.27.2 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0c2eaebbd2753..afd4de543fb8d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1084,7 +1084,7 @@ habitipy==0.3.3 habluetooth==3.6.0 # homeassistant.components.cloud -hass-nabucasa==0.83.0 +hass-nabucasa==0.84.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 78154cec9f6e4..abd88b11580f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -922,7 +922,7 @@ habitipy==0.3.3 habluetooth==3.6.0 # homeassistant.components.cloud -hass-nabucasa==0.83.0 +hass-nabucasa==0.84.0 # homeassistant.components.conversation hassil==1.7.4 From e6c20333b38d75cf7a542c8e320636b0ada14483 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 10:47:37 +0100 Subject: [PATCH 0147/1070] Remove dead code in translation checks (#129875) --- tests/components/conftest.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 5bf393a8405ff..ba5d12afd0133 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -528,21 +528,6 @@ async def _ensure_translation_exists( ignore_translations[full_key] = "used" return - key_parts = key.split(".") - # Ignore step data translations if title or description exists - if ( - len(key_parts) >= 3 - and key_parts[0] == "step" - and key_parts[2] == "data" - and ( - f"component.{component}.{category}.{key_parts[0]}.{key_parts[1]}.description" - in translations - or f"component.{component}.{category}.{key_parts[0]}.{key_parts[1]}.title" - in translations - ) - ): - return - pytest.fail( f"Translation not found for {component}: `{category}.{key}`. " f"Please add to homeassistant/components/{component}/strings.json" From fa3010016033e53e304edef30f4e8704b0bb146f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 10:55:40 +0100 Subject: [PATCH 0148/1070] Fix flaky tests in device_sun_light_trigger (#129871) --- tests/components/device_sun_light_trigger/test_init.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index 1de0794b9eedd..2499648291603 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -177,6 +177,9 @@ async def test_lights_turn_on_when_coming_home_after_sun_set_person( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test lights turn on when coming home after sun set.""" + # Ensure all setup tasks are done (avoid flaky tests) + await hass.async_block_till_done(wait_background_tasks=True) + device_1 = f"{DEVICE_TRACKER_DOMAIN}.device_1" device_2 = f"{DEVICE_TRACKER_DOMAIN}.device_2" From 80ff6dc6180070b1794fc99ee71bc49c0c277cda Mon Sep 17 00:00:00 2001 From: Alex Bush <45221249+KC3BZU@users.noreply.github.com> Date: Tue, 5 Nov 2024 04:56:34 -0500 Subject: [PATCH 0149/1070] Bump pyfibaro to 0.8.0 (#129846) --- homeassistant/components/fibaro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index 39850672d060a..d2a1186b05b5e 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.7.8"] + "requirements": ["pyfibaro==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index afd4de543fb8d..5f3fab2433577 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1907,7 +1907,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.8 +pyfibaro==0.8.0 # homeassistant.components.fido pyfido==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index abd88b11580f4..0e83f381730ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1536,7 +1536,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.8 +pyfibaro==0.8.0 # homeassistant.components.fido pyfido==2.1.2 From e9e20229a35acd09184a66c2654d33b6b6228bef Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 10:57:03 +0100 Subject: [PATCH 0150/1070] Drop use of initialize_options in androidtv_remote (#129855) --- homeassistant/components/androidtv_remote/config_flow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 962b1c09f1f60..3500e4ff47b03 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -226,8 +226,7 @@ class AndroidTVRemoteOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.initialize_options(config_entry) - self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {}) + self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {})) self._conf_app_id: str | None = None @callback From af58b0c3b78f84b6029859dbeeda8aa210d9ad1a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 5 Nov 2024 11:05:20 +0100 Subject: [PATCH 0151/1070] Add reconfigure flow to yale_smart_alarm (#129536) --- .../yale_smart_alarm/config_flow.py | 76 ++++--- .../components/yale_smart_alarm/strings.json | 13 +- .../yale_smart_alarm/test_config_flow.py | 205 ++++++++++++++++++ 3 files changed, 267 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index 9d653da7a7e58..c71b7b33a0872 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -25,7 +25,6 @@ DEFAULT_AREA_ID, DEFAULT_NAME, DOMAIN, - LOGGER, YALE_BASE_ERRORS, ) @@ -52,6 +51,18 @@ ) +def validate_credentials(username: str, password: str) -> dict[str, Any]: + """Validate credentials.""" + errors: dict[str, str] = {} + try: + YaleSmartAlarmClient(username, password) + except AuthenticationError: + errors = {"base": "invalid_auth"} + except YALE_BASE_ERRORS: + errors = {"base": "cannot_connect"} + return errors + + class YaleConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Yale integration.""" @@ -73,24 +84,16 @@ async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: reauth_entry = self._get_reauth_entry() username = reauth_entry.data[CONF_USERNAME] password = user_input[CONF_PASSWORD] - try: - await self.hass.async_add_executor_job( - YaleSmartAlarmClient, username, password - ) - except AuthenticationError as error: - LOGGER.error("Authentication failed. Check credentials %s", error) - errors = {"base": "invalid_auth"} - except YALE_BASE_ERRORS as error: - LOGGER.error("Connection to API failed %s", error) - errors = {"base": "cannot_connect"} - + errors = await self.hass.async_add_executor_job( + validate_credentials, username, password + ) if not errors: return self.async_update_reload_and_abort( reauth_entry, @@ -103,11 +106,42 @@ async def async_step_reauth_confirm( errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of existing entry.""" + errors: dict[str, str] = {} + + if user_input is not None: + reconfigure_entry = self._get_reconfigure_entry() + username = user_input[CONF_USERNAME] + + errors = await self.hass.async_add_executor_job( + validate_credentials, username, user_input[CONF_PASSWORD] + ) + if ( + username != reconfigure_entry.unique_id + and await self.async_set_unique_id(username) + ): + errors["base"] = "unique_id_exists" + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + unique_id=username, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=DATA_SCHEMA, + errors=errors, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: username = user_input[CONF_USERNAME] @@ -115,17 +149,9 @@ async def async_step_user( name = DEFAULT_NAME area = user_input.get(CONF_AREA_ID, DEFAULT_AREA_ID) - try: - await self.hass.async_add_executor_job( - YaleSmartAlarmClient, username, password - ) - except AuthenticationError as error: - LOGGER.error("Authentication failed. Check credentials %s", error) - errors = {"base": "invalid_auth"} - except YALE_BASE_ERRORS as error: - LOGGER.error("Connection to API failed %s", error) - errors = {"base": "cannot_connect"} - + errors = await self.hass.async_add_executor_job( + validate_credentials, username, password + ) if not errors: await self.async_set_unique_id(username) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index cc837d7b7d744..7f940e1139e5b 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unique_id_exists": "Another config entry with this username already exist" }, "step": { "user": { @@ -21,6 +23,13 @@ "data": { "password": "[%key:common::config_flow::data::password%]" } + }, + "reconfigure": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]" + } } } }, diff --git a/tests/components/yale_smart_alarm/test_config_flow.py b/tests/components/yale_smart_alarm/test_config_flow.py index e325e259806f7..e5b59f7946314 100644 --- a/tests/components/yale_smart_alarm/test_config_flow.py +++ b/tests/components/yale_smart_alarm/test_config_flow.py @@ -239,6 +239,211 @@ async def test_reauth_flow_error( } +async def test_reconfigure(hass: HomeAssistant) -> None: + """Test reconfigure config flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={ + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + version=2, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + with ( + patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + return_value="", + ), + patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-test-password", + "area_id": "2", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert entry.data == { + "username": "test-username", + "password": "new-test-password", + "name": "Yale Smart Alarm", + "area_id": "2", + } + + +async def test_reconfigure_username_exist(hass: HomeAssistant) -> None: + """Test reconfigure config flow abort other username already exist.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={ + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + version=2, + ) + entry.add_to_hass(hass) + entry2 = MockConfigEntry( + domain=DOMAIN, + unique_id="other-username", + data={ + "username": "other-username", + "password": "test-password", + "name": "Yale Smart Alarm 2", + "area_id": "1", + }, + version=2, + ) + entry2.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + with ( + patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + return_value="", + ), + patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "other-username", + "password": "test-password", + "area_id": "1", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unique_id_exists"} + + with ( + patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + return_value="", + ), + patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "other-new-username", + "password": "test-password", + "area_id": "1", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + "username": "other-new-username", + "name": "Yale Smart Alarm", + "password": "test-password", + "area_id": "1", + } + + +@pytest.mark.parametrize( + ("sideeffect", "p_error"), + [ + (AuthenticationError, "invalid_auth"), + (ConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (UnknownError, "cannot_connect"), + ], +) +async def test_reconfigure_flow_error( + hass: HomeAssistant, sideeffect: Exception, p_error: str +) -> None: + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={ + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + version=2, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + side_effect=sideeffect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "update-password", + "area_id": "1", + }, + ) + await hass.async_block_till_done() + + assert result["step_id"] == "reconfigure" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": p_error} + + with ( + patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + return_value="", + ), + patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-test-password", + "area_id": "1", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + "username": "test-username", + "name": "Yale Smart Alarm", + "password": "new-test-password", + "area_id": "1", + } + + async def test_options_flow(hass: HomeAssistant) -> None: """Test options config flow.""" entry = MockConfigEntry( From 8889464e04174504e4ab9b846a2d663b6335f03c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Nov 2024 11:09:10 +0100 Subject: [PATCH 0152/1070] Validate go2rtc server version (#129810) --- homeassistant/components/go2rtc/__init__.py | 14 +++- homeassistant/components/go2rtc/server.py | 6 +- tests/components/go2rtc/conftest.py | 1 + tests/components/go2rtc/test_init.py | 85 +++++++++++++++++++-- tests/components/go2rtc/test_server.py | 3 +- 5 files changed, 98 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 5be1dbc1a4841..2bcdaddf7391e 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -5,7 +5,7 @@ from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from go2rtc_client import Go2RtcRestClient -from go2rtc_client.exceptions import Go2RtcClientError +from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError from go2rtc_client.ws import ( Go2RtcWsClient, ReceiveMessages, @@ -114,7 +114,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: server = Server( hass, binary, enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False) ) - await server.start() + try: + await server.start() + except Exception: # noqa: BLE001 + _LOGGER.warning("Could not start go2rtc server", exc_info=True) + return False async def on_stop(event: Event) -> None: await server.stop() @@ -143,7 +147,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Validate the server URL try: client = Go2RtcRestClient(async_get_clientsession(hass), url) - await client.streams.list() + await client.validate_server_version() except Go2RtcClientError as err: if isinstance(err.__cause__, _RETRYABLE_ERRORS): raise ConfigEntryNotReady( @@ -151,6 +155,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from err _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False + except Go2RtcVersionError as err: + raise ConfigEntryNotReady( + f"The go2rtc server version is not supported, {err}" + ) from err except Exception as err: # noqa: BLE001 _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index b2aa19d527586..eff067416b3fd 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -112,6 +112,10 @@ async def _start(self) -> None: await self._stop() raise Go2RTCServerStartError from err + # Check the server version + client = Go2RtcRestClient(async_get_clientsession(self._hass), DEFAULT_URL) + await client.validate_server_version() + async def _log_output(self, process: asyncio.subprocess.Process) -> None: """Log the output of the process.""" assert process.stdout is not None @@ -174,7 +178,7 @@ async def _monitor_api(self) -> None: _LOGGER.debug("Monitoring go2rtc API") try: while True: - await client.streams.list() + await client.validate_server_version() await asyncio.sleep(10) except Exception as err: _LOGGER.debug("go2rtc API did not reply", exc_info=True) diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 87c68989fd284..42b363b232440 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -23,6 +23,7 @@ def rest_client() -> Generator[AsyncMock]: client = mock_client.return_value client.streams = streams = Mock(spec_set=_StreamClient) streams.list.return_value = {} + client.validate_server_version = AsyncMock() client.webrtc = Mock(spec_set=_WebRTCClient) yield client diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 847de248aaf4f..21d4d0a047e3d 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -7,7 +7,7 @@ from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from go2rtc_client import Stream -from go2rtc_client.exceptions import Go2RtcClientError +from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError from go2rtc_client.models import Producer from go2rtc_client.ws import ( ReceiveMessages, @@ -494,6 +494,8 @@ async def test_close_session( ERR_CONNECT_RETRY = ( "Could not connect to go2rtc instance on http://localhost:1984/; Retrying" ) +ERR_START_SERVER = "Could not start go2rtc server" +ERR_UNSUPPORTED_VERSION = "The go2rtc server version is not supported" _INVALID_CONFIG = "Invalid config for 'go2rtc': " ERR_INVALID_URL = _INVALID_CONFIG + "invalid url" ERR_EXCLUSIVE = _INVALID_CONFIG + DEBUG_UI_URL_MESSAGE @@ -526,8 +528,10 @@ async def test_non_user_setup_with_error( ("config", "go2rtc_binary", "is_docker_env", "expected_log_message"), [ ({DEFAULT_CONFIG_DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND), + ({DEFAULT_CONFIG_DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_START_SERVER), ({DOMAIN: {}}, None, False, ERR_URL_REQUIRED), ({DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND), + ({DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_START_SERVER), ({DOMAIN: {CONF_URL: "invalid"}}, None, True, ERR_INVALID_URL), ( {DOMAIN: {CONF_URL: "http://localhost:1984", CONF_DEBUG_UI: True}}, @@ -559,8 +563,6 @@ async def test_setup_with_setup_error( @pytest.mark.parametrize( ("config", "go2rtc_binary", "is_docker_env", "expected_log_message"), [ - ({DEFAULT_CONFIG_DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_CONNECT), - ({DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_CONNECT), ({DOMAIN: {CONF_URL: "http://localhost:1984/"}}, None, True, ERR_CONNECT), ], ) @@ -584,7 +586,7 @@ async def test_setup_with_setup_entry_error( assert expected_log_message in caplog.text -@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}]) +@pytest.mark.parametrize("config", [{DOMAIN: {CONF_URL: "http://localhost:1984/"}}]) @pytest.mark.parametrize( ("cause", "expected_config_entry_state", "expected_log_message"), [ @@ -598,10 +600,46 @@ async def test_setup_with_setup_entry_error( @pytest.mark.usefixtures( "mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server" ) -async def test_setup_with_retryable_setup_entry_error( +async def test_setup_with_retryable_setup_entry_error_custom_server( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + rest_client: AsyncMock, + config: ConfigType, + cause: Exception, + expected_config_entry_state: ConfigEntryState, + expected_log_message: str, +) -> None: + """Test setup integration entry fails.""" + go2rtc_error = Go2RtcClientError() + go2rtc_error.__cause__ = cause + rest_client.validate_server_version.side_effect = go2rtc_error + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0].state == expected_config_entry_state + assert expected_log_message in caplog.text + + +@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}]) +@pytest.mark.parametrize( + ("cause", "expected_config_entry_state", "expected_log_message"), + [ + (ClientConnectionError(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER), + (ServerConnectionError(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER), + (None, ConfigEntryState.NOT_LOADED, ERR_START_SERVER), + (Exception(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER), + ], +) +@pytest.mark.parametrize("has_go2rtc_entry", [True, False]) +@pytest.mark.usefixtures( + "mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server" +) +async def test_setup_with_retryable_setup_entry_error_default_server( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, rest_client: AsyncMock, + has_go2rtc_entry: bool, config: ConfigType, cause: Exception, expected_config_entry_state: ConfigEntryState, @@ -610,7 +648,42 @@ async def test_setup_with_retryable_setup_entry_error( """Test setup integration entry fails.""" go2rtc_error = Go2RtcClientError() go2rtc_error.__cause__ = cause - rest_client.streams.list.side_effect = go2rtc_error + rest_client.validate_server_version.side_effect = go2rtc_error + assert not await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == has_go2rtc_entry + for config_entry in config_entries: + assert config_entry.state == expected_config_entry_state + assert expected_log_message in caplog.text + + +@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}]) +@pytest.mark.parametrize( + ("go2rtc_error", "expected_config_entry_state", "expected_log_message"), + [ + ( + Go2RtcVersionError("1.9.4", "1.9.5", "2.0.0"), + ConfigEntryState.SETUP_RETRY, + ERR_UNSUPPORTED_VERSION, + ), + ], +) +@pytest.mark.parametrize("has_go2rtc_entry", [True, False]) +@pytest.mark.usefixtures( + "mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server" +) +async def test_setup_with_version_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + rest_client: AsyncMock, + config: ConfigType, + go2rtc_error: Exception, + expected_config_entry_state: ConfigEntryState, + expected_log_message: str, +) -> None: + """Test setup integration entry fails.""" + rest_client.validate_server_version.side_effect = [None, go2rtc_error] assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done(wait_background_tasks=True) config_entries = hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index 1410fbeb6c331..fedf155baf561 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -47,6 +47,7 @@ def mock_tempfile() -> Generator[Mock]: ) async def test_server_run_success( mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, server_stdout: list[str], server: Server, caplog: pytest.LogCaptureFixture, @@ -95,7 +96,7 @@ async def test_server_run_success( @pytest.mark.usefixtures("mock_tempfile") async def test_server_timeout_on_stop( - mock_create_subprocess: MagicMock, server: Server + mock_create_subprocess: MagicMock, rest_client: AsyncMock, server: Server ) -> None: """Test server run where the process takes too long to terminate.""" # Start server thread From 72bcc6702f214752b36914831aadd09edb44d363 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 5 Nov 2024 11:14:53 +0100 Subject: [PATCH 0153/1070] Add child lock for tplink thermostats (#129649) --- homeassistant/components/tplink/icons.json | 3 ++ homeassistant/components/tplink/strings.json | 3 ++ homeassistant/components/tplink/switch.py | 3 ++ .../components/tplink/fixtures/features.json | 5 ++ .../tplink/snapshots/test_switch.ambr | 46 +++++++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index 75d1537320236..3a83349c61306 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -68,6 +68,9 @@ "state": { "on": "mdi:sleep" } + }, + "child_lock": { + "default": "mdi:account-lock" } }, "sensor": { diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 66380434d3215..e15f3cfba0388 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -190,6 +190,9 @@ }, "fan_sleep_mode": { "name": "Fan sleep mode" + }, + "child_lock": { + "name": "Child lock" } }, "number": { diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 6d3e21d88c514..9ef58484ea861 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -48,6 +48,9 @@ class TPLinkSwitchEntityDescription( TPLinkSwitchEntityDescription( key="fan_sleep_mode", ), + TPLinkSwitchEntityDescription( + key="child_lock", + ), ) SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS} diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index d3526adec8adb..f0cfcc92ea1ac 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -34,6 +34,11 @@ "type": "Switch", "category": "Config" }, + "child_lock": { + "value": true, + "type": "Switch", + "category": "Config" + }, "current_consumption": { "value": 5.23, "type": "Sensor", diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index 4354ea1905a02..f6e9ad51410dc 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -173,6 +173,52 @@ 'state': 'on', }) # --- +# name: test_states[switch.my_device_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.my_device_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': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '123456789ABCDEFGH_child_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Child lock', + }), + 'context': , + 'entity_id': 'switch.my_device_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_states[switch.my_device_fan_sleep_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 5fd1e23255e470995712b105b157ac2f92ef05a9 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 5 Nov 2024 11:52:11 +0100 Subject: [PATCH 0154/1070] Bump pynecil to 0.2.1 (#129843) --- homeassistant/components/iron_os/coordinator.py | 9 ++++----- homeassistant/components/iron_os/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 32b6da13b57b2..699f5a0170469 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -37,15 +37,14 @@ def __init__(self, hass: HomeAssistant, device: Pynecil) -> None: ) self.device = device - async def _async_setup(self) -> None: - """Set up the coordinator.""" - - self.device_info = await self.device.get_device_info() - async def _async_update_data(self) -> LiveDataResponse: """Fetch data from Device.""" try: + # device info is cached and won't be refetched on every + # coordinator refresh, only after the device has disconnected + # the device info is refetched + self.device_info = await self.device.get_device_info() return await self.device.get_live_data() except CommunicationError as e: diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 9fcb84e0f6a05..4ec08a43b61e4 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/iron_os", "iot_class": "local_polling", "loggers": ["pynecil", "aiogithubapi"], - "requirements": ["pynecil==0.2.0", "aiogithubapi==24.6.0"] + "requirements": ["pynecil==0.2.1", "aiogithubapi==24.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5f3fab2433577..484d6341a9a5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2084,7 +2084,7 @@ pymsteams==0.1.12 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==0.2.0 +pynecil==0.2.1 # homeassistant.components.netgear pynetgear==0.10.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e83f381730ec..656e3b1b63c39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1680,7 +1680,7 @@ pymonoprice==0.4 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==0.2.0 +pynecil==0.2.1 # homeassistant.components.netgear pynetgear==0.10.10 From 5eadfcc52439b352d84bb16856c4f6118e6c6a80 Mon Sep 17 00:00:00 2001 From: Kunal Aggarwal Date: Tue, 5 Nov 2024 16:22:38 +0530 Subject: [PATCH 0155/1070] Adding new on values for Tuya Presence Detection Sensor (#129801) --- homeassistant/components/tuya/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index a8c9157caa73e..934f03336aadf 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -151,7 +151,7 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): TuyaBinarySensorEntityDescription( key=DPCode.PRESENCE_STATE, device_class=BinarySensorDeviceClass.OCCUPANCY, - on_value="presence", + on_value={"presence", "small_move", "large_move"}, ), ), # Formaldehyde Detector From ae37c8cc7ac501166787e35f4486fa0da8f4db94 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 5 Nov 2024 05:53:01 -0500 Subject: [PATCH 0156/1070] Add repair for add-on boot fail (#129847) --- homeassistant/components/hassio/const.py | 1 + homeassistant/components/hassio/issues.py | 2 + homeassistant/components/hassio/repairs.py | 12 ++- homeassistant/components/hassio/strings.json | 17 ++++ tests/components/hassio/test_repairs.py | 101 +++++++++++++++++++ 5 files changed, 129 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 6e6c9006fcae3..b337017147b2e 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -103,6 +103,7 @@ PLACEHOLDER_KEY_REFERENCE = "reference" PLACEHOLDER_KEY_COMPONENTS = "components" +ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail" ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing" ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed" diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 9c2152489d633..944bc99a6b922 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -36,6 +36,7 @@ EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, + ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, @@ -94,6 +95,7 @@ # Keys (type + context) of issues that when found should be made into a repair ISSUE_KEYS_FOR_REPAIRS = { + ISSUE_KEY_ADDON_BOOT_FAIL, "issue_mount_mount_failed", "issue_system_multiple_data_disks", "issue_system_reboot_required", diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 082dbe38beee6..0fcd96ace383d 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -14,6 +14,7 @@ from . import get_addons_info, get_issues_info from .const import ( + ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, PLACEHOLDER_KEY_ADDON, @@ -181,8 +182,8 @@ def description_placeholders(self) -> dict[str, str] | None: return placeholders -class DetachedAddonIssueRepairFlow(SupervisorIssueRepairFlow): - """Handler for detached addon issue fixing flows.""" +class AddonIssueRepairFlow(SupervisorIssueRepairFlow): + """Handler for addon issue fixing flows.""" @property def description_placeholders(self) -> dict[str, str] | None: @@ -210,7 +211,10 @@ async def async_create_fix_flow( issue = supervisor_issues and supervisor_issues.get_issue(issue_id) if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG: return DockerConfigIssueRepairFlow(issue_id) - if issue and issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: - return DetachedAddonIssueRepairFlow(issue_id) + if issue and issue.key in { + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, + ISSUE_KEY_ADDON_BOOT_FAIL, + }: + return AddonIssueRepairFlow(issue_id) return SupervisorIssueRepairFlow(issue_id) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 8688934ee3d93..09ed45bd5bc2c 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -17,6 +17,23 @@ } }, "issues": { + "issue_addon_boot_fail": { + "title": "Add-on failed to start at boot", + "fix_flow": { + "step": { + "fix_menu": { + "description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.", + "menu_options": { + "addon_execute_start": "Start", + "addon_disable_boot": "Disable" + } + } + }, + "abort": { + "apply_suggestion_fail": "Could not apply the fix. Check the Supervisor logs for more details." + } + } + }, "issue_addon_detached_addon_missing": { "title": "Missing repository for an installed add-on", "description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store." diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 907529ec9c4ac..f3ccb5948f132 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -868,3 +868,104 @@ async def test_supervisor_issue_detached_addon_removed( str(aioclient_mock.mock_calls[-1][1]) == "http://127.0.0.1/resolution/suggestion/1235" ) + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issue_addon_boot_fail( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test fix flow for supervisor issue.""" + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "boot_fail", + "context": "addon", + "reference": "test", + "suggestions": [ + { + "uuid": "1235", + "type": "execute_start", + "context": "addon", + "reference": "test", + }, + { + "uuid": "1236", + "type": "disable_boot", + "context": "addon", + "reference": "test", + }, + ], + }, + ], + ) + + assert await async_setup_component(hass, "hassio", {}) + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "menu", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "fix_menu", + "data_schema": [ + { + "type": "select", + "options": [ + ["addon_execute_start", "addon_execute_start"], + ["addon_disable_boot", "addon_disable_boot"], + ], + "name": "next_step_id", + } + ], + "menu_options": ["addon_execute_start", "addon_disable_boot"], + "description_placeholders": { + "reference": "test", + "addon": "test", + }, + } + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "addon_execute_start"}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1235" + ) From 27dc82d7d033344d5c86fa3c1a6129d9a163847c Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Tue, 5 Nov 2024 02:57:00 -0800 Subject: [PATCH 0157/1070] Add device model ID if provided by NUT (#124189) Co-authored-by: J. Nick Koston --- homeassistant/components/nut/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index b4e53c1380ca0..169dbbbff5db1 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -130,6 +130,7 @@ async def async_update_data() -> dict[str, str]: name=data.name.title(), manufacturer=data.device_info.manufacturer, model=data.device_info.model, + model_id=data.device_info.model_id, sw_version=data.device_info.firmware, serial_number=data.device_info.serial, suggested_area=data.device_info.device_location, @@ -210,6 +211,7 @@ class NUTDeviceInfo: manufacturer: str | None = None model: str | None = None + model_id: str | None = None firmware: str | None = None serial: str | None = None device_location: str | None = None @@ -271,10 +273,13 @@ def _get_device_info(self) -> NUTDeviceInfo | None: manufacturer = _manufacturer_from_status(self._status) model = _model_from_status(self._status) + model_id: str | None = self._status.get("device.part") firmware = _firmware_from_status(self._status) serial = _serial_from_status(self._status) device_location: str | None = self._status.get("device.location") - return NUTDeviceInfo(manufacturer, model, firmware, serial, device_location) + return NUTDeviceInfo( + manufacturer, model, model_id, firmware, serial, device_location + ) async def _async_get_status(self) -> dict[str, str]: """Get the ups status from NUT.""" From 79901cede985830ab053c8945e253d7b39c61f8e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 12:02:33 +0100 Subject: [PATCH 0158/1070] Drop initialize_options helper from OptionsFlow (#129870) --- homeassistant/config_entries.py | 6 +----- homeassistant/helpers/schema_config_entry_flow.py | 4 +++- tests/helpers/test_schema_config_entry_flow.py | 4 ++++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 0682d46924d99..6a95707dcdaa9 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3127,10 +3127,6 @@ def config_entry(self, value: ConfigEntry) -> None: ) self._config_entry = value - def initialize_options(self, config_entry: ConfigEntry) -> None: - """Initialize the options to a mutable copy of the config entry options.""" - self._options = deepcopy(dict(config_entry.options)) - @property def options(self) -> dict[str, Any]: """Return a mutable copy of the config entry options. @@ -3139,7 +3135,7 @@ def options(self) -> dict[str, Any]: can only be referenced after initialisation. """ if not hasattr(self, "_options"): - self.initialize_options(self.config_entry) + self._options = deepcopy(dict(self.config_entry.options)) return self._options @options.setter diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 58a44f9682d52..b956a58398a5e 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -421,7 +421,9 @@ def __init__( options, which is the union of stored options and user input from the options flow steps. """ - self.initialize_options(config_entry) + # Although `self.options` is most likely unused, it is safer to keep both + # `self.options` and `self._common_handler.options` referring to the same object + self._options = copy.deepcopy(dict(config_entry.options)) self._common_handler = SchemaCommonFlowHandler(self, options_flow, self.options) self._async_options_flow_finished = async_options_flow_finished diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index 877e3762d3bed..e67525253bcf7 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -648,6 +648,10 @@ class TestFlow(MockSchemaConfigFlowHandler, domain="test"): options_handler = hass.config_entries.options._progress[result["flow_id"]] assert options_handler._common_handler.flow_state == {"idx": None} + # Ensure that self.options and self._common_handler.options refer to the + # same mutable copy of the options + assert options_handler.options is options_handler._common_handler.options + # In step 1, flow state is updated with user input result = await hass.config_entries.options.async_configure( result["flow_id"], {"option1": "blublu"} From eafed2b86c030c68250e9f74fc1e2d32e90b68cf Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 5 Nov 2024 12:29:51 +0100 Subject: [PATCH 0159/1070] Append a 1 to all go2rtc ports to avoid port conflicts (#129881) --- homeassistant/components/go2rtc/__init__.py | 4 ++-- homeassistant/components/go2rtc/const.py | 3 ++- homeassistant/components/go2rtc/server.py | 17 +++++++++++------ tests/components/go2rtc/test_server.py | 5 +++-- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 2bcdaddf7391e..9ffe9e25f78b1 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -38,7 +38,7 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DEFAULT_URL, DOMAIN +from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, HA_MANAGED_URL from .server import Server _LOGGER = logging.getLogger(__name__) @@ -125,7 +125,7 @@ async def on_stop(event: Event) -> None: hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) - url = DEFAULT_URL + url = HA_MANAGED_URL hass.data[_DATA_GO2RTC] = url discovery_flow.async_create_flow( diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index cb03e224e5250..d33ae3e389759 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -4,4 +4,5 @@ CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." -DEFAULT_URL = "http://localhost:1984/" +HA_MANAGED_API_PORT = 11984 +HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index eff067416b3fd..6384cc5d49b3d 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -11,7 +11,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DEFAULT_URL +from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL _LOGGER = logging.getLogger(__name__) _TERMINATE_TIMEOUT = 5 @@ -26,13 +26,14 @@ # - Clear default ice servers _GO2RTC_CONFIG_FORMAT = r""" api: - listen: "{api_ip}:1984" + listen: "{api_ip}:{api_port}" rtsp: # ffmpeg needs rtsp for opus audio transcoding - listen: "127.0.0.1:8554" + listen: "127.0.0.1:18554" webrtc: + listen: ":18555/tcp" ice_servers: [] """ @@ -52,7 +53,11 @@ def _create_temp_file(api_ip: str) -> str: # Set delete=False to prevent the file from being deleted when the file is closed # Linux is clearing tmp folder on reboot, so no need to delete it manually with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: - file.write(_GO2RTC_CONFIG_FORMAT.format(api_ip=api_ip).encode()) + file.write( + _GO2RTC_CONFIG_FORMAT.format( + api_ip=api_ip, api_port=HA_MANAGED_API_PORT + ).encode() + ) return file.name @@ -113,7 +118,7 @@ async def _start(self) -> None: raise Go2RTCServerStartError from err # Check the server version - client = Go2RtcRestClient(async_get_clientsession(self._hass), DEFAULT_URL) + client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL) await client.validate_server_version() async def _log_output(self, process: asyncio.subprocess.Process) -> None: @@ -173,7 +178,7 @@ async def _monitor_process(self) -> None: async def _monitor_api(self) -> None: """Raise if the go2rtc process terminates.""" - client = Go2RtcRestClient(async_get_clientsession(self._hass), DEFAULT_URL) + client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL) _LOGGER.debug("Monitoring go2rtc API") try: diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index fedf155baf561..5b430d6664187 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -71,13 +71,14 @@ async def test_server_run_success( mock_tempfile.write.assert_called_once_with( f""" api: - listen: "{api_ip}:1984" + listen: "{api_ip}:11984" rtsp: # ffmpeg needs rtsp for opus audio transcoding - listen: "127.0.0.1:8554" + listen: "127.0.0.1:18554" webrtc: + listen: ":18555/tcp" ice_servers: [] """.encode() ) From 15bf652f37fe492ed067682c159742a90a0f3316 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Tue, 5 Nov 2024 12:30:48 +0100 Subject: [PATCH 0160/1070] Bump python-tado to 0.17.7 (#129842) --- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tado/fixtures/home.json | 47 +++++++++++++++++++++ tests/components/tado/util.py | 5 +++ 5 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 tests/components/tado/fixtures/home.json diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index b0c00c888b7b6..652d51f02619b 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.17.6"] + "requirements": ["python-tado==0.17.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 484d6341a9a5e..89114ef7724d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2405,7 +2405,7 @@ python-smarttub==0.0.36 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.17.6 +python-tado==0.17.7 # homeassistant.components.technove python-technove==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 656e3b1b63c39..0a763845ded38 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1926,7 +1926,7 @@ python-smarttub==0.0.36 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.17.6 +python-tado==0.17.7 # homeassistant.components.technove python-technove==1.3.1 diff --git a/tests/components/tado/fixtures/home.json b/tests/components/tado/fixtures/home.json new file mode 100644 index 0000000000000..3431c1c24713c --- /dev/null +++ b/tests/components/tado/fixtures/home.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "name": "My Home", + "dateTimeZone": "Europe/Berlin", + "dateCreated": "2019-03-24T16:16:19.541Z", + "temperatureUnit": "CELSIUS", + "partner": null, + "simpleSmartScheduleEnabled": true, + "awayRadiusInMeters": 100.0, + "installationCompleted": true, + "incidentDetection": { "supported": true, "enabled": true }, + "generation": "PRE_LINE_X", + "zonesCount": 7, + "language": "de-DE", + "skills": ["AUTO_ASSIST"], + "christmasModeEnabled": true, + "showAutoAssistReminders": true, + "contactDetails": { + "name": "Max Mustermann", + "email": "max@example.com", + "phone": "+493023125431" + }, + "address": { + "addressLine1": "Musterstrasse 123", + "addressLine2": null, + "zipCode": "12345", + "city": "Berlin", + "state": null, + "country": "DEU" + }, + "geolocation": { "latitude": 52.0, "longitude": 13.0 }, + "consentGrantSkippable": true, + "enabledFeatures": [ + "EIQ_SETTINGS_AS_WEBVIEW", + "HIDE_BOILER_REPAIR_SERVICE", + "INTERCOM_ENABLED", + "MORE_AS_WEBVIEW", + "OWD_SETTINGS_AS_WEBVIEW", + "SETTINGS_OVERVIEW_AS_WEBVIEW" + ], + "isAirComfortEligible": true, + "isBalanceAcEligible": false, + "isEnergyIqEligible": true, + "isHeatSourceInstalled": false, + "isHeatPumpInstalled": false, + "supportsFlowTemperatureOptimization": false +} diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index de4fd515e5a94..a76858ab98ea6 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -20,6 +20,7 @@ async def async_init_integration( mobile_devices_fixture = "tado/mobile_devices.json" me_fixture = "tado/me.json" weather_fixture = "tado/weather.json" + home_fixture = "tado/home.json" home_state_fixture = "tado/home_state.json" zones_fixture = "tado/zones.json" zone_states_fixture = "tado/zone_states.json" @@ -65,6 +66,10 @@ async def async_init_integration( "https://my.tado.com/api/v2/me", text=load_fixture(me_fixture), ) + m.get( + "https://my.tado.com/api/v2/homes/1/", + text=load_fixture(home_fixture), + ) m.get( "https://my.tado.com/api/v2/homes/1/weather", text=load_fixture(weather_fixture), From 4c86102dafad5cd78006a05981da48cc012d92e7 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 5 Nov 2024 13:39:45 +0100 Subject: [PATCH 0161/1070] Add Reolink PTZ tilt position sensor (#129837) --- homeassistant/components/reolink/icons.json | 5 ++++- homeassistant/components/reolink/sensor.py | 11 ++++++++++- homeassistant/components/reolink/strings.json | 3 +++ .../reolink/snapshots/test_diagnostics.ambr | 4 ++-- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 5815e165607a1..7f4a15ffe213e 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -261,7 +261,10 @@ }, "sensor": { "ptz_pan_position": { - "default": "mdi:pan" + "default": "mdi:pan-horizontal" + }, + "ptz_tilt_position": { + "default": "mdi:pan-vertical" }, "battery_temperature": { "default": "mdi:thermometer" diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index c2fc815235ee5..80e58c3d5c28b 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -58,7 +58,16 @@ class ReolinkHostSensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda api, ch: api.ptz_pan_position(ch), - supported=lambda api, ch: api.supported(ch, "ptz_position"), + supported=lambda api, ch: api.supported(ch, "ptz_pan_position"), + ), + ReolinkSensorEntityDescription( + key="ptz_tilt_position", + cmd_key="GetPtzCurPos", + translation_key="ptz_tilt_position", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda api, ch: api.ptz_tilt_position(ch), + supported=lambda api, ch: api.supported(ch, "ptz_tilt_position"), ), ReolinkSensorEntityDescription( key="battery_percent", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 67fd5329e14d4..fbc88ed1b506d 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -649,6 +649,9 @@ "ptz_pan_position": { "name": "PTZ pan position" }, + "ptz_tilt_position": { + "name": "PTZ tilt position" + }, "battery_temperature": { "name": "Battery temperature" }, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 33e9c78c55022..71c5397fbd105 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -118,8 +118,8 @@ 'null': 2, }), 'GetPtzCurPos': dict({ - '0': 1, - 'null': 1, + '0': 2, + 'null': 2, }), 'GetPtzGuard': dict({ '0': 2, From 3a667bce8cb33dc609c4affa51acc87e26b351c1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Nov 2024 14:05:04 +0100 Subject: [PATCH 0162/1070] Log go2rtc output with warning level on error (#129882) --- homeassistant/components/go2rtc/server.py | 13 ++++ tests/components/go2rtc/test_server.py | 89 +++++++++++++++++++---- 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index 6384cc5d49b3d..9be02d9a5d692 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -1,6 +1,7 @@ """Go2rtc server.""" import asyncio +from collections import deque from contextlib import suppress import logging from tempfile import NamedTemporaryFile @@ -18,6 +19,7 @@ _SETUP_TIMEOUT = 30 _SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr=" _LOCALHOST_IP = "127.0.0.1" +_LOG_BUFFER_SIZE = 512 _RESPAWN_COOLDOWN = 1 # Default configuration for HA @@ -70,6 +72,7 @@ def __init__( """Initialize the server.""" self._hass = hass self._binary = binary + self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE) self._process: asyncio.subprocess.Process | None = None self._startup_complete = asyncio.Event() self._api_ip = _LOCALHOST_IP @@ -114,6 +117,7 @@ async def _start(self) -> None: except TimeoutError as err: msg = "Go2rtc server didn't start correctly" _LOGGER.exception(msg) + self._log_server_output(logging.WARNING) await self._stop() raise Go2RTCServerStartError from err @@ -127,10 +131,17 @@ async def _log_output(self, process: asyncio.subprocess.Process) -> None: async for line in process.stdout: msg = line[:-1].decode().strip() + self._log_buffer.append(msg) _LOGGER.debug(msg) if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg: self._startup_complete.set() + def _log_server_output(self, loglevel: int) -> None: + """Log captured process output, then clear the log buffer.""" + for line in list(self._log_buffer): # Copy the deque to avoid mutation error + _LOGGER.log(loglevel, line) + self._log_buffer.clear() + async def _watchdog(self) -> None: """Keep respawning go2rtc servers. @@ -158,6 +169,8 @@ async def _watchdog(self) -> None: await asyncio.sleep(_RESPAWN_COOLDOWN) try: await self._stop() + _LOGGER.warning("Go2rtc unexpectedly stopped, server log:") + self._log_server_output(logging.WARNING) _LOGGER.debug("Spawning new go2rtc server") with suppress(Go2RTCServerStartError): await self._start() diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index 5b430d6664187..cda05fc4f2bde 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -38,6 +38,42 @@ def mock_tempfile() -> Generator[Mock]: yield file +def _assert_server_output_logged( + server_stdout: list[str], + caplog: pytest.LogCaptureFixture, + loglevel: int, + expect_logged: bool, +) -> None: + """Check server stdout was logged.""" + for entry in server_stdout: + assert ( + ( + "homeassistant.components.go2rtc.server", + loglevel, + entry, + ) + in caplog.record_tuples + ) is expect_logged + + +def assert_server_output_logged( + server_stdout: list[str], + caplog: pytest.LogCaptureFixture, + loglevel: int, +) -> None: + """Check server stdout was logged.""" + _assert_server_output_logged(server_stdout, caplog, loglevel, True) + + +def assert_server_output_not_logged( + server_stdout: list[str], + caplog: pytest.LogCaptureFixture, + loglevel: int, +) -> None: + """Check server stdout was logged.""" + _assert_server_output_logged(server_stdout, caplog, loglevel, False) + + @pytest.mark.parametrize( ("enable_ui", "api_ip"), [ @@ -83,17 +119,15 @@ async def test_server_run_success( """.encode() ) - # Check that server read the log lines - for entry in server_stdout: - assert ( - "homeassistant.components.go2rtc.server", - logging.DEBUG, - entry, - ) in caplog.record_tuples + # Verify go2rtc binary stdout was logged with debug level + assert_server_output_logged(server_stdout, caplog, logging.DEBUG) await server.stop() mock_create_subprocess.return_value.terminate.assert_called_once() + # Verify go2rtc binary stdout was not logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + @pytest.mark.usefixtures("mock_tempfile") async def test_server_timeout_on_stop( @@ -140,13 +174,9 @@ async def test_server_failed_to_start( ): await server.start() - # Verify go2rtc binary stdout was logged - for entry in server_stdout: - assert ( - "homeassistant.components.go2rtc.server", - logging.DEBUG, - entry, - ) in caplog.record_tuples + # Verify go2rtc binary stdout was logged with debug and warning level + assert_server_output_logged(server_stdout, caplog, logging.DEBUG) + assert_server_output_logged(server_stdout, caplog, logging.WARNING) assert ( "homeassistant.components.go2rtc.server", @@ -169,8 +199,10 @@ async def test_server_failed_to_start( async def test_server_restart_process_exit( hass: HomeAssistant, mock_create_subprocess: AsyncMock, + server_stdout: list[str], rest_client: AsyncMock, server: Server, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that the server is restarted when it exits.""" evt = asyncio.Event() @@ -188,10 +220,16 @@ async def wait_event() -> None: await hass.async_block_till_done() mock_create_subprocess.assert_not_awaited() + # Verify go2rtc binary stdout was not yet logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + evt.set() await asyncio.sleep(0.1) mock_create_subprocess.assert_awaited_once() + # Verify go2rtc binary stdout was logged with warning level + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + await server.stop() @@ -199,8 +237,10 @@ async def wait_event() -> None: async def test_server_restart_process_error( hass: HomeAssistant, mock_create_subprocess: AsyncMock, + server_stdout: list[str], rest_client: AsyncMock, server: Server, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that the server is restarted on error.""" mock_create_subprocess.return_value.wait.side_effect = [Exception, None, None, None] @@ -209,10 +249,16 @@ async def test_server_restart_process_error( mock_create_subprocess.assert_awaited_once() mock_create_subprocess.reset_mock() + # Verify go2rtc binary stdout was not yet logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + await asyncio.sleep(0.1) await hass.async_block_till_done() mock_create_subprocess.assert_awaited_once() + # Verify go2rtc binary stdout was logged with warning level + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + await server.stop() @@ -220,8 +266,10 @@ async def test_server_restart_process_error( async def test_server_restart_api_error( hass: HomeAssistant, mock_create_subprocess: AsyncMock, + server_stdout: list[str], rest_client: AsyncMock, server: Server, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that the server is restarted on error.""" rest_client.streams.list.side_effect = Exception @@ -230,10 +278,16 @@ async def test_server_restart_api_error( mock_create_subprocess.assert_awaited_once() mock_create_subprocess.reset_mock() + # Verify go2rtc binary stdout was not yet logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + await asyncio.sleep(0.1) await hass.async_block_till_done() mock_create_subprocess.assert_awaited_once() + # Verify go2rtc binary stdout was logged with warning level + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + await server.stop() @@ -241,6 +295,7 @@ async def test_server_restart_api_error( async def test_server_restart_error( hass: HomeAssistant, mock_create_subprocess: AsyncMock, + server_stdout: list[str], rest_client: AsyncMock, server: Server, caplog: pytest.LogCaptureFixture, @@ -253,10 +308,16 @@ async def test_server_restart_error( mock_create_subprocess.assert_awaited_once() mock_create_subprocess.reset_mock() + # Verify go2rtc binary stdout was not yet logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + await asyncio.sleep(0.1) await hass.async_block_till_done() mock_create_subprocess.assert_awaited_once() + # Verify go2rtc binary stdout was logged with warning level + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + assert "Unexpected error when restarting go2rtc server" in caplog.text await server.stop() From 8abbc4abbc439d0c4f0f16664067a08b7df07da1 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Tue, 5 Nov 2024 14:13:48 +0100 Subject: [PATCH 0163/1070] Bump bimmer_connected to 0.16.4 (#129838) --- .../bmw_connected_drive/config_flow.py | 14 +++++- .../bmw_connected_drive/coordinator.py | 13 +++++- .../bmw_connected_drive/manifest.json | 2 +- .../bmw_connected_drive/strings.json | 6 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bmw_connected_drive/test_config_flow.py | 35 ++++++++++++++- .../bmw_connected_drive/test_coordinator.py | 43 ++++++++++++++++++- 8 files changed, 109 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index cd43325f1295c..409bfdca6f189 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -7,7 +7,11 @@ from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError import voluptuous as vol @@ -54,6 +58,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, try: await auth.login() + except MyBMWCaptchaMissingError as ex: + raise MissingCaptcha from ex except MyBMWAuthError as ex: raise InvalidAuth from ex except (MyBMWAPIError, RequestError) as ex: @@ -98,6 +104,8 @@ async def async_step_user( CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), CONF_GCID: info.get(CONF_GCID), } + except MissingCaptcha: + errors["base"] = "missing_captcha" except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -192,3 +200,7 @@ class CannotConnect(HomeAssistantError): class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class MissingCaptcha(HomeAssistantError): + """Error to indicate the captcha token is missing.""" diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 992e7dea6b263..d38b7ffacc2a7 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -7,7 +7,12 @@ from bimmer_connected.account import MyBMWAccount from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + GPSPosition, + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError from homeassistant.config_entries import ConfigEntry @@ -61,6 +66,12 @@ async def _async_update_data(self) -> None: try: await self.account.get_vehicles() + except MyBMWCaptchaMissingError as err: + # If a captcha is required (user/password login flow), always trigger the reauth flow + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="missing_captcha", + ) from err except MyBMWAuthError as err: # Allow one retry interval before raising AuthFailed to avoid flaky API issues if self.last_update_success: diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 6bc9027ac1998..584eb1eebb554 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], "quality_scale": "platinum", - "requirements": ["bimmer-connected[china]==0.16.3"] + "requirements": ["bimmer-connected[china]==0.16.4"] } diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index fed71f85e3552..0e7a4a32ef45e 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -11,7 +11,8 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_captcha": "Captcha validation missing" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", @@ -200,6 +201,9 @@ "exceptions": { "invalid_poi": { "message": "Invalid data for point of interest: {poi_exception}" + }, + "missing_captcha": { + "message": "Login requires captcha validation" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 89114ef7724d7..6bd9afc33c040 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -576,7 +576,7 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.3 +bimmer-connected[china]==0.16.4 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0a763845ded38..f617bab52c656 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -510,7 +510,7 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.3 +bimmer-connected[china]==0.16.4 # homeassistant.components.eq3btsmart # homeassistant.components.esphome diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 9d4d15703f271..f57f1a304ac01 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -4,8 +4,13 @@ from unittest.mock import patch from bimmer_connected.api.authentication import MyBMWAuthentication -from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError +import pytest from homeassistant import config_entries from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN @@ -311,3 +316,31 @@ async def test_reconfigure_unique_id_abort(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "account_mismatch" assert config_entry.data == FIXTURE_COMPLETE_ENTRY + + +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_flow_not_set(hass: HomeAssistant) -> None: + """Test the external flow with captcha failing once and succeeding the second time.""" + + TEST_REGION = "north_america" + + # Start flow and open form + # Start flow and open form + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Add login data + with patch( + "bimmer_connected.api.authentication.MyBMWAuthentication._login_row_na", + side_effect=MyBMWCaptchaMissingError( + "Missing hCaptcha token for North America login" + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION}, + ) + assert result["errors"]["base"] == "missing_captcha" diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py index b0f507bbfc2b2..774a85eb6da03 100644 --- a/tests/components/bmw_connected_drive/test_coordinator.py +++ b/tests/components/bmw_connected_drive/test_coordinator.py @@ -1,13 +1,19 @@ """Test BMW coordinator.""" +from copy import deepcopy from datetime import timedelta from unittest.mock import patch -from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.const import CONF_REGION from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import issue_registry as ir @@ -122,3 +128,38 @@ async def test_init_reauth( f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}", ) assert reauth_issue.active is True + + +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_reauth( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the reauth form.""" + TEST_REGION = "north_america" + + config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY) + config_entry_fixure["data"][CONF_REGION] = TEST_REGION + config_entry = MockConfigEntry(**config_entry_fixure) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = config_entry.runtime_data.coordinator + + assert coordinator.last_update_success is True + + freezer.tick(timedelta(minutes=10, seconds=1)) + with patch( + "bimmer_connected.account.MyBMWAccount.get_vehicles", + side_effect=MyBMWCaptchaMissingError( + "Missing hCaptcha token for North America login" + ), + ): + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert coordinator.last_update_success is False + assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True + assert coordinator.last_exception.translation_key == "missing_captcha" From 4729b19dc6a90ca96bd67fe65fc1b01ca65a7df2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 5 Nov 2024 14:44:37 +0100 Subject: [PATCH 0164/1070] Skip adding providers if the camera has native WebRTC (#129808) * Skip adding providers if the camera has native WebRTC * Update homeassistant/components/camera/__init__.py Co-authored-by: Martin Hjelmare * Implement suggestion * Add tests * Shorten test name * Fix test --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/camera/__init__.py | 40 ++++++++------ tests/components/camera/common.py | 50 +++++++++++++++++ tests/components/camera/conftest.py | 49 ++++++++++++++--- tests/components/camera/test_init.py | 20 ++++++- tests/components/camera/test_webrtc.py | 60 ++------------------- 5 files changed, 136 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 47d8b9dfbd0b0..b600eae02c711 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -484,9 +484,13 @@ def __init__(self) -> None: self._create_stream_lock: asyncio.Lock | None = None self._webrtc_provider: CameraWebRTCProvider | None = None self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None - self._webrtc_sync_offer = ( + self._supports_native_sync_webrtc = ( type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer ) + self._supports_native_async_webrtc = ( + type(self).async_handle_async_webrtc_offer + != Camera.async_handle_async_webrtc_offer + ) @cached_property def entity_picture(self) -> str: @@ -623,7 +627,7 @@ async def async_handle_async_webrtc_offer( Integrations can override with a native WebRTC implementation. """ - if self._webrtc_sync_offer: + if self._supports_native_sync_webrtc: try: answer = await self.async_handle_web_rtc_offer(offer_sdp) except ValueError as ex: @@ -788,18 +792,25 @@ async def async_refresh_providers(self, *, write_state: bool = True) -> None: providers or inputs to the state attributes change. """ old_provider = self._webrtc_provider - new_provider = await self._async_get_supported_webrtc_provider( - async_get_supported_provider - ) - old_legacy_provider = self._legacy_webrtc_provider + new_provider = None new_legacy_provider = None - if new_provider is None: - # Only add the legacy provider if the new provider is not available - new_legacy_provider = await self._async_get_supported_webrtc_provider( - async_get_supported_legacy_provider + + # Skip all providers if the camera has a native WebRTC implementation + if not ( + self._supports_native_sync_webrtc or self._supports_native_async_webrtc + ): + # Camera doesn't have a native WebRTC implementation + new_provider = await self._async_get_supported_webrtc_provider( + async_get_supported_provider ) + if new_provider is None: + # Only add the legacy provider if the new provider is not available + new_legacy_provider = await self._async_get_supported_webrtc_provider( + async_get_supported_legacy_provider + ) + if old_provider != new_provider or old_legacy_provider != new_legacy_provider: self._webrtc_provider = new_provider self._legacy_webrtc_provider = new_legacy_provider @@ -827,7 +838,7 @@ def async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration: """Return the WebRTC client configuration and extend it with the registered ice servers.""" config = self._async_get_webrtc_client_configuration() - if not self._webrtc_sync_offer: + if not self._supports_native_sync_webrtc: # Until 2024.11, the frontend was not resolving any ice servers # The async approach was added 2024.11 and new integrations need to use it ice_servers = [ @@ -867,12 +878,7 @@ def camera_capabilities(self) -> CameraCapabilities: """Return the camera capabilities.""" frontend_stream_types = set() if CameraEntityFeature.STREAM in self.supported_features_compat: - if ( - type(self).async_handle_web_rtc_offer - != Camera.async_handle_web_rtc_offer - or type(self).async_handle_async_webrtc_offer - != Camera.async_handle_async_webrtc_offer - ): + if self._supports_native_sync_webrtc or self._supports_native_async_webrtc: # The camera has a native WebRTC implementation frontend_stream_types.add(StreamType.WEB_RTC) else: diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index f7dcf46db01ac..569756c26401e 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -6,6 +6,16 @@ from unittest.mock import Mock +from webrtc_models import RTCIceCandidate + +from homeassistant.components.camera import ( + Camera, + CameraWebRTCProvider, + WebRTCAnswer, + WebRTCSendMessage, +) +from homeassistant.core import callback + EMPTY_8_6_JPEG = b"empty_8_6" WEBRTC_ANSWER = "a=sendonly" STREAM_SOURCE = "rtsp://127.0.0.1/stream" @@ -23,3 +33,43 @@ def mock_turbo_jpeg( mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG mocked_turbo_jpeg.encode.return_value = EMPTY_8_6_JPEG return mocked_turbo_jpeg + + +class SomeTestProvider(CameraWebRTCProvider): + """Test provider.""" + + def __init__(self) -> None: + """Initialize the provider.""" + self._is_supported = True + + @property + def domain(self) -> str: + """Return the integration domain of the provider.""" + return "some_test" + + @callback + def async_is_supported(self, stream_source: str) -> bool: + """Determine if the provider supports the stream source.""" + return self._is_supported + + async def async_handle_async_webrtc_offer( + self, + camera: Camera, + offer_sdp: str, + session_id: str, + send_message: WebRTCSendMessage, + ) -> None: + """Handle the WebRTC offer and return the answer via the provided callback. + + Return value determines if the offer was handled successfully. + """ + send_message(WebRTCAnswer(answer="answer")) + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: + """Handle the WebRTC candidate.""" + + @callback + def async_close_session(self, session_id: str) -> None: + """Close the session.""" diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index a88cd898e335a..d6343959d411c 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, Mock, PropertyMock, patch import pytest +from webrtc_models import RTCIceCandidate from homeassistant.components import camera from homeassistant.components.camera.const import StreamType @@ -14,7 +15,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.setup import async_setup_component -from .common import STREAM_SOURCE, WEBRTC_ANSWER +from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider from tests.common import ( MockConfigEntry, @@ -155,16 +156,15 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]: @pytest.fixture -async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None: - """Initialize a test camera with native sync WebRTC support.""" +async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None: + """Initialize a test WebRTC cameras.""" # Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer # and native support is checked by verify the function "async_handle_web_rtc_offer" was # overwritten(implemented) or not - class MockCamera(camera.Camera): - """Mock Camera Entity.""" + class BaseCamera(camera.Camera): + """Base Camera.""" - _attr_name = "Test" _attr_supported_features: camera.CameraEntityFeature = ( camera.CameraEntityFeature.STREAM ) @@ -173,9 +173,30 @@ class MockCamera(camera.Camera): async def stream_source(self) -> str | None: return STREAM_SOURCE + class SyncCamera(BaseCamera): + """Mock Camera with native sync WebRTC support.""" + + _attr_name = "Sync" + async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: return WEBRTC_ANSWER + class AsyncCamera(BaseCamera): + """Mock Camera with native async WebRTC support.""" + + _attr_name = "Async" + + async def async_handle_async_webrtc_offer( + self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage + ) -> None: + send_message(WebRTCAnswer(WEBRTC_ANSWER)) + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: + """Handle a WebRTC candidate.""" + # Do nothing + domain = "test" entry = MockConfigEntry(domain=domain) @@ -208,10 +229,24 @@ async def async_unload_entry_init( ), ) setup_test_component_platform( - hass, camera.DOMAIN, [MockCamera()], from_config_entry=True + hass, camera.DOMAIN, [SyncCamera(), AsyncCamera()], from_config_entry=True ) mock_platform(hass, f"{domain}.config_flow", Mock()) with mock_config_flow(domain, ConfigFlow): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + + +@pytest.fixture +async def register_test_provider( + hass: HomeAssistant, +) -> AsyncGenerator[SomeTestProvider]: + """Add WebRTC test provider.""" + await async_setup_component(hass, "camera", {}) + + provider = SomeTestProvider() + unsub = camera.async_register_webrtc_provider(hass, provider) + await hass.async_block_till_done() + yield provider + unsub() diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 0a173065564e5..621ac8b7fb319 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -979,7 +979,7 @@ async def test_camera_capabilities_hls( ) -@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_camera_capabilities_webrtc( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -987,5 +987,21 @@ async def test_camera_capabilities_webrtc( """Test WebRTC camera capabilities.""" await _test_capabilities( - hass, hass_ws_client, "camera.test", {StreamType.WEB_RTC}, {StreamType.WEB_RTC} + hass, hass_ws_client, "camera.sync", {StreamType.WEB_RTC}, {StreamType.WEB_RTC} ) + + +@pytest.mark.parametrize( + ("entity_id", "expect_native_async_webrtc"), + [("camera.sync", False), ("camera.async", True)], +) +@pytest.mark.usefixtures("mock_test_webrtc_cameras", "register_test_provider") +async def test_webrtc_provider_not_added_for_native_webrtc( + hass: HomeAssistant, entity_id: str, expect_native_async_webrtc: bool +) -> None: + """Test that a WebRTC provider is not added to a camera when the camera has native WebRTC support.""" + camera_obj = get_camera_from_entity_id(hass, entity_id) + assert camera_obj + assert camera_obj._webrtc_provider is None + assert camera_obj._supports_native_sync_webrtc is not expect_native_async_webrtc + assert camera_obj._supports_native_async_webrtc is expect_native_async_webrtc diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 2970a41408c98..f726eb2967388 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -34,7 +34,7 @@ from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from .common import STREAM_SOURCE, WEBRTC_ANSWER +from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider from tests.common import ( MockConfigEntry, @@ -51,46 +51,6 @@ TEST_INTEGRATION_DOMAIN = "test" -class SomeTestProvider(CameraWebRTCProvider): - """Test provider.""" - - def __init__(self) -> None: - """Initialize the provider.""" - self._is_supported = True - - @property - def domain(self) -> str: - """Return the integration domain of the provider.""" - return "some_test" - - @callback - def async_is_supported(self, stream_source: str) -> bool: - """Determine if the provider supports the stream source.""" - return self._is_supported - - async def async_handle_async_webrtc_offer( - self, - camera: Camera, - offer_sdp: str, - session_id: str, - send_message: WebRTCSendMessage, - ) -> None: - """Handle the WebRTC offer and return the answer via the provided callback. - - Return value determines if the offer was handled successfully. - """ - send_message(WebRTCAnswer(answer="answer")) - - async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidate - ) -> None: - """Handle the WebRTC candidate.""" - - @callback - def async_close_session(self, session_id: str) -> None: - """Close the session.""" - - class Go2RTCProvider(SomeTestProvider): """go2rtc provider.""" @@ -179,20 +139,6 @@ async def async_unload_entry_init( return test_camera -@pytest.fixture -async def register_test_provider( - hass: HomeAssistant, -) -> AsyncGenerator[SomeTestProvider]: - """Add WebRTC test provider.""" - await async_setup_component(hass, "camera", {}) - - provider = SomeTestProvider() - unsub = async_register_webrtc_provider(hass, provider) - await hass.async_block_till_done() - yield provider - unsub() - - @pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") async def test_async_register_webrtc_provider( hass: HomeAssistant, @@ -393,7 +339,7 @@ def get_ice_server() -> list[RTCIceServer]: } -@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_get_client_config_sync_offer( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -403,7 +349,7 @@ async def test_ws_get_client_config_sync_offer( client = await hass_ws_client(hass) await client.send_json_auto_id( - {"type": "camera/webrtc/get_client_config", "entity_id": "camera.test"} + {"type": "camera/webrtc/get_client_config", "entity_id": "camera.sync"} ) msg = await client.receive_json() From 6caa4baa007e160d673029c4d84eb0fb35980292 Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Tue, 5 Nov 2024 14:58:25 +0100 Subject: [PATCH 0165/1070] Fix missing translation string in emoncms (#129859) --- homeassistant/components/emoncms/config_flow.py | 10 ++++++++-- homeassistant/components/emoncms/strings.json | 6 ++++++ tests/components/emoncms/test_config_flow.py | 11 +++-------- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index fa68418871308..e2e08217b3cb6 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -79,6 +79,7 @@ async def async_step_user( ) -> ConfigFlowResult: """Initiate a flow via the UI.""" errors: dict[str, str] = {} + description_placeholders = {} if user_input is not None: self._async_abort_entries_match( @@ -91,7 +92,8 @@ async def async_step_user( self.hass, user_input[CONF_URL], user_input[CONF_API_KEY] ) if not result[CONF_SUCCESS]: - errors["base"] = result[CONF_MESSAGE] + errors["base"] = "api_error" + description_placeholders = {"details": result[CONF_MESSAGE]} else: self.include_only_feeds = user_input.get(CONF_ONLY_INCLUDE_FEEDID) self.url = user_input[CONF_URL] @@ -115,6 +117,7 @@ async def async_step_user( user_input, ), errors=errors, + description_placeholders=description_placeholders, ) async def async_step_choose_feeds( @@ -177,6 +180,7 @@ async def async_step_init( ) -> ConfigFlowResult: """Manage the options.""" errors: dict[str, str] = {} + description_placeholders = {} data = self.options if self.options else self.config_entry.data url = data[CONF_URL] api_key = data[CONF_API_KEY] @@ -184,7 +188,8 @@ async def async_step_init( options: list = include_only_feeds result = await get_feed_list(self.hass, url, api_key) if not result[CONF_SUCCESS]: - errors["base"] = result[CONF_MESSAGE] + errors["base"] = "api_error" + description_placeholders = {"details": result[CONF_MESSAGE]} else: options = get_options(result[CONF_MESSAGE]) dropdown = {"options": options, "mode": "dropdown", "multiple": True} @@ -209,4 +214,5 @@ async def async_step_init( } ), errors=errors, + description_placeholders=description_placeholders, ) diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index 4a700cc8981e0..e2b7602f6f268 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "api_error": "An error occured in the pyemoncms API : {details}" + }, "step": { "user": { "data": { @@ -19,6 +22,9 @@ } }, "options": { + "error": { + "api_error": "[%key:component::emoncms::config::error::api_error%]" + }, "step": { "init": { "data": { diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index b554466639e13..43710967a0154 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -2,8 +2,6 @@ from unittest.mock import AsyncMock -import pytest - from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_URL @@ -44,7 +42,7 @@ async def test_flow_import_failure( data=YAML, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == EMONCMS_FAILURE["message"] + assert result["reason"] == "api_error" async def test_flow_import_already_configured( @@ -129,10 +127,6 @@ async def test_options_flow( assert config_entry.options == CONFIG_ENTRY -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.emoncms.options.error.failure"], -) async def test_options_flow_failure( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -144,6 +138,7 @@ async def test_options_flow_failure( await setup_integration(hass, config_entry) result = await hass.config_entries.options.async_init(config_entry.entry_id) await hass.async_block_till_done() - assert result["errors"]["base"] == "failure" + assert result["errors"]["base"] == "api_error" + assert result["description_placeholders"]["details"] == "failure" assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" From 69e3348cd79abc6b3ee86bb05edeff605fbc4a4e Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 5 Nov 2024 08:01:45 -0600 Subject: [PATCH 0166/1070] Use different VAD thresholds for before and during voice command (#129848) * Use two VAD thresholds * Fix VoiceActivityTimeout class * Update homeassistant/components/assist_pipeline/audio_enhancer.py --------- Co-authored-by: Joost Lekkerkerker --- .../assist_pipeline/audio_enhancer.py | 16 ++-- .../components/assist_pipeline/pipeline.py | 10 ++- .../components/assist_pipeline/vad.py | 62 +++++++++----- tests/components/assist_pipeline/test_vad.py | 80 ++++++++++++------- 4 files changed, 108 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/assist_pipeline/audio_enhancer.py b/homeassistant/components/assist_pipeline/audio_enhancer.py index ff2b122187a7e..1fabc7790e73f 100644 --- a/homeassistant/components/assist_pipeline/audio_enhancer.py +++ b/homeassistant/components/assist_pipeline/audio_enhancer.py @@ -22,8 +22,8 @@ class EnhancedAudioChunk: timestamp_ms: int """Timestamp relative to start of audio stream (milliseconds)""" - is_speech: bool | None - """True if audio chunk likely contains speech, False if not, None if unknown""" + speech_probability: float | None + """Probability that audio chunk contains speech (0-1), None if unknown""" class AudioEnhancer(ABC): @@ -70,27 +70,27 @@ def __init__( ) self.vad: MicroVad | None = None - self.threshold = 0.5 if self.is_vad_enabled: self.vad = MicroVad() - _LOGGER.debug("Initialized microVAD with threshold=%s", self.threshold) + _LOGGER.debug("Initialized microVAD") def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk: """Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples.""" - is_speech: bool | None = None + speech_probability: float | None = None assert len(audio) == BYTES_PER_CHUNK if self.vad is not None: # Run VAD - speech_prob = self.vad.Process10ms(audio) - is_speech = speech_prob > self.threshold + speech_probability = self.vad.Process10ms(audio) if self.audio_processor is not None: # Run noise suppression and auto gain audio = self.audio_processor.Process10ms(audio).audio return EnhancedAudioChunk( - audio=audio, timestamp_ms=timestamp_ms, is_speech=is_speech + audio=audio, + timestamp_ms=timestamp_ms, + speech_probability=speech_probability, ) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a4255e377568b..a55e23ae05189 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -780,7 +780,9 @@ async def wake_word_detection( # speaking the voice command. audio_chunks_for_stt.extend( EnhancedAudioChunk( - audio=chunk_ts[0], timestamp_ms=chunk_ts[1], is_speech=False + audio=chunk_ts[0], + timestamp_ms=chunk_ts[1], + speech_probability=None, ) for chunk_ts in result.queued_audio ) @@ -827,7 +829,7 @@ async def _wake_word_audio_stream( if wake_word_vad is not None: chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate - if not wake_word_vad.process(chunk_seconds, chunk.is_speech): + if not wake_word_vad.process(chunk_seconds, chunk.speech_probability): raise WakeWordTimeoutError( code="wake-word-timeout", message="Wake word was not detected" ) @@ -955,7 +957,7 @@ async def _speech_to_text_stream( if stt_vad is not None: chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate - if not stt_vad.process(chunk_seconds, chunk.is_speech): + if not stt_vad.process(chunk_seconds, chunk.speech_probability): # Silence detected at the end of voice command self.process_event( PipelineEvent( @@ -1221,7 +1223,7 @@ async def process_volume_only( yield EnhancedAudioChunk( audio=sub_chunk, timestamp_ms=timestamp_ms, - is_speech=None, # no VAD + speech_probability=None, # no VAD ) timestamp_ms += MS_PER_CHUNK diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index 4782d14dee47d..deae5b9b7b387 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -75,7 +75,7 @@ def __bool__(self) -> bool: class VoiceCommandSegmenter: """Segments an audio stream into voice commands.""" - speech_seconds: float = 0.3 + speech_seconds: float = 0.1 """Seconds of speech before voice command has started.""" command_seconds: float = 1.0 @@ -96,6 +96,12 @@ class VoiceCommandSegmenter: timed_out: bool = False """True a timeout occurred during voice command.""" + before_command_speech_threshold: float = 0.2 + """Probability threshold for speech before voice command.""" + + in_command_speech_threshold: float = 0.5 + """Probability threshold for speech during voice command.""" + _speech_seconds_left: float = 0.0 """Seconds left before considering voice command as started.""" @@ -124,7 +130,7 @@ def reset(self) -> None: self._reset_seconds_left = self.reset_seconds self.in_command = False - def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: + def process(self, chunk_seconds: float, speech_probability: float | None) -> bool: """Process samples using external VAD. Returns False when command is done. @@ -142,7 +148,12 @@ def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: self.timed_out = True return False + if speech_probability is None: + speech_probability = 0.0 + if not self.in_command: + # Before command + is_speech = speech_probability > self.before_command_speech_threshold if is_speech: self._reset_seconds_left = self.reset_seconds self._speech_seconds_left -= chunk_seconds @@ -160,24 +171,29 @@ def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: if self._reset_seconds_left <= 0: self._speech_seconds_left = self.speech_seconds self._reset_seconds_left = self.reset_seconds - elif not is_speech: - # Silence in command - self._reset_seconds_left = self.reset_seconds - self._silence_seconds_left -= chunk_seconds - self._command_seconds_left -= chunk_seconds - if (self._silence_seconds_left <= 0) and (self._command_seconds_left <= 0): - # Command finished successfully - self.reset() - _LOGGER.debug("Voice command finished") - return False else: - # Speech in command. - # Reset silence counter if enough speech. - self._reset_seconds_left -= chunk_seconds - self._command_seconds_left -= chunk_seconds - if self._reset_seconds_left <= 0: - self._silence_seconds_left = self.silence_seconds + # In command + is_speech = speech_probability > self.in_command_speech_threshold + if not is_speech: + # Silence in command self._reset_seconds_left = self.reset_seconds + self._silence_seconds_left -= chunk_seconds + self._command_seconds_left -= chunk_seconds + if (self._silence_seconds_left <= 0) and ( + self._command_seconds_left <= 0 + ): + # Command finished successfully + self.reset() + _LOGGER.debug("Voice command finished") + return False + else: + # Speech in command. + # Reset silence counter if enough speech. + self._reset_seconds_left -= chunk_seconds + self._command_seconds_left -= chunk_seconds + if self._reset_seconds_left <= 0: + self._silence_seconds_left = self.silence_seconds + self._reset_seconds_left = self.reset_seconds return True @@ -226,6 +242,9 @@ class VoiceActivityTimeout: reset_seconds: float = 0.5 """Seconds of speech before resetting timeout.""" + speech_threshold: float = 0.5 + """Threshold for speech.""" + _silence_seconds_left: float = 0.0 """Seconds left before considering voice command as stopped.""" @@ -241,12 +260,15 @@ def reset(self) -> None: self._silence_seconds_left = self.silence_seconds self._reset_seconds_left = self.reset_seconds - def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: + def process(self, chunk_seconds: float, speech_probability: float | None) -> bool: """Process samples using external VAD. Returns False when timeout is reached. """ - if is_speech: + if speech_probability is None: + speech_probability = 0.0 + + if speech_probability > self.speech_threshold: # Speech self._reset_seconds_left -= chunk_seconds if self._reset_seconds_left <= 0: diff --git a/tests/components/assist_pipeline/test_vad.py b/tests/components/assist_pipeline/test_vad.py index fda26d2fb94e6..bd07601cd5d10 100644 --- a/tests/components/assist_pipeline/test_vad.py +++ b/tests/components/assist_pipeline/test_vad.py @@ -16,7 +16,7 @@ def test_silence() -> None: segmenter = VoiceCommandSegmenter() # True return value indicates voice command has not finished - assert segmenter.process(_ONE_SECOND * 3, False) + assert segmenter.process(_ONE_SECOND * 3, 0.0) assert not segmenter.in_command @@ -26,15 +26,15 @@ def test_speech() -> None: segmenter = VoiceCommandSegmenter() # silence - assert segmenter.process(_ONE_SECOND, False) + assert segmenter.process(_ONE_SECOND, 0.0) # "speech" - assert segmenter.process(_ONE_SECOND, True) + assert segmenter.process(_ONE_SECOND, 1.0) assert segmenter.in_command # silence # False return value indicates voice command is finished - assert not segmenter.process(_ONE_SECOND, False) + assert not segmenter.process(_ONE_SECOND, 0.0) assert not segmenter.in_command @@ -112,19 +112,19 @@ def test_silence_seconds() -> None: segmenter = VoiceCommandSegmenter(silence_seconds=1.0) # silence - assert segmenter.process(_ONE_SECOND, False) + assert segmenter.process(_ONE_SECOND, 0.0) assert not segmenter.in_command # "speech" - assert segmenter.process(_ONE_SECOND, True) + assert segmenter.process(_ONE_SECOND, 1.0) assert segmenter.in_command # not enough silence to end - assert segmenter.process(_ONE_SECOND * 0.5, False) + assert segmenter.process(_ONE_SECOND * 0.5, 0.0) assert segmenter.in_command # exactly enough silence now - assert not segmenter.process(_ONE_SECOND * 0.5, False) + assert not segmenter.process(_ONE_SECOND * 0.5, 0.0) assert not segmenter.in_command @@ -134,27 +134,27 @@ def test_silence_reset() -> None: segmenter = VoiceCommandSegmenter(silence_seconds=1.0, reset_seconds=0.5) # silence - assert segmenter.process(_ONE_SECOND, False) + assert segmenter.process(_ONE_SECOND, 0.0) assert not segmenter.in_command # "speech" - assert segmenter.process(_ONE_SECOND, True) + assert segmenter.process(_ONE_SECOND, 1.0) assert segmenter.in_command # not enough silence to end - assert segmenter.process(_ONE_SECOND * 0.5, False) + assert segmenter.process(_ONE_SECOND * 0.5, 0.0) assert segmenter.in_command # speech should reset silence detection - assert segmenter.process(_ONE_SECOND * 0.5, True) + assert segmenter.process(_ONE_SECOND * 0.5, 1.0) assert segmenter.in_command # not enough silence to end - assert segmenter.process(_ONE_SECOND * 0.5, False) + assert segmenter.process(_ONE_SECOND * 0.5, 0.0) assert segmenter.in_command # exactly enough silence now - assert not segmenter.process(_ONE_SECOND * 0.5, False) + assert not segmenter.process(_ONE_SECOND * 0.5, 0.0) assert not segmenter.in_command @@ -166,23 +166,23 @@ def test_speech_reset() -> None: ) # silence - assert segmenter.process(_ONE_SECOND, False) + assert segmenter.process(_ONE_SECOND, 0.0) assert not segmenter.in_command # not enough speech to start voice command - assert segmenter.process(_ONE_SECOND * 0.5, True) + assert segmenter.process(_ONE_SECOND * 0.5, 1.0) assert not segmenter.in_command # silence should reset speech detection - assert segmenter.process(_ONE_SECOND, False) + assert segmenter.process(_ONE_SECOND, 0.0) assert not segmenter.in_command # not enough speech to start voice command - assert segmenter.process(_ONE_SECOND * 0.5, True) + assert segmenter.process(_ONE_SECOND * 0.5, 1.0) assert not segmenter.in_command # exactly enough speech now - assert segmenter.process(_ONE_SECOND * 0.5, True) + assert segmenter.process(_ONE_SECOND * 0.5, 1.0) assert segmenter.in_command @@ -193,18 +193,18 @@ def test_timeout() -> None: # not enough to time out assert not segmenter.timed_out - assert segmenter.process(_ONE_SECOND * 0.5, False) + assert segmenter.process(_ONE_SECOND * 0.5, 0.0) assert not segmenter.timed_out # enough to time out - assert not segmenter.process(_ONE_SECOND * 0.5, True) + assert not segmenter.process(_ONE_SECOND * 0.5, 1.0) assert segmenter.timed_out # flag resets with more audio - assert segmenter.process(_ONE_SECOND * 0.5, True) + assert segmenter.process(_ONE_SECOND * 0.5, 1.0) assert not segmenter.timed_out - assert not segmenter.process(_ONE_SECOND * 0.5, False) + assert not segmenter.process(_ONE_SECOND * 0.5, 0.0) assert segmenter.timed_out @@ -215,14 +215,38 @@ def test_command_seconds() -> None: command_seconds=3, speech_seconds=1, silence_seconds=1, reset_seconds=1 ) - assert segmenter.process(_ONE_SECOND, True) + assert segmenter.process(_ONE_SECOND, 1.0) # Silence counts towards total command length - assert segmenter.process(_ONE_SECOND * 0.5, False) + assert segmenter.process(_ONE_SECOND * 0.5, 0.0) # Enough to finish command now - assert segmenter.process(_ONE_SECOND, True) - assert segmenter.process(_ONE_SECOND * 0.5, False) + assert segmenter.process(_ONE_SECOND, 1.0) + assert segmenter.process(_ONE_SECOND * 0.5, 0.0) # Silence to finish - assert not segmenter.process(_ONE_SECOND * 0.5, False) + assert not segmenter.process(_ONE_SECOND * 0.5, 0.0) + + +def test_speech_thresholds() -> None: + """Test before/in command speech thresholds.""" + + segmenter = VoiceCommandSegmenter( + before_command_speech_threshold=0.2, + in_command_speech_threshold=0.5, + command_seconds=2, + speech_seconds=1, + silence_seconds=1, + ) + + # Not high enough probability to trigger command + assert segmenter.process(_ONE_SECOND, 0.1) + assert not segmenter.in_command + + # Triggers command + assert segmenter.process(_ONE_SECOND, 0.3) + assert segmenter.in_command + + # Now that same probability is considered silence. + # Finishes command. + assert not segmenter.process(_ONE_SECOND, 0.3) From 080e3d7a42c372b433c4d054c1abb62e3600fa97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 5 Nov 2024 15:17:03 +0100 Subject: [PATCH 0167/1070] Removed stale translation and improved `set_setting` translation at Home Connect (#129878) --- homeassistant/components/home_connect/strings.json | 5 +---- tests/components/home_connect/test_number.py | 4 +++- tests/components/home_connect/test_time.py | 4 +++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 9851c08d34b6e..eb57d822b155a 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -37,11 +37,8 @@ "set_light_color": { "message": "Error while trying to set color of {entity_id}: {description}" }, - "set_light_effect": { - "message": "Error while trying to set effect of {entity_id}: {description}" - }, "set_setting": { - "message": "Error while trying to set \"{value}\" to \"{key}\" setting for {entity_id}: {description}" + "message": "Error while trying to assign the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}" }, "turn_on": { "message": "Error while trying to turn on {entity_id} ({key}): {description}" diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index d822f791e40b7..f70e307cb416e 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -161,7 +161,9 @@ async def test_number_entity_error( with pytest.raises(HomeConnectError): getattr(problematic_appliance, mock_attr)() - with pytest.raises(ServiceValidationError, match=r"Error.*set.*setting.*"): + with pytest.raises( + ServiceValidationError, match=r"Error.*assign.*value.*to.*setting.*" + ): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 2beab32c5568a..25ce39786a5ba 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -135,7 +135,9 @@ async def test_time_entity_error( with pytest.raises(HomeConnectError): getattr(problematic_appliance, mock_attr)() - with pytest.raises(ServiceValidationError, match=r"Error.*set.*setting.*"): + with pytest.raises( + ServiceValidationError, match=r"Error.*assign.*value.*to.*setting.*" + ): await hass.services.async_call( TIME_DOMAIN, SERVICE_SET_VALUE, From 4e11ff05dec1c2c6179f917fc82f3653bf4403f2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Nov 2024 15:23:41 +0100 Subject: [PATCH 0168/1070] Use default package for yt-dlp (#129886) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 3e4db5d5b042e..ebfa79d71902c 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2024.11.04"], + "requirements": ["yt-dlp[default]==2024.11.04"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 6bd9afc33c040..07776b6399cc7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3054,7 +3054,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.11.04 +yt-dlp[default]==2024.11.04 # homeassistant.components.zamg zamg==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f617bab52c656..e0f127ac8bc70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2440,7 +2440,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.11.04 +yt-dlp[default]==2024.11.04 # homeassistant.components.zamg zamg==0.3.6 From b76a94bd42c95496a365bea1805cad457e8b4890 Mon Sep 17 00:00:00 2001 From: dotvav Date: Tue, 5 Nov 2024 15:34:25 +0100 Subject: [PATCH 0169/1070] Bump pypalazzetti to 0.1.10 (#129832) --- homeassistant/components/palazzetti/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index 96edf86b43bc0..a1b25f563bf42 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pypalazzetti==0.1.6"] + "requirements": ["pypalazzetti==0.1.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 07776b6399cc7..99cd9ea761141 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2146,7 +2146,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.6 +pypalazzetti==0.1.10 # homeassistant.components.elv pypca==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e0f127ac8bc70..ab28ebd9f2d70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1733,7 +1733,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.6 +pypalazzetti==0.1.10 # homeassistant.components.lcn pypck==0.7.24 From e562b6f42be357501acda349aa8ac6a33594c93e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Nov 2024 15:57:33 +0100 Subject: [PATCH 0170/1070] Map go2rtc log levels to Python log levels (#129894) --- homeassistant/components/go2rtc/server.py | 15 ++++- tests/components/go2rtc/test_server.py | 69 +++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index 9be02d9a5d692..ed3b44aadf9a8 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -39,6 +39,16 @@ ice_servers: [] """ +_LOG_LEVEL_MAP = { + "TRC": logging.DEBUG, + "DBG": logging.DEBUG, + "INF": logging.DEBUG, + "WRN": logging.WARNING, + "ERR": logging.WARNING, + "FTL": logging.ERROR, + "PNC": logging.ERROR, +} + class Go2RTCServerStartError(HomeAssistantError): """Raised when server does not start.""" @@ -132,7 +142,10 @@ async def _log_output(self, process: asyncio.subprocess.Process) -> None: async for line in process.stdout: msg = line[:-1].decode().strip() self._log_buffer.append(msg) - _LOGGER.debug(msg) + loglevel = logging.WARNING + if len(split_msg := msg.split(" ", 2)) == 3: + loglevel = _LOG_LEVEL_MAP.get(split_msg[1], loglevel) + _LOGGER.log(loglevel, msg) if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg: self._startup_complete.set() diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index cda05fc4f2bde..d810dbd88eb93 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -195,6 +195,75 @@ async def test_server_failed_to_start( ) +@pytest.mark.parametrize( + ("server_stdout", "expected_loglevel"), + [ + ( + [ + "09:00:03.466 TRC [api] register path path=/", + "09:00:03.466 DBG build vcs.time=2024-10-28T19:47:55Z version=go1.23.2", + "09:00:03.466 INF go2rtc platform=linux/amd64 revision=780f378 version=1.9.5", + "09:00:03.467 INF [api] listen addr=127.0.0.1:1984", + "09:00:03.466 WRN warning message", + '09:00:03.466 ERR [api] listen error="listen tcp 127.0.0.1:11984: bind: address already in use"', + "09:00:03.466 FTL fatal message", + "09:00:03.466 PNC panic message", + "exit with signal: interrupt", # Example of stderr write + ], + [ + logging.DEBUG, + logging.DEBUG, + logging.DEBUG, + logging.DEBUG, + logging.WARNING, + logging.WARNING, + logging.ERROR, + logging.ERROR, + logging.WARNING, + ], + ) + ], +) +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_log_level_mapping( + hass: HomeAssistant, + mock_create_subprocess: MagicMock, + server_stdout: list[str], + rest_client: AsyncMock, + server: Server, + caplog: pytest.LogCaptureFixture, + expected_loglevel: list[int], +) -> None: + """Log level mapping.""" + evt = asyncio.Event() + + async def wait_event() -> None: + await evt.wait() + + mock_create_subprocess.return_value.wait.side_effect = wait_event + + await server.start() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + + # Verify go2rtc binary stdout was logged with default level + for i, entry in enumerate(server_stdout): + assert ( + "homeassistant.components.go2rtc.server", + expected_loglevel[i], + entry, + ) in caplog.record_tuples + + evt.set() + await asyncio.sleep(0.1) + await hass.async_block_till_done() + + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + + await server.stop() + + @patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) async def test_server_restart_process_exit( hass: HomeAssistant, From 5f36062ef339bc77a2fdb8997f4d2ae0bb198228 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Nov 2024 16:32:05 +0100 Subject: [PATCH 0171/1070] Remove timers from LG ThinQ (#129898) --- homeassistant/components/lg_thinq/sensor.py | 87 +----------------- .../lg_thinq/snapshots/test_sensor.ambr | 92 ------------------- 2 files changed, 1 insertion(+), 178 deletions(-) diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 30d38685b3a55..99b4df8176e29 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -255,73 +255,9 @@ translation_key=ThinQProperty.WATER_TYPE, ), } -TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { - TimerProperty.RELATIVE_TO_START: SensorEntityDescription( - key=TimerProperty.RELATIVE_TO_START, - translation_key=TimerProperty.RELATIVE_TO_START, - ), - TimerProperty.RELATIVE_TO_START_WM: SensorEntityDescription( - key=TimerProperty.RELATIVE_TO_START, - translation_key=TimerProperty.RELATIVE_TO_START_WM, - ), - TimerProperty.RELATIVE_TO_STOP: SensorEntityDescription( - key=TimerProperty.RELATIVE_TO_STOP, - translation_key=TimerProperty.RELATIVE_TO_STOP, - ), - TimerProperty.RELATIVE_TO_STOP_WM: SensorEntityDescription( - key=TimerProperty.RELATIVE_TO_STOP, - translation_key=TimerProperty.RELATIVE_TO_STOP_WM, - ), - TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP: SensorEntityDescription( - key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP, - translation_key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP, - ), - TimerProperty.ABSOLUTE_TO_START: SensorEntityDescription( - key=TimerProperty.ABSOLUTE_TO_START, - translation_key=TimerProperty.ABSOLUTE_TO_START, - ), - TimerProperty.ABSOLUTE_TO_STOP: SensorEntityDescription( - key=TimerProperty.ABSOLUTE_TO_STOP, - translation_key=TimerProperty.ABSOLUTE_TO_STOP, - ), - TimerProperty.REMAIN: SensorEntityDescription( - key=TimerProperty.REMAIN, - translation_key=TimerProperty.REMAIN, - ), - TimerProperty.TARGET: SensorEntityDescription( - key=TimerProperty.TARGET, - translation_key=TimerProperty.TARGET, - ), - TimerProperty.RUNNING: SensorEntityDescription( - key=TimerProperty.RUNNING, - translation_key=TimerProperty.RUNNING, - ), - TimerProperty.TOTAL: SensorEntityDescription( - key=TimerProperty.TOTAL, - translation_key=TimerProperty.TOTAL, - ), - TimerProperty.LIGHT_START: SensorEntityDescription( - key=TimerProperty.LIGHT_START, - translation_key=TimerProperty.LIGHT_START, - ), - ThinQProperty.ELAPSED_DAY_STATE: SensorEntityDescription( - key=ThinQProperty.ELAPSED_DAY_STATE, - native_unit_of_measurement=UnitOfTime.DAYS, - translation_key=ThinQProperty.ELAPSED_DAY_STATE, - ), - ThinQProperty.ELAPSED_DAY_TOTAL: SensorEntityDescription( - key=ThinQProperty.ELAPSED_DAY_TOTAL, - native_unit_of_measurement=UnitOfTime.DAYS, - translation_key=ThinQProperty.ELAPSED_DAY_TOTAL, - ), -} WASHER_SENSORS: tuple[SensorEntityDescription, ...] = ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP_WM], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], - TIMER_SENSOR_DESC[TimerProperty.TOTAL], ) DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = { DeviceType.AIR_CONDITIONER: ( @@ -332,9 +268,6 @@ AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_LIFETIME], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP], - TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], ), DeviceType.AIR_PURIFIER_FAN: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -345,7 +278,6 @@ AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], - TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], ), DeviceType.AIR_PURIFIER: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -361,7 +293,6 @@ DeviceType.COOKTOP: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], POWER_SENSOR_DESC[ThinQProperty.POWER_LEVEL], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], ), DeviceType.DEHUMIDIFIER: ( JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], @@ -372,9 +303,6 @@ PREFERENCE_SENSOR_DESC[ThinQProperty.RINSE_LEVEL], PREFERENCE_SENSOR_DESC[ThinQProperty.SOFTENING_LEVEL], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], - TIMER_SENSOR_DESC[TimerProperty.TOTAL], ), DeviceType.DRYER: WASHER_SENSORS, DeviceType.HOME_BREW: ( @@ -385,10 +313,7 @@ RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_INFO], RECIPE_SENSOR_DESC[ThinQProperty.BEER_REMAIN], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], - TIMER_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_STATE], - TIMER_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_TOTAL], ), - DeviceType.HOOD: (TIMER_SENSOR_DESC[TimerProperty.REMAIN],), DeviceType.HUMIDIFIER: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM2], @@ -397,9 +322,6 @@ AIR_QUALITY_SENSOR_DESC[ThinQProperty.TEMPERATURE], AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], - TIMER_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], - TIMER_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], - TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], ), DeviceType.KIMCHI_REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], @@ -408,15 +330,10 @@ translation_key=ThinQProperty.TARGET_TEMPERATURE, ), ), - DeviceType.MICROWAVE_OVEN: ( - RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], - ), + DeviceType.MICROWAVE_OVEN: (RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],), DeviceType.OVEN: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], TEMPERATURE_SENSOR_DESC[ThinQProperty.TARGET_TEMPERATURE], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], - TIMER_SENSOR_DESC[TimerProperty.TARGET], ), DeviceType.PLANT_CULTIVATOR: ( LIGHT_SENSOR_DESC[ThinQProperty.BRIGHTNESS], @@ -427,7 +344,6 @@ TEMPERATURE_SENSOR_DESC[ThinQProperty.DAY_TARGET_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQProperty.NIGHT_TARGET_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQProperty.TEMPERATURE_STATE], - TIMER_SENSOR_DESC[TimerProperty.LIGHT_START], ), DeviceType.REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], @@ -436,7 +352,6 @@ DeviceType.ROBOT_CLEANER: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], - TIMER_SENSOR_DESC[TimerProperty.RUNNING], ), DeviceType.STICK_CLEANER: ( BATTERY_SENSOR_DESC[ThinQProperty.BATTERY_PERCENT], diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index aa50ae5b03e77..387df916eba02 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -203,95 +203,3 @@ 'state': '24', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', - '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': 'Schedule turn-off', - 'platform': 'lg_thinq', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_stop', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test air conditioner Schedule turn-off', - }), - 'context': , - 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', - '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': 'Schedule turn-on', - 'platform': 'lg_thinq', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_start', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test air conditioner Schedule turn-on', - }), - 'context': , - 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- From 9253fa4471a5dfa1591a7741cf59d4c57cbd9a06 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 5 Nov 2024 17:01:38 +0100 Subject: [PATCH 0172/1070] Add binary sensor platform to Habitica integration (#129613) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/habitica/__init__.py | 1 + .../components/habitica/binary_sensor.py | 85 +++++++++++++++++++ homeassistant/components/habitica/icons.json | 8 ++ .../components/habitica/strings.json | 5 ++ .../fixtures/quest_invitation_off.json | 64 ++++++++++++++ tests/components/habitica/fixtures/user.json | 6 ++ .../snapshots/test_binary_sensor.ambr | 48 +++++++++++ .../components/habitica/test_binary_sensor.py | 80 +++++++++++++++++ 8 files changed, 297 insertions(+) create mode 100644 homeassistant/components/habitica/binary_sensor.py create mode 100644 tests/components/habitica/fixtures/quest_invitation_off.json create mode 100644 tests/components/habitica/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/habitica/test_binary_sensor.py diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 502f52609ddbf..5843e14d63e16 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -30,6 +30,7 @@ PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CALENDAR, Platform.SENSOR, diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py new file mode 100644 index 0000000000000..bc79370ea63b2 --- /dev/null +++ b/homeassistant/components/habitica/binary_sensor.py @@ -0,0 +1,85 @@ +"""Binary sensor platform for Habitica integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ASSETS_URL +from .entity import HabiticaBase +from .types import HabiticaConfigEntry + + +@dataclass(kw_only=True, frozen=True) +class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription): + """Habitica Binary Sensor Description.""" + + value_fn: Callable[[dict[str, Any]], bool | None] + entity_picture: Callable[[dict[str, Any]], str | None] + + +class HabiticaBinarySensor(StrEnum): + """Habitica Entities.""" + + PENDING_QUEST = "pending_quest" + + +def get_scroll_image_for_pending_quest_invitation(user: dict[str, Any]) -> str | None: + """Entity picture for pending quest invitation.""" + if user["party"]["quest"].get("key") and user["party"]["quest"]["RSVPNeeded"]: + return f"inventory_quest_scroll_{user["party"]["quest"]["key"]}.png" + return None + + +BINARY_SENSOR_DESCRIPTIONS: tuple[HabiticaBinarySensorEntityDescription, ...] = ( + HabiticaBinarySensorEntityDescription( + key=HabiticaBinarySensor.PENDING_QUEST, + translation_key=HabiticaBinarySensor.PENDING_QUEST, + value_fn=lambda user: user["party"]["quest"]["RSVPNeeded"], + entity_picture=get_scroll_image_for_pending_quest_invitation, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HabiticaConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the habitica binary sensors.""" + + coordinator = config_entry.runtime_data + + async_add_entities( + HabiticaBinarySensorEntity(coordinator, description) + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class HabiticaBinarySensorEntity(HabiticaBase, BinarySensorEntity): + """Representation of a Habitica binary sensor.""" + + entity_description: HabiticaBinarySensorEntityDescription + + @property + def is_on(self) -> bool | None: + """If the binary sensor is on.""" + return self.entity_description.value_fn(self.coordinator.data.user) + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + if entity_picture := self.entity_description.entity_picture( + self.coordinator.data.user + ): + return f"{ASSETS_URL}{entity_picture}" + return None diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 617f08a4e5829..0698b85afe17e 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -135,6 +135,14 @@ "on": "mdi:sleep" } } + }, + "binary_sensor": { + "pending_quest": { + "default": "mdi:script-outline", + "state": { + "on": "mdi:script-text-outline" + } + } } }, "services": { diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 390dc3ba9ae18..45824c484e94f 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -38,6 +38,11 @@ } }, "entity": { + "binary_sensor": { + "pending_quest": { + "name": "Pending quest invitation" + } + }, "button": { "run_cron": { "name": "Start my day" diff --git a/tests/components/habitica/fixtures/quest_invitation_off.json b/tests/components/habitica/fixtures/quest_invitation_off.json new file mode 100644 index 0000000000000..f862a85c7c45d --- /dev/null +++ b/tests/components/habitica/fixtures/quest_invitation_off.json @@ -0,0 +1,64 @@ +{ + "data": { + "api_user": "test-api-user", + "profile": { "name": "test-user" }, + "stats": { + "buffs": { + "str": 0, + "int": 0, + "per": 0, + "con": 0, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "hp": 0, + "mp": 50.89999999999998, + "exp": 737, + "gp": 137.62587214609795, + "lvl": 38, + "class": "wizard", + "maxHealth": 50, + "maxMP": 166, + "toNextLevel": 880, + "points": 5 + }, + "preferences": { + "sleep": false, + "automaticAllocation": true, + "disableClasses": false + }, + "flags": { + "classSelected": true + }, + "tasksOrder": { + "rewards": ["5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"], + "todos": [ + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "86ea2475-d1b5-4020-bdcc-c188c7996afa" + ], + "dailys": [ + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "e97659e0-2c42-4599-a7bb-00282adc410d", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1" + ], + "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] + }, + "party": { + "quest": { + "RSVPNeeded": false, + "key": null + } + }, + "needsCron": true, + "lastCron": "2024-09-21T22:01:55.586Z" + } +} diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index a10ce354f442d..818f4ed4eda86 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -52,6 +52,12 @@ ], "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] }, + "party": { + "quest": { + "RSVPNeeded": true, + "key": "dustbunnies" + } + }, "needsCron": true, "lastCron": "2024-09-21T22:01:55.586Z" } diff --git a/tests/components/habitica/snapshots/test_binary_sensor.ambr b/tests/components/habitica/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..c18f8f551c921 --- /dev/null +++ b/tests/components/habitica/snapshots/test_binary_sensor.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.test_user_pending_quest_invitation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_user_pending_quest_invitation', + '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': 'Pending quest invitation', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_pending_quest', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_user_pending_quest_invitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/inventory_quest_scroll_dustbunnies.png', + 'friendly_name': 'test-user Pending quest invitation', + }), + 'context': , + 'entity_id': 'binary_sensor.test_user_pending_quest_invitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/habitica/test_binary_sensor.py b/tests/components/habitica/test_binary_sensor.py new file mode 100644 index 0000000000000..5b19cd008bf5b --- /dev/null +++ b/tests/components/habitica/test_binary_sensor.py @@ -0,0 +1,80 @@ +"""Tests for the Habitica binary sensor platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.habitica.const import ASSETS_URL, DEFAULT_URL, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture(autouse=True) +def binary_sensor_only() -> Generator[None]: + """Enable only the binarty sensor platform.""" + with patch( + "homeassistant.components.habitica.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_habitica") +async def test_binary_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the Habitica binary sensor platform.""" + + 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 + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("fixture", "entity_state", "entity_picture"), + [ + ("user", STATE_ON, f"{ASSETS_URL}inventory_quest_scroll_dustbunnies.png"), + ("quest_invitation_off", STATE_OFF, None), + ], +) +async def test_pending_quest_states( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + fixture: str, + entity_state: str, + entity_picture: str | None, +) -> None: + """Test states of pending quest sensor.""" + + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/user", + json=load_json_object_fixture(f"{fixture}.json", DOMAIN), + ) + aioclient_mock.get(f"{DEFAULT_URL}/api/v3/tasks/user", json={"data": []}) + + 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 + + assert ( + state := hass.states.get("binary_sensor.test_user_pending_quest_invitation") + ) + assert state.state == entity_state + assert state.attributes.get("entity_picture") == entity_picture From ed56e5d631d193083b39d8608703d80290311f6d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Nov 2024 11:02:44 -0500 Subject: [PATCH 0173/1070] Change Ollama default to llama3.2 (#129901) --- homeassistant/components/ollama/const.py | 64 +++++++++++++++++------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index 6152b223d6d2b..69c0a3d62965f 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -24,8 +24,12 @@ MODEL_NAMES = [ # https://ollama.com/library "alfred", "all-minilm", + "aya-expanse", "aya", "bakllava", + "bespoke-minicheck", + "bge-large", + "bge-m3", "codebooga", "codegeex4", "codegemma", @@ -33,18 +37,19 @@ "codeqwen", "codestral", "codeup", - "command-r", "command-r-plus", + "command-r", "dbrx", - "deepseek-coder", "deepseek-coder-v2", + "deepseek-coder", "deepseek-llm", + "deepseek-v2.5", "deepseek-v2", - "dolphincoder", "dolphin-llama3", "dolphin-mistral", "dolphin-mixtral", "dolphin-phi", + "dolphincoder", "duckdb-nsql", "everythinglm", "falcon", @@ -55,74 +60,97 @@ "glm4", "goliath", "granite-code", + "granite3-dense", + "granite3-guardian" "granite3-moe", + "hermes3", "internlm2", - "llama2", + "llama-guard3", + "llama-pro", "llama2-chinese", "llama2-uncensored", - "llama3", + "llama2", "llama3-chatqa", "llama3-gradient", "llama3-groq-tool-use", - "llama-pro", - "llava", + "llama3.1", + "llama3.2", + "llama3", "llava-llama3", "llava-phi3", + "llava", "magicoder", "mathstral", "meditron", "medllama2", "megadolphin", - "mistral", - "mistrallite", + "minicpm-v", + "mistral-large", "mistral-nemo", "mistral-openorca", + "mistral-small", + "mistral", + "mistrallite", "mixtral", "moondream", "mxbai-embed-large", + "nemotron-mini", + "nemotron", "neural-chat", "nexusraven", "nomic-embed-text", "notus", "notux", "nous-hermes", - "nous-hermes2", "nous-hermes2-mixtral", + "nous-hermes2", "nuextract", + "open-orca-platypus2", "openchat", "openhermes", - "open-orca-platypus2", - "orca2", "orca-mini", + "orca2", + "paraphrase-multilingual", "phi", + "phi3.5", "phi3", "phind-codellama", "qwen", + "qwen2-math", + "qwen2.5-coder", + "qwen2.5", "qwen2", + "reader-lm", + "reflection", "samantha-mistral", + "shieldgemma", + "smollm", + "smollm2", "snowflake-arctic-embed", + "solar-pro", "solar", "sqlcoder", "stable-beluga", "stable-code", - "stablelm2", "stablelm-zephyr", + "stablelm2", "starcoder", "starcoder2", "starling-lm", "tinydolphin", "tinyllama", "vicuna", + "wizard-math", + "wizard-vicuna-uncensored", + "wizard-vicuna", "wizardcoder", + "wizardlm-uncensored", "wizardlm", "wizardlm2", - "wizardlm-uncensored", - "wizard-math", - "wizard-vicuna", - "wizard-vicuna-uncensored", "xwinlm", "yarn-llama2", "yarn-mistral", + "yi-coder", "yi", "zephyr", ] -DEFAULT_MODEL = "llama3.1:latest" +DEFAULT_MODEL = "llama3.2:latest" From 05e76105ad0dd28653701c7900fb70d3928d9b7a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Nov 2024 17:12:05 +0100 Subject: [PATCH 0174/1070] Improve improv BLE error handling (#129902) --- .../components/improv_ble/config_flow.py | 18 ++++++++++++++---- tests/components/improv_ble/__init__.py | 19 +++++++++++++++++++ .../components/improv_ble/test_config_flow.py | 18 ++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index f38f4830ace97..05dd1de449a7f 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -120,12 +120,22 @@ def _abort_if_provisioned(self) -> None: assert self._discovery_info is not None service_data = self._discovery_info.service_data - improv_service_data = ImprovServiceData.from_bytes( - service_data[SERVICE_DATA_UUID] - ) + try: + improv_service_data = ImprovServiceData.from_bytes( + service_data[SERVICE_DATA_UUID] + ) + except improv_ble_errors.InvalidCommand as err: + _LOGGER.warning( + "Aborting improv flow, device %s sent invalid improv data: '%s'", + self._discovery_info.address, + service_data[SERVICE_DATA_UUID].hex(), + ) + raise AbortFlow("invalid_improv_data") from err + if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED): _LOGGER.debug( - "Aborting improv flow, device is already provisioned: %s", + "Aborting improv flow, device %s is already provisioned: %s", + self._discovery_info.address, improv_service_data.state, ) raise AbortFlow("already_provisioned") diff --git a/tests/components/improv_ble/__init__.py b/tests/components/improv_ble/__init__.py index 41ea98cda7bf5..521d088144316 100644 --- a/tests/components/improv_ble/__init__.py +++ b/tests/components/improv_ble/__init__.py @@ -25,6 +25,25 @@ ) +BAD_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="00123456", + address="AA:BB:CC:DD:EE:F0", + rssi=-60, + manufacturer_data={}, + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x00\x00\x00\x00\x00\x00"}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="00123456"), + advertisement=generate_advertisement_data( + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x00\x00\x00\x00\x00\x00"}, + ), + time=0, + connectable=True, + tx_power=-127, +) + + PROVISIONED_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="00123456", address="AA:BB:CC:DD:EE:F0", diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 640a931bee546..2df4be2ba7d2d 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -15,6 +15,7 @@ from homeassistant.data_entry_flow import FlowResult, FlowResultType from . import ( + BAD_IMPROV_BLE_DISCOVERY_INFO, IMPROV_BLE_DISCOVERY_INFO, NOT_IMPROV_BLE_DISCOVERY_INFO, PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, @@ -649,3 +650,20 @@ async def test_provision_retry(hass: HomeAssistant, exc, error) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "provision" assert result["errors"] == {"base": error} + + +async def test_provision_fails_invalid_data( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test bluetooth flow with error due to invalid data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=BAD_IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_improv_data" + assert ( + "Aborting improv flow, device AA:BB:CC:DD:EE:F0 sent invalid improv data: '000000000000'" + in caplog.text + ) From 611a952232c650def4cf979805c8f685859774e2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 5 Nov 2024 18:39:10 +0100 Subject: [PATCH 0175/1070] Prevent update entity becoming unavailable on device disconnect in IronOS (#129840) * Don't render update entity unavailable when Pinecil device disconnects * fixes --- homeassistant/components/iron_os/update.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py index bae9ccd4c6c7f..786ba86f73025 100644 --- a/homeassistant/components/iron_os/update.py +++ b/homeassistant/components/iron_os/update.py @@ -92,4 +92,7 @@ async def async_added_to_hass(self) -> None: @property def available(self) -> bool: """Return if entity is available.""" - return super().available and self.firmware_update.last_update_success + return ( + self.installed_version is not None + and self.firmware_update.last_update_success + ) From c54ed53a818728807786f52c8eb789da445ed8db Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 19:51:20 +0100 Subject: [PATCH 0176/1070] Remove usage of options property in OptionsFlow (part 1) (#129895) * Remove usage of options property in OptionsFlow * Improve --- .../components/analytics_insights/config_flow.py | 2 +- homeassistant/components/androidtv/config_flow.py | 2 +- homeassistant/components/elevenlabs/config_flow.py | 2 +- homeassistant/components/feedreader/config_flow.py | 4 +++- homeassistant/components/fritz/config_flow.py | 7 +++---- homeassistant/components/lamarzocco/config_flow.py | 2 +- homeassistant/components/opensky/config_flow.py | 8 ++------ .../components/pvpc_hourly_pricing/config_flow.py | 14 ++++++-------- homeassistant/components/roku/config_flow.py | 2 +- homeassistant/components/roomba/config_flow.py | 5 +++-- homeassistant/components/sql/config_flow.py | 4 ++-- .../components/trafikverket_train/config_flow.py | 2 +- homeassistant/components/upnp/config_flow.py | 2 +- .../components/vodafone_station/config_flow.py | 2 +- homeassistant/components/wled/config_flow.py | 2 +- homeassistant/components/workday/config_flow.py | 13 ++++++------- homeassistant/components/youtube/config_flow.py | 2 +- 17 files changed, 35 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index 0212f208436ff..c36755f5403ca 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -212,6 +212,6 @@ async def async_step_init( ), }, ), - self.options, + self.config_entry.options, ), ) diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index a41a113268e13..afaba5175dac9 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -235,7 +235,7 @@ def _async_init_form(self) -> ConfigFlowResult: SelectOptionDict(value=k, label=v) for k, v in apps_list.items() ] rules = [RULES_NEW_ID, *self._state_det_rules] - options = self.options + options = self.config_entry.options data_schema = vol.Schema( { diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index 6419b1c973ca4..227150a0f4e63 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -168,7 +168,7 @@ def elevenlabs_config_option_schema(self) -> vol.Schema: vol.Required(CONF_CONFIGURE_VOICE, default=False): bool, } ), - self.options, + self.config_entry.options, ) async def async_step_voice_settings( diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index 1a19f612e7ef9..b902d48a1c8eb 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -163,7 +163,9 @@ async def async_step_init( { vol.Optional( CONF_MAX_ENTRIES, - default=self.options.get(CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES), + default=self.config_entry.options.get( + CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES + ), ): cv.positive_int, } ) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 38e86519a0176..ec9ffdd755481 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -405,19 +405,18 @@ async def async_step_init( if user_input is not None: return self.async_create_entry(title="", data=user_input) + options = self.config_entry.options data_schema = vol.Schema( { vol.Optional( CONF_CONSIDER_HOME, - default=self.options.get( + default=options.get( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() ), ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)), vol.Optional( CONF_OLD_DISCOVERY, - default=self.options.get( - CONF_OLD_DISCOVERY, DEFAULT_CONF_OLD_DISCOVERY - ), + default=options.get(CONF_OLD_DISCOVERY, DEFAULT_CONF_OLD_DISCOVERY), ): bool, } ) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index bcb55a19275eb..4fadd3a9a32a1 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -359,7 +359,7 @@ async def async_step_init( { vol.Optional( CONF_USE_BLUETOOTH, - default=self.options.get(CONF_USE_BLUETOOTH, True), + default=self.config_entry.options.get(CONF_USE_BLUETOOTH, True), ): cv.boolean, } ) diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index f0f599628cb45..867a4781265c4 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -18,7 +18,6 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, - CONF_NAME, CONF_PASSWORD, CONF_RADIUS, CONF_USERNAME, @@ -112,10 +111,7 @@ async def async_step_init( except OpenSkyUnauthenticatedError: errors["base"] = "invalid_auth" if not errors: - return self.async_create_entry( - title=self.options.get(CONF_NAME, "OpenSky"), - data=user_input, - ) + return self.async_create_entry(data=user_input) return self.async_show_form( step_id="init", @@ -130,6 +126,6 @@ async def async_step_init( vol.Optional(CONF_CONTRIBUTING_USER, default=False): bool, } ), - user_input or self.options, + user_input or self.config_entry.options, ), ) diff --git a/homeassistant/components/pvpc_hourly_pricing/config_flow.py b/homeassistant/components/pvpc_hourly_pricing/config_flow.py index af80c40b75b8b..3c6b510004a66 100644 --- a/homeassistant/components/pvpc_hourly_pricing/config_flow.py +++ b/homeassistant/components/pvpc_hourly_pricing/config_flow.py @@ -199,7 +199,7 @@ async def async_step_api_token( ) # Fill options with entry data - api_token = self.options.get( + api_token = self.config_entry.options.get( CONF_API_TOKEN, self.config_entry.data.get(CONF_API_TOKEN) ) return self.async_show_form( @@ -229,13 +229,11 @@ async def async_step_init( ) # Fill options with entry data - power = self.options.get(ATTR_POWER, self.config_entry.data[ATTR_POWER]) - power_valley = self.options.get( - ATTR_POWER_P3, self.config_entry.data[ATTR_POWER_P3] - ) - api_token = self.options.get( - CONF_API_TOKEN, self.config_entry.data.get(CONF_API_TOKEN) - ) + options = self.config_entry.options + data = self.config_entry.data + power = options.get(ATTR_POWER, data[ATTR_POWER]) + power_valley = options.get(ATTR_POWER_P3, data[ATTR_POWER_P3]) + api_token = options.get(CONF_API_TOKEN, data.get(CONF_API_TOKEN)) use_api_token = api_token is not None schema = vol.Schema( { diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index a99c475f51508..18e3b3ed68a37 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -186,7 +186,7 @@ async def async_step_init( { vol.Optional( CONF_PLAY_MEDIA_APP_ID, - default=self.options.get( + default=self.config_entry.options.get( CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID ), ): str, diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index a53f0ac857f20..e48d2d9113956 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -310,17 +310,18 @@ async def async_step_init( if user_input is not None: return self.async_create_entry(title="", data=user_input) + options = self.config_entry.options return self.async_show_form( step_id="init", data_schema=vol.Schema( { vol.Optional( CONF_CONTINUOUS, - default=self.options.get(CONF_CONTINUOUS, DEFAULT_CONTINUOUS), + default=options.get(CONF_CONTINUOUS, DEFAULT_CONTINUOUS), ): bool, vol.Optional( CONF_DELAY, - default=self.options.get(CONF_DELAY, DEFAULT_DELAY), + default=options.get(CONF_DELAY, DEFAULT_DELAY), ): int, } ), diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 9f0614fae89c0..4fe04f2401c83 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -223,7 +223,7 @@ async def async_step_init( db_url = user_input.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] - name = self.options.get(CONF_NAME, self.config_entry.title) + name = self.config_entry.options.get(CONF_NAME, self.config_entry.title) try: query = validate_sql_select(query) @@ -275,7 +275,7 @@ async def async_step_init( return self.async_show_form( step_id="init", data_schema=self.add_suggested_values_to_schema( - OPTIONS_SCHEMA, user_input or self.options + OPTIONS_SCHEMA, user_input or self.config_entry.options ), errors=errors, description_placeholders=description_placeholders, diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index b3b8180a08dc3..f498a7b0d0e46 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -247,7 +247,7 @@ async def async_step_init( step_id="init", data_schema=self.add_suggested_values_to_schema( vol.Schema(OPTION_SCHEMA), - user_input or self.options, + user_input or self.config_entry.options, ), errors=errors, ) diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 5f1fdbee88ff2..41e481fa58c0e 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -314,7 +314,7 @@ async def async_step_init( { vol.Optional( CONFIG_ENTRY_FORCE_POLL, - default=self.options.get( + default=self.config_entry.options.get( CONFIG_ENTRY_FORCE_POLL, DEFAULT_CONFIG_ENTRY_FORCE_POLL ), ): bool, diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index 288ebeb9a074a..7a80244f8d624 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -159,7 +159,7 @@ async def async_step_init( { vol.Optional( CONF_CONSIDER_HOME, - default=self.options.get( + default=self.config_entry.options.get( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() ), ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 67f2f60d13ecc..812a0500d1a38 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -135,7 +135,7 @@ async def async_step_init( { vol.Optional( CONF_KEEP_MAIN_LIGHT, - default=self.options.get( + default=self.config_entry.options.get( CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT ), ): bool, diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 759cc13aecffc..4d93fccb1a77e 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -320,7 +320,7 @@ async def async_step_init( errors: dict[str, str] = {} if user_input is not None: - combined_input: dict[str, Any] = {**self.options, **user_input} + combined_input: dict[str, Any] = {**self.config_entry.options, **user_input} if CONF_PROVINCE not in user_input: # Province not present, delete old value (if present) too combined_input.pop(CONF_PROVINCE, None) @@ -357,23 +357,22 @@ async def async_step_init( else: return self.async_create_entry(data=combined_input) + options = self.config_entry.options schema: vol.Schema = await self.hass.async_add_executor_job( add_province_and_language_to_schema, DATA_SCHEMA_OPT, - self.options.get(CONF_COUNTRY), + options.get(CONF_COUNTRY), ) - new_schema = self.add_suggested_values_to_schema( - schema, user_input or self.options - ) + new_schema = self.add_suggested_values_to_schema(schema, user_input or options) LOGGER.debug("Errors have occurred in options %s", errors) return self.async_show_form( step_id="init", data_schema=new_schema, errors=errors, description_placeholders={ - "name": self.options[CONF_NAME], - "country": self.options.get(CONF_COUNTRY), + "name": options[CONF_NAME], + "country": options.get(CONF_COUNTRY), }, ) diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index d03beffdb4953..48336422585b7 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -194,6 +194,6 @@ async def async_step_init( ), } ), - self.options, + self.config_entry.options, ), ) From 1e42a38473c0ff2927aa8fe8e80627e4ecf8c47a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 19:53:05 +0100 Subject: [PATCH 0177/1070] Remove usage of options property in OptionsFlow (part 2) (#129897) --- homeassistant/components/axis/config_flow.py | 3 +-- homeassistant/components/deconz/config_flow.py | 3 +-- homeassistant/components/iss/config_flow.py | 3 +-- homeassistant/components/kitchen_sink/config_flow.py | 7 +------ 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 5026f7e7ab6ea..592b1e2d41f7e 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -284,8 +284,7 @@ async def async_step_configure_stream( ) -> ConfigFlowResult: """Manage the Axis device stream options.""" if user_input is not None: - self.options.update(user_input) - return self.async_create_entry(title="", data=self.options) + return self.async_create_entry(data=self.config_entry.options | user_input) schema = {} diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 6332c56a08a8d..ed54701f65691 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -312,8 +312,7 @@ async def async_step_deconz_devices( ) -> ConfigFlowResult: """Manage the deconz devices options.""" if user_input is not None: - self.options.update(user_input) - return self.async_create_entry(title="", data=self.options) + return self.async_create_entry(data=self.config_entry.options | user_input) schema_options = {} for option, default in ( diff --git a/homeassistant/components/iss/config_flow.py b/homeassistant/components/iss/config_flow.py index 567618a768067..eaf01a6d0946c 100644 --- a/homeassistant/components/iss/config_flow.py +++ b/homeassistant/components/iss/config_flow.py @@ -47,8 +47,7 @@ class OptionsFlowHandler(OptionsFlow): async def async_step_init(self, user_input=None) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: - self.options.update(user_input) - return self.async_create_entry(title="", data=self.options) + return self.async_create_entry(data=self.config_entry.options | user_input) return self.async_show_form( step_id="init", diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 74e738a0e04c5..019d1dddcad53 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -68,8 +68,7 @@ async def async_step_options_1( ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: - self.options.update(user_input) - return await self._update_options() + return self.async_create_entry(data=self.config_entry.options | user_input) return self.async_show_form( step_id="options_1", @@ -95,7 +94,3 @@ async def async_step_options_1( } ), ) - - async def _update_options(self) -> ConfigFlowResult: - """Update config entry options.""" - return self.async_create_entry(title="", data=self.options) From 83a1b06b560703ec723254afe57878fc795bad29 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 5 Nov 2024 18:59:43 +0000 Subject: [PATCH 0178/1070] Set friendly name of utility meter select entity when configured through YAML (#128267) * set select friendly name in YAML * backward compatibility added * clean * cleaner backward compatibility approach * don't introduce default unique_id * split test according to review --- .../components/utility_meter/select.py | 24 ++++--- tests/components/utility_meter/test_select.py | 62 +++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index d5b1206d04617..5815ce7ec95be 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -6,7 +6,7 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_UNIQUE_ID +from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo @@ -36,9 +36,9 @@ async def async_setup_entry( ) tariff_select = TariffSelect( - name, - tariffs, - unique_id, + name=name, + tariffs=tariffs, + unique_id=unique_id, device_info=device_info, ) async_add_entities([tariff_select]) @@ -62,13 +62,15 @@ async def async_setup_platform( conf_meter_unique_id: str | None = hass.data[DATA_UTILITY][meter].get( CONF_UNIQUE_ID ) + conf_meter_name = hass.data[DATA_UTILITY][meter].get(CONF_NAME, meter) async_add_entities( [ TariffSelect( - meter, - discovery_info[CONF_TARIFFS], - conf_meter_unique_id, + name=conf_meter_name, + tariffs=discovery_info[CONF_TARIFFS], + yaml_slug=meter, + unique_id=conf_meter_unique_id, ) ] ) @@ -82,12 +84,16 @@ class TariffSelect(SelectEntity, RestoreEntity): def __init__( self, name, - tariffs, - unique_id, + tariffs: list[str], + *, + yaml_slug: str | None = None, + unique_id: str | None = None, device_info: DeviceInfo | None = None, ) -> None: """Initialize a tariff selector.""" self._attr_name = name + if yaml_slug: # Backwards compatibility with YAML configuration entries + self.entity_id = f"select.{yaml_slug}" self._attr_unique_id = unique_id self._attr_device_info = device_info self._current_tariff: str | None = None diff --git a/tests/components/utility_meter/test_select.py b/tests/components/utility_meter/test_select.py index 61f6cbe75b948..1f54f3b500a16 100644 --- a/tests/components/utility_meter/test_select.py +++ b/tests/components/utility_meter/test_select.py @@ -3,10 +3,72 @@ from homeassistant.components.utility_meter.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +async def test_select_entity_name_config_entry( + hass: HomeAssistant, +) -> None: + """Test for Utility Meter select platform.""" + + config_entry_config = { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.energy", + "tariffs": ["peak", "offpeak"], + } + + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + utility_meter_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + ) + + utility_meter_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + + await hass.async_block_till_done() + + state = hass.states.get("select.energy_bill") + assert state is not None + assert state.attributes.get("friendly_name") == "Energy bill" + + +async def test_select_entity_name_yaml( + hass: HomeAssistant, +) -> None: + """Test for Utility Meter select platform.""" + + yaml_config = { + "utility_meter": { + "energy_bill": { + "name": "Energy bill", + "source": "sensor.energy", + "tariffs": ["peak", "offpeak"], + "unique_id": "1234abcd", + } + } + } + + assert await async_setup_component(hass, DOMAIN, yaml_config) + + await hass.async_block_till_done() + + state = hass.states.get("select.energy_bill") + assert state is not None + assert state.attributes.get("friendly_name") == "Energy bill" + + async def test_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 94db78a0be3bb1e2a3301d54d82ede66af4de03f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 6 Nov 2024 05:04:55 +1000 Subject: [PATCH 0179/1070] Add signing support to Tesla Fleet (#128407) * Add command signing * wip * Update tests * requirements * Add test --- .../components/tesla_fleet/__init__.py | 17 ++++++++-- .../components/tesla_fleet/button.py | 2 -- .../components/tesla_fleet/climate.py | 4 +-- homeassistant/components/tesla_fleet/cover.py | 10 +++--- .../components/tesla_fleet/entity.py | 8 ----- .../components/tesla_fleet/media_player.py | 2 +- .../components/tesla_fleet/strings.json | 3 -- tests/components/tesla_fleet/conftest.py | 10 ++++++ .../snapshots/test_media_player.ambr | 4 +-- tests/components/tesla_fleet/test_button.py | 32 ++++++++++++++++++- tests/components/tesla_fleet/test_init.py | 20 ++++++++++++ tests/components/tesla_fleet/test_switch.py | 27 ---------------- 12 files changed, 85 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 4cd8c5c7142ee..70db4a183aae6 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -5,7 +5,12 @@ from aiohttp.client_exceptions import ClientResponseError import jwt -from tesla_fleet_api import EnergySpecific, TeslaFleetApi, VehicleSpecific +from tesla_fleet_api import ( + EnergySpecific, + TeslaFleetApi, + VehicleSigned, + VehicleSpecific, +) from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( InvalidRegion, @@ -126,7 +131,13 @@ async def _refresh_token() -> str: # Remove the protobuff 'cached_data' that we do not use to save memory product.pop("cached_data", None) vin = product["vin"] - api = VehicleSpecific(tesla.vehicle, vin) + signing = product["command_signing"] == "required" + if signing: + if not tesla.private_key: + await tesla.get_private_key("config/tesla_fleet.key") + api = VehicleSigned(tesla.vehicle, vin) + else: + api = VehicleSpecific(tesla.vehicle, vin) coordinator = TeslaFleetVehicleDataCoordinator(hass, api, product) await coordinator.async_config_entry_first_refresh() @@ -145,7 +156,7 @@ async def _refresh_token() -> str: coordinator=coordinator, vin=vin, device=device, - signing=product["command_signing"] == "required", + signing=signing, ) ) elif "energy_site_id" in product and hasattr(tesla, "energy"): diff --git a/homeassistant/components/tesla_fleet/button.py b/homeassistant/components/tesla_fleet/button.py index 87cd95576d2b0..aea0f91a97c5d 100644 --- a/homeassistant/components/tesla_fleet/button.py +++ b/homeassistant/components/tesla_fleet/button.py @@ -70,8 +70,6 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles for description in DESCRIPTIONS if Scope.VEHICLE_CMDS in entry.runtime_data.scopes - and (not vehicle.signing or description.key == "wake") - # Wake doesn't need signing ) diff --git a/homeassistant/components/tesla_fleet/climate.py b/homeassistant/components/tesla_fleet/climate.py index 6199ee112b5dc..9a1533a688f85 100644 --- a/homeassistant/components/tesla_fleet/climate.py +++ b/homeassistant/components/tesla_fleet/climate.py @@ -84,7 +84,7 @@ def __init__( ) -> None: """Initialize the climate.""" - self.read_only = Scope.VEHICLE_CMDS not in scopes or data.signing + self.read_only = Scope.VEHICLE_CMDS not in scopes if self.read_only: self._attr_supported_features = ClimateEntityFeature(0) @@ -231,7 +231,7 @@ def __init__( """Initialize the cabin overheat climate entity.""" # Scopes - self.read_only = Scope.VEHICLE_CMDS not in scopes or data.signing + self.read_only = Scope.VEHICLE_CMDS not in scopes # Supported Features if self.read_only: diff --git a/homeassistant/components/tesla_fleet/cover.py b/homeassistant/components/tesla_fleet/cover.py index 4e49e24b6898a..2a14c4f039b05 100644 --- a/homeassistant/components/tesla_fleet/cover.py +++ b/homeassistant/components/tesla_fleet/cover.py @@ -57,7 +57,7 @@ def __init__(self, data: TeslaFleetVehicleData, scopes: list[Scope]) -> None: self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) - if not self.scoped or self.vehicle.signing: + if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) def _async_update_attrs(self) -> None: @@ -111,7 +111,7 @@ def __init__(self, vehicle: TeslaFleetVehicleData, scopes: list[Scope]) -> None: self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) - if not self.scoped or self.vehicle.signing: + if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) def _async_update_attrs(self) -> None: @@ -144,7 +144,7 @@ def __init__(self, vehicle: TeslaFleetVehicleData, scopes: list[Scope]) -> None: self.scoped = Scope.VEHICLE_CMDS in scopes self._attr_supported_features = CoverEntityFeature.OPEN - if not self.scoped or self.vehicle.signing: + if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) def _async_update_attrs(self) -> None: @@ -172,7 +172,7 @@ def __init__(self, vehicle: TeslaFleetVehicleData, scopes: list[Scope]) -> None: self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) - if not self.scoped or self.vehicle.signing: + if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) def _async_update_attrs(self) -> None: @@ -216,7 +216,7 @@ def __init__(self, vehicle: TeslaFleetVehicleData, scopes: list[Scope]) -> None: super().__init__(vehicle, "vehicle_state_sun_roof_state") self.scoped = Scope.VEHICLE_CMDS in scopes - if not self.scoped or self.vehicle.signing: + if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index 60230cd881d3f..0ee41b5e32271 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -123,14 +123,6 @@ async def wake_up_if_asleep(self) -> None: """Wake up the vehicle if its asleep.""" await wake_up_vehicle(self.vehicle) - def raise_for_read_only(self, scope: Scope) -> None: - """Raise an error if no command signing or a scope is not available.""" - if self.vehicle.signing: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="command_signing" - ) - super().raise_for_read_only(scope) - class TeslaFleetEnergyLiveEntity(TeslaFleetEntity): """Parent class for TeslaFleet Energy Site Live entities.""" diff --git a/homeassistant/components/tesla_fleet/media_player.py b/homeassistant/components/tesla_fleet/media_player.py index 0a1d18c340712..455c990077d2f 100644 --- a/homeassistant/components/tesla_fleet/media_player.py +++ b/homeassistant/components/tesla_fleet/media_player.py @@ -64,7 +64,7 @@ def __init__( """Initialize the media player entity.""" super().__init__(data, "media") self.scoped = scoped - if not scoped and data.signing: + if not scoped: self._attr_supported_features = MediaPlayerEntityFeature(0) def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 942824c504359..fe5cd06c1ef47 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -504,9 +504,6 @@ "command_no_reason": { "message": "Command was unsuccessful but did not return a reason why." }, - "command_signing": { - "message": "Vehicle requires command signing. Please see documentation for more details." - }, "invalid_cop_temp": { "message": "Cabin overheat protection does not support that temperature." }, diff --git a/tests/components/tesla_fleet/conftest.py b/tests/components/tesla_fleet/conftest.py index cc580212233e5..0dc5d87984f33 100644 --- a/tests/components/tesla_fleet/conftest.py +++ b/tests/components/tesla_fleet/conftest.py @@ -167,3 +167,13 @@ def mock_request(): return_value=COMMAND_OK, ) as mock_request: yield mock_request + + +@pytest.fixture(autouse=True) +def mock_signed_command() -> Generator[AsyncMock]: + """Mock Tesla Fleet Api signed_command method.""" + with patch( + "homeassistant.components.tesla_fleet.VehicleSigned.signed_command", + return_value=COMMAND_OK, + ) as mock_signed_command: + yield mock_signed_command diff --git a/tests/components/tesla_fleet/snapshots/test_media_player.ambr b/tests/components/tesla_fleet/snapshots/test_media_player.ambr index d6f3f3e48253c..cc3018364a5eb 100644 --- a/tests/components/tesla_fleet/snapshots/test_media_player.ambr +++ b/tests/components/tesla_fleet/snapshots/test_media_player.ambr @@ -105,7 +105,7 @@ 'original_name': 'Media player', 'platform': 'tesla_fleet', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': 0, 'translation_key': 'media', 'unique_id': 'LRWXF7EK4KC700000-media', 'unit_of_measurement': None, @@ -123,7 +123,7 @@ 'media_position': 1.0, 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', 'source': 'Audible', - 'supported_features': , + 'supported_features': , 'volume_level': 0.16129355359011466, }), 'context': , diff --git a/tests/components/tesla_fleet/test_button.py b/tests/components/tesla_fleet/test_button.py index addba00b93def..07fdc962be94f 100644 --- a/tests/components/tesla_fleet/test_button.py +++ b/tests/components/tesla_fleet/test_button.py @@ -1,13 +1,16 @@ """Test the Tesla Fleet button platform.""" -from unittest.mock import patch +from copy import deepcopy +from unittest.mock import AsyncMock, patch import pytest from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import NotOnWhitelistFault from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import assert_entities, setup_platform @@ -63,3 +66,30 @@ async def test_press( blocking=True, ) command.assert_called_once() + + +async def test_press_signing_error( + hass: HomeAssistant, normal_config_entry: MockConfigEntry, mock_products: AsyncMock +) -> None: + """Test pressing a button with a signing error.""" + # Enable Signing + new_product = deepcopy(mock_products.return_value) + new_product["response"][0]["command_signing"] = "required" + mock_products.return_value = new_product + + await setup_platform(hass, normal_config_entry, [Platform.BUTTON]) + + with ( + patch( + "homeassistant.components.tesla_fleet.VehicleSigned.flash_lights", + side_effect=NotOnWhitelistFault, + ), + pytest.raises(HomeAssistantError) as error, + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ["button.test_flash_lights"]}, + blocking=True, + ) + assert error.from_exception(NotOnWhitelistFault) diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 9dcac4ec388c7..7c17f986663e9 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -1,5 +1,6 @@ """Test the Tesla Fleet init.""" +from copy import deepcopy from unittest.mock import AsyncMock, patch from aiohttp import RequestInfo @@ -404,3 +405,22 @@ async def test_init_region_issue_failed( await setup_platform(hass, normal_config_entry) mock_find_server.assert_called_once() assert normal_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_signing( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_products: AsyncMock, +) -> None: + """Tests when a vehicle requires signing.""" + + # Make the vehicle require command signing + products = deepcopy(mock_products.return_value) + products["response"][0]["command_signing"] = "required" + mock_products.return_value = products + + with patch( + "homeassistant.components.tesla_fleet.TeslaFleetApi.get_private_key" + ) as mock_get_private_key: + await setup_platform(hass, normal_config_entry) + mock_get_private_key.assert_called_once() diff --git a/tests/components/tesla_fleet/test_switch.py b/tests/components/tesla_fleet/test_switch.py index 5cf812439a545..fba4fc05cc402 100644 --- a/tests/components/tesla_fleet/test_switch.py +++ b/tests/components/tesla_fleet/test_switch.py @@ -1,6 +1,5 @@ """Test the tesla_fleet switch platform.""" -from copy import deepcopy from unittest.mock import AsyncMock, patch import pytest @@ -166,29 +165,3 @@ async def test_switch_no_scope( {ATTR_ENTITY_ID: "switch.test_auto_steering_wheel_heater"}, blocking=True, ) - - -async def test_switch_no_signing( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - normal_config_entry: MockConfigEntry, - mock_products: AsyncMock, -) -> None: - """Tests that the switch entities are correct.""" - - # Make the vehicle require command signing - products = deepcopy(mock_products.return_value) - products["response"][0]["command_signing"] = "required" - mock_products.return_value = products - - await setup_platform(hass, normal_config_entry, [Platform.SWITCH]) - with pytest.raises( - ServiceValidationError, - match="Vehicle requires command signing. Please see documentation for more details", - ): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_auto_steering_wheel_heater"}, - blocking=True, - ) From 7fefa5c2359400896a7459573b6226fcbf456707 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 5 Nov 2024 20:25:15 +0100 Subject: [PATCH 0180/1070] Update frontend to 20241105.0 (#129906) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 89cd93227a450..ff399512c8b3b 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241104.0"] + "requirements": ["home-assistant-frontend==20241105.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 56155d53fd51f..e0465ea6c0ef0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241104.0 +home-assistant-frontend==20241105.0 home-assistant-intents==2024.10.30 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 99cd9ea761141..713498f60aabd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241104.0 +home-assistant-frontend==20241105.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab28ebd9f2d70..8bce16ef628a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241104.0 +home-assistant-frontend==20241105.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 From 79de1d9ed4b9374125cfd5303b4c0f9397735578 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 5 Nov 2024 20:26:22 +0100 Subject: [PATCH 0181/1070] Bump holidays to 0.60 (#129909) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 9bb5bd9968ee9..8c64f492d42b2 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.59", "babel==2.15.0"] + "requirements": ["holidays==0.60", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index c9a65a473bdd5..b02db73472967 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.59"] + "requirements": ["holidays==0.60"] } diff --git a/requirements_all.txt b/requirements_all.txt index 713498f60aabd..a414ec12d4bcc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1121,7 +1121,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.59 +holidays==0.60 # homeassistant.components.frontend home-assistant-frontend==20241105.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8bce16ef628a3..1fca9957ff4bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.59 +holidays==0.60 # homeassistant.components.frontend home-assistant-frontend==20241105.0 From 6ecdbb677f8774f99c25576f7fd416ec40ce1a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 5 Nov 2024 19:03:26 -0100 Subject: [PATCH 0182/1070] Bump huawei-lte-api to 1.10.0 (#129911) --- homeassistant/components/huawei_lte/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 908092ba2caa6..6720d6718eff4 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["huawei_lte_api.Session"], "requirements": [ - "huawei-lte-api==1.9.3", + "huawei-lte-api==1.10.0", "stringcase==1.2.0", "url-normalize==1.4.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index a414ec12d4bcc..23ebdb07f4d2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1142,7 +1142,7 @@ horimote==0.4.1 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.9.3 +huawei-lte-api==1.10.0 # homeassistant.components.huum huum==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fca9957ff4bc..fca0717b4aa3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -965,7 +965,7 @@ homematicip==1.1.2 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.9.3 +huawei-lte-api==1.10.0 # homeassistant.components.huum huum==0.7.10 From 9e0445747232cf95f00be91995570d0ea04210be Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Nov 2024 21:04:58 +0100 Subject: [PATCH 0183/1070] Bump spotifyaio to 0.8.4 (#129899) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 2d86083d49c6b..9a52a4cf36a46 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.3"], + "requirements": ["spotifyaio==0.8.4"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 23ebdb07f4d2b..2d17ef364372d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2710,7 +2710,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.3 +spotifyaio==0.8.4 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fca0717b4aa3b..aee62d587c897 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2162,7 +2162,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.3 +spotifyaio==0.8.4 # homeassistant.components.sql sqlparse==0.5.0 From 89a9c2ec24b8e62035046d10885e4d416c21ebb6 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 5 Nov 2024 22:18:41 +0100 Subject: [PATCH 0184/1070] Disable uv cache (#129912) --- Dockerfile | 3 ++- script/hassfest/docker.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2f6a400e0d137..b6d571f308e61 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,8 @@ FROM ${BUILD_FROM} # Synchronize with homeassistant/core.py:async_stop ENV \ S6_SERVICES_GRACETIME=240000 \ - UV_SYSTEM_PYTHON=true + UV_SYSTEM_PYTHON=true \ + UV_NO_CACHE=true ARG QEMU_CPU diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 1f6c19e659363..083cdaba1a90e 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -20,7 +20,8 @@ # Synchronize with homeassistant/core.py:async_stop ENV \ S6_SERVICES_GRACETIME={timeout} \ - UV_SYSTEM_PYTHON=true + UV_SYSTEM_PYTHON=true \ + UV_NO_CACHE=true ARG QEMU_CPU From 901457e7aa03114b6327acaf3b3c23f245b4bcb2 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 5 Nov 2024 15:22:49 -0600 Subject: [PATCH 0185/1070] Bump intents and add HassRespond test (#129830) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- tests/components/conversation/test_default_agent.py | 13 ++++++++++++- tests/components/intent/test_init.py | 11 +++++++++++ 7 files changed, 28 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index ce0849f95144c..2c446ac5d7058 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.10.30"] + "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e0465ea6c0ef0..68ac451a9f0b4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241105.0 -home-assistant-intents==2024.10.30 +home-assistant-intents==2024.11.4 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 2d17ef364372d..b62776a533c4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ holidays==0.60 home-assistant-frontend==20241105.0 # homeassistant.components.conversation -home-assistant-intents==2024.10.30 +home-assistant-intents==2024.11.4 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aee62d587c897..b937d8afa0f3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -953,7 +953,7 @@ holidays==0.60 home-assistant-frontend==20241105.0 # homeassistant.components.conversation -home-assistant-intents==2024.10.30 +home-assistant-intents==2024.11.4 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index cd53c25ffc6c3..1e948c2982ae6 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.4.28,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.2 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.10.30 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.11.4 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index e06ba8b47501d..14a9b0ca88c54 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -431,7 +431,7 @@ async def test_shopping_list_add_item(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("init_components") -async def test_nevermind_item(hass: HomeAssistant) -> None: +async def test_nevermind_intent(hass: HomeAssistant) -> None: """Test HassNevermind intent through the default agent.""" result = await conversation.async_converse(hass, "nevermind", None, Context()) assert result.response.intent is not None @@ -441,6 +441,17 @@ async def test_nevermind_item(hass: HomeAssistant) -> None: assert not result.response.speech +@pytest.mark.usefixtures("init_components") +async def test_respond_intent(hass: HomeAssistant) -> None: + """Test HassRespond intent through the default agent.""" + result = await conversation.async_converse(hass, "hello", None, Context()) + assert result.response.intent is not None + assert result.response.intent.intent_type == intent.INTENT_RESPOND + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == "Hello from Home Assistant." + + @pytest.mark.usefixtures("init_components") async def test_device_area_context( hass: HomeAssistant, diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 659ca16c0bbc7..20c0f9d8d4440 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -455,3 +455,14 @@ async def test_set_position_intent_unsupported_domain(hass: HomeAssistant) -> No "HassSetPosition", {"name": {"value": "test light"}, "position": {"value": 100}}, ) + + +async def test_intents_with_no_responses(hass: HomeAssistant) -> None: + """Test intents that should not return a response during handling.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + # The "respond" intent gets its response text from home-assistant-intents + for intent_name in (intent.INTENT_NEVERMIND, intent.INTENT_RESPOND): + response = await intent.async_handle(hass, "test", intent_name, {}) + assert not response.speech From 64e84e2aa0c88522d9cdde5b7c58cdb06a536f8a Mon Sep 17 00:00:00 2001 From: kingal123 <70146605+kingal123@users.noreply.github.com> Date: Tue, 5 Nov 2024 21:23:14 +0000 Subject: [PATCH 0186/1070] Update pylutron to 0.2.16 (#129653) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/lutron/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 5dbf3c45f2ab5..82bdfad4774e9 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/lutron", "iot_class": "local_polling", "loggers": ["pylutron"], - "requirements": ["pylutron==0.2.15"], + "requirements": ["pylutron==0.2.16"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index b62776a533c4d..f0860a099bbce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2045,7 +2045,7 @@ pylitterbot==2023.5.0 pylutron-caseta==0.21.1 # homeassistant.components.lutron -pylutron==0.2.15 +pylutron==0.2.16 # homeassistant.components.mailgun pymailgunner==1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b937d8afa0f3c..df577c2834a8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1650,7 +1650,7 @@ pylitterbot==2023.5.0 pylutron-caseta==0.21.1 # homeassistant.components.lutron -pylutron==0.2.15 +pylutron==0.2.16 # homeassistant.components.mailgun pymailgunner==1.4 From 5f13db2356bd270a247e57df05fa8563b160da1b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 6 Nov 2024 00:05:05 +0100 Subject: [PATCH 0187/1070] Bump reolink_aio to 0.10.4 (#129914) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 5fd87c2ccb178..23a46c5e1c992 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.10.3"] + "requirements": ["reolink-aio==0.10.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index f0860a099bbce..322d8feb611a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2550,7 +2550,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.3 +reolink-aio==0.10.4 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df577c2834a8b..26bdb41b5b058 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.3 +reolink-aio==0.10.4 # homeassistant.components.rflink rflink==0.0.66 From a927312fb557d98c18afbc7fd1a9ba2a55c6070d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Nov 2024 22:36:26 -0500 Subject: [PATCH 0188/1070] Ensure all template names are strings (#129921) --- homeassistant/components/template/template_entity.py | 6 ++++-- tests/components/template/test_sensor.py | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 3e70e1c3546fc..f5b84b1ad7ad8 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -535,13 +535,15 @@ def _async_setup_templates(self) -> None: ) if self._entity_picture_template is not None: self.add_template_attribute( - "_attr_entity_picture", self._entity_picture_template + "_attr_entity_picture", self._entity_picture_template, cv.string ) if ( self._friendly_name_template is not None and not self._friendly_name_template.is_static ): - self.add_template_attribute("_attr_name", self._friendly_name_template) + self.add_template_attribute( + "_attr_name", self._friendly_name_template, cv.string + ) @callback def async_start_preview( diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 5a7521f98c737..929a890ab384f 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.template.sensor import TriggerSensorEntity from homeassistant.const import ( ATTR_ENTITY_PICTURE, + ATTR_FRIENDLY_NAME, ATTR_ICON, EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, @@ -983,6 +984,7 @@ async def test_self_referencing_sensor_with_icon_and_picture_entity_loop( "test": { "value_template": "{{ 1 }}", "entity_picture_template": "{{ ((states.sensor.test.attributes['entity_picture'] or 0) | int) + 1 }}", + "friendly_name_template": "{{ ((states.sensor.test.attributes['friendly_name'] or 0) | int) + 1 }}", }, }, } @@ -1007,7 +1009,8 @@ async def test_self_referencing_entity_picture_loop( state = hass.states.get("sensor.test") assert int(state.state) == 1 - assert state.attributes[ATTR_ENTITY_PICTURE] == 2 + assert state.attributes[ATTR_ENTITY_PICTURE] == "3" + assert state.attributes[ATTR_FRIENDLY_NAME] == "3" await hass.async_block_till_done() assert int(state.state) == 1 From f88bc008e5c8ad7cc00bbc8a247dd07485eff7c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Nov 2024 08:13:41 +0100 Subject: [PATCH 0189/1070] Bump actions/attest-build-provenance from 1.4.3 to 1.4.4 (#129924) --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index e359ed59cf0a9..7c08df39000c8 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@1c608d11d69870c2092266b3f9a6f3abbf17002c # v1.4.3 + uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1.4.4 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} From 184cbfea23eb73ab9cc29e343284589a8274de2f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 08:14:54 +0100 Subject: [PATCH 0190/1070] Use read-only options in lastfm options flow (#129928) Use read-only options in lstfm options flow --- homeassistant/components/lastfm/config_flow.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index d460792f7c8a9..0e1f680dd636c 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -163,24 +163,25 @@ async def async_step_init( ) -> ConfigFlowResult: """Initialize form.""" errors: dict[str, str] = {} + options = self.config_entry.options if user_input is not None: users, errors = validate_lastfm_users( - self.options[CONF_API_KEY], user_input[CONF_USERS] + options[CONF_API_KEY], user_input[CONF_USERS] ) user_input[CONF_USERS] = users if not errors: return self.async_create_entry( title="LastFM", data={ - **self.options, + **options, CONF_USERS: user_input[CONF_USERS], }, ) - if self.options[CONF_MAIN_USER]: + if options[CONF_MAIN_USER]: try: main_user, _ = get_lastfm_user( - self.options[CONF_API_KEY], - self.options[CONF_MAIN_USER], + options[CONF_API_KEY], + options[CONF_MAIN_USER], ) friends_response = await self.hass.async_add_executor_job( main_user.get_friends @@ -206,6 +207,6 @@ async def async_step_init( ), } ), - user_input or self.options, + user_input or options, ), ) From 2eb2bdd61558760439240205f448b6eb7befa252 Mon Sep 17 00:00:00 2001 From: Nicholas Romyn <13968908+nromyn@users.noreply.github.com> Date: Wed, 6 Nov 2024 02:25:18 -0500 Subject: [PATCH 0191/1070] Consolidating async_add_entities into one call in Ecobee (#129917) * Consolidating async_add_entities into one call. * changing to comprehension. --- homeassistant/components/ecobee/switch.py | 33 ++++++++++++----------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/ecobee/switch.py b/homeassistant/components/ecobee/switch.py index 67be78fb21de3..89ee433c07225 100644 --- a/homeassistant/components/ecobee/switch.py +++ b/homeassistant/components/ecobee/switch.py @@ -31,25 +31,26 @@ async def async_setup_entry( """Set up the ecobee thermostat switch entity.""" data: EcobeeData = hass.data[DOMAIN] - async_add_entities( - [ - EcobeeVentilator20MinSwitch( - data, - index, - (await dt_util.async_get_time_zone(thermostat["location"]["timeZone"])) - or dt_util.get_default_time_zone(), - ) + entities: list[SwitchEntity] = [ + EcobeeVentilator20MinSwitch( + data, + index, + (await dt_util.async_get_time_zone(thermostat["location"]["timeZone"])) + or dt_util.get_default_time_zone(), + ) + for index, thermostat in enumerate(data.ecobee.thermostats) + if thermostat["settings"]["ventilatorType"] != "none" + ] + + entities.extend( + ( + EcobeeSwitchAuxHeatOnly(data, index) for index, thermostat in enumerate(data.ecobee.thermostats) - if thermostat["settings"]["ventilatorType"] != "none" - ], - update_before_add=True, + if thermostat["settings"]["hasHeatPump"] + ) ) - async_add_entities( - EcobeeSwitchAuxHeatOnly(data, index) - for index, thermostat in enumerate(data.ecobee.thermostats) - if thermostat["settings"]["hasHeatPump"] - ) + async_add_entities(entities, update_before_add=True) class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity): From 5679b061d2986bfe4dee46ab0556fb823b02e4f8 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 Nov 2024 10:07:10 +0100 Subject: [PATCH 0192/1070] Fix native sync WebRTC offer (#129931) --- homeassistant/components/camera/__init__.py | 5 ++++- tests/components/camera/test_webrtc.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index b600eae02c711..67c2432129f4f 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -848,7 +848,10 @@ def async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration: ] config.configuration.ice_servers.extend(ice_servers) - config.get_candidates_upfront = self._legacy_webrtc_provider is not None + config.get_candidates_upfront = ( + self._supports_native_sync_webrtc + or self._legacy_webrtc_provider is not None + ) return config diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index f726eb2967388..7a1df556c20af 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -358,7 +358,7 @@ async def test_ws_get_client_config_sync_offer( assert msg["success"] assert msg["result"] == { "configuration": {}, - "getCandidatesUpfront": False, + "getCandidatesUpfront": True, } From 33016c29770de12ea62e9df701be86c56a345b33 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 10:37:55 +0100 Subject: [PATCH 0193/1070] Use new helper properties in netatmo options flow (#129781) * Use new helper properties in netatmo options flow * Update homeassistant/components/netatmo/config_flow.py * Apply suggestions from code review * Improve * Keep options * Simplify --- homeassistant/components/netatmo/config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 0da4d6f16b73a..d853694ffeae7 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -101,7 +101,6 @@ class NetatmoOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Netatmo options flow.""" - self.config_entry = config_entry self.options = dict(config_entry.options) self.options.setdefault(CONF_WEATHER_AREAS, {}) From 648c3d500b922d77deeaf947fa25dc7591be0adb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Nov 2024 11:32:35 +0100 Subject: [PATCH 0194/1070] Bump spotifyaio to 0.8.5 (#129938) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 9a52a4cf36a46..8cf8d73555382 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.4"], + "requirements": ["spotifyaio==0.8.5"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 322d8feb611a4..3f602f592d792 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2710,7 +2710,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.4 +spotifyaio==0.8.5 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26bdb41b5b058..63f7db8a2122b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2162,7 +2162,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.4 +spotifyaio==0.8.5 # homeassistant.components.sql sqlparse==0.5.0 From 25eb7173bf5d3a25c2c9a09fdf5cfd3cef6f001e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Nov 2024 11:32:59 +0100 Subject: [PATCH 0195/1070] Write squeezebox player state after query (#129939) --- homeassistant/components/squeezebox/media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 6037017dd1ee7..19cd1e369105f 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -535,6 +535,7 @@ async def async_call_query( all_params.extend(parameters) self._query_result = await self._player.async_query(*all_params) _LOGGER.debug("call_query got result %s", self._query_result) + self.async_write_ha_state() async def async_join_players(self, group_members: list[str]) -> None: """Add other Squeezebox players to this player's sync group. From 4dbf3359c11a3a2d2c8eb5cb449ecf3ab066d9a5 Mon Sep 17 00:00:00 2001 From: Kunal Aggarwal Date: Wed, 6 Nov 2024 16:13:41 +0530 Subject: [PATCH 0196/1070] Adding "peaceful" status as on value to Tuya Presence Sensor (#129925) --- homeassistant/components/tuya/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 934f03336aadf..12661a26fd16b 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -151,7 +151,7 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): TuyaBinarySensorEntityDescription( key=DPCode.PRESENCE_STATE, device_class=BinarySensorDeviceClass.OCCUPANCY, - on_value={"presence", "small_move", "large_move"}, + on_value={"presence", "small_move", "large_move", "peaceful"}, ), ), # Formaldehyde Detector From 370d7d6bdfa707e30c3c7f321b02691b29468cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 6 Nov 2024 11:44:54 +0100 Subject: [PATCH 0197/1070] Bump pyTibber to 0.30.4 (#129844) --- homeassistant/components/tibber/manifest.json | 2 +- homeassistant/components/tibber/services.py | 12 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tibber/test_services.py | 96 +++++-------------- 5 files changed, 29 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index ac46141d974a8..205bc1352ebda 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.30.3"] + "requirements": ["pyTibber==0.30.4"] } diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py index 87268186285b6..72943a0215a09 100644 --- a/homeassistant/components/tibber/services.py +++ b/homeassistant/components/tibber/services.py @@ -47,17 +47,13 @@ async def __get_prices(call: ServiceCall, *, hass: HomeAssistant) -> ServiceResp for tibber_home in tibber_connection.get_homes(only_active=True): home_nickname = tibber_home.name - price_info = tibber_home.info["viewer"]["home"]["currentSubscription"][ - "priceInfo" - ] price_data = [ { - "start_time": price["startsAt"], - "price": price["total"], - "level": price["level"], + "start_time": starts_at, + "price": price, + "level": tibber_home.price_level.get(starts_at), } - for key in ("today", "tomorrow") - for price in price_info[key] + for starts_at, price in tibber_home.price_total.items() ] selected_data = [ diff --git a/requirements_all.txt b/requirements_all.txt index 3f602f592d792..2be7bb32ff22f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1738,7 +1738,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.30.3 +pyTibber==0.30.4 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 63f7db8a2122b..c589b664ff171 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1415,7 +1415,7 @@ pyElectra==1.2.4 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.30.3 +pyTibber==0.30.4 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/tests/components/tibber/test_services.py b/tests/components/tibber/test_services.py index 49f9e5e451b78..dc6f5d2789df2 100644 --- a/tests/components/tibber/test_services.py +++ b/tests/components/tibber/test_services.py @@ -20,84 +20,32 @@ def generate_mock_home_data(): mock_homes = [ MagicMock( name="first_home", - info={ - "viewer": { - "home": { - "currentSubscription": { - "priceInfo": { - "today": [ - { - "startsAt": START_TIME.isoformat(), - "total": 0.36914, - "level": "VERY_EXPENSIVE", - }, - { - "startsAt": ( - START_TIME + dt.timedelta(hours=1) - ).isoformat(), - "total": 0.36914, - "level": "VERY_EXPENSIVE", - }, - ], - "tomorrow": [ - { - "startsAt": tomorrow.isoformat(), - "total": 0.46914, - "level": "VERY_EXPENSIVE", - }, - { - "startsAt": ( - tomorrow + dt.timedelta(hours=1) - ).isoformat(), - "total": 0.46914, - "level": "VERY_EXPENSIVE", - }, - ], - } - } - } - } + price_total={ + START_TIME.isoformat(): 0.36914, + (START_TIME + dt.timedelta(hours=1)).isoformat(): 0.36914, + tomorrow.isoformat(): 0.46914, + (tomorrow + dt.timedelta(hours=1)).isoformat(): 0.46914, + }, + price_level={ + START_TIME.isoformat(): "VERY_EXPENSIVE", + (START_TIME + dt.timedelta(hours=1)).isoformat(): "VERY_EXPENSIVE", + tomorrow.isoformat(): "VERY_EXPENSIVE", + (tomorrow + dt.timedelta(hours=1)).isoformat(): "VERY_EXPENSIVE", }, ), MagicMock( name="second_home", - info={ - "viewer": { - "home": { - "currentSubscription": { - "priceInfo": { - "today": [ - { - "startsAt": START_TIME.isoformat(), - "total": 0.36914, - "level": "VERY_EXPENSIVE", - }, - { - "startsAt": ( - START_TIME + dt.timedelta(hours=1) - ).isoformat(), - "total": 0.36914, - "level": "VERY_EXPENSIVE", - }, - ], - "tomorrow": [ - { - "startsAt": tomorrow.isoformat(), - "total": 0.46914, - "level": "VERY_EXPENSIVE", - }, - { - "startsAt": ( - tomorrow + dt.timedelta(hours=1) - ).isoformat(), - "total": 0.46914, - "level": "VERY_EXPENSIVE", - }, - ], - } - } - } - } + price_total={ + START_TIME.isoformat(): 0.36914, + (START_TIME + dt.timedelta(hours=1)).isoformat(): 0.36914, + tomorrow.isoformat(): 0.46914, + (tomorrow + dt.timedelta(hours=1)).isoformat(): 0.46914, + }, + price_level={ + START_TIME.isoformat(): "VERY_EXPENSIVE", + (START_TIME + dt.timedelta(hours=1)).isoformat(): "VERY_EXPENSIVE", + tomorrow.isoformat(): "VERY_EXPENSIVE", + (tomorrow + dt.timedelta(hours=1)).isoformat(): "VERY_EXPENSIVE", }, ), ] From f6f89bd807e26417cf43f36abf6cd961a7b44bab Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Wed, 6 Nov 2024 11:52:00 +0100 Subject: [PATCH 0198/1070] Update Bang & Olufsen source list as availability changes (#129910) --- .../components/bang_olufsen/const.py | 36 ++++++++++--------- .../components/bang_olufsen/media_player.py | 9 ++--- .../components/bang_olufsen/websocket.py | 11 ++++++ tests/components/bang_olufsen/conftest.py | 6 ++-- tests/components/bang_olufsen/const.py | 1 + .../bang_olufsen/test_media_player.py | 32 +++++++++++++++++ 6 files changed, 70 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index caa4cef8a130f..1e06f153cdb05 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -21,41 +21,57 @@ class BangOlufsenSource: name="Audio Streamer", id="uriStreamer", is_seekable=False, + is_enabled=True, + is_playable=True, ) BLUETOOTH: Final[Source] = Source( name="Bluetooth", id="bluetooth", is_seekable=False, + is_enabled=True, + is_playable=True, ) CHROMECAST: Final[Source] = Source( name="Chromecast built-in", id="chromeCast", is_seekable=False, + is_enabled=True, + is_playable=True, ) LINE_IN: Final[Source] = Source( name="Line-In", id="lineIn", is_seekable=False, + is_enabled=True, + is_playable=True, ) SPDIF: Final[Source] = Source( name="Optical", id="spdif", is_seekable=False, + is_enabled=True, + is_playable=True, ) NET_RADIO: Final[Source] = Source( name="B&O Radio", id="netRadio", is_seekable=False, + is_enabled=True, + is_playable=True, ) DEEZER: Final[Source] = Source( name="Deezer", id="deezer", is_seekable=True, + is_enabled=True, + is_playable=True, ) TIDAL: Final[Source] = Source( name="Tidal", id="tidal", is_seekable=True, + is_enabled=True, + is_playable=True, ) @@ -170,20 +186,6 @@ class WebsocketNotification(StrEnum): MediaType.CHANNEL, ) -# Sources on the device that should not be selectable by the user -HIDDEN_SOURCE_IDS: Final[tuple] = ( - "airPlay", - "bluetooth", - "chromeCast", - "generator", - "local", - "dlna", - "qplay", - "wpl", - "pl", - "beolink", - "usbIn", -) # Fallback sources to use in case of API failure. FALLBACK_SOURCES: Final[SourceArray] = SourceArray( @@ -191,7 +193,7 @@ class WebsocketNotification(StrEnum): Source( id="uriStreamer", is_enabled=True, - is_playable=False, + is_playable=True, name="Audio Streamer", type=SourceTypeEnum(value="uriStreamer"), is_seekable=False, @@ -199,7 +201,7 @@ class WebsocketNotification(StrEnum): Source( id="bluetooth", is_enabled=True, - is_playable=False, + is_playable=True, name="Bluetooth", type=SourceTypeEnum(value="bluetooth"), is_seekable=False, @@ -207,7 +209,7 @@ class WebsocketNotification(StrEnum): Source( id="spotify", is_enabled=True, - is_playable=False, + is_playable=True, name="Spotify Connect", type=SourceTypeEnum(value="spotify"), is_seekable=True, diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 31f821683d487..e8108ee2cf7f7 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -70,7 +70,6 @@ CONNECTION_STATUS, DOMAIN, FALLBACK_SOURCES, - HIDDEN_SOURCE_IDS, VALID_MEDIA_TYPES, BangOlufsenMediaType, BangOlufsenSource, @@ -169,6 +168,7 @@ async def async_added_to_hass(self) -> None: WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error, WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink, WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress, + WebsocketNotification.PLAYBACK_SOURCE: self._async_update_sources, WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state, WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources, WebsocketNotification.SOURCE_CHANGE: self._async_update_source_change, @@ -243,7 +243,7 @@ async def async_update(self) -> None: if queue_settings.shuffle is not None: self._attr_shuffle = queue_settings.shuffle - async def _async_update_sources(self) -> None: + async def _async_update_sources(self, _: Source | None = None) -> None: """Get sources for the specific product.""" # Audio sources @@ -270,10 +270,7 @@ async def _async_update_sources(self) -> None: self._audio_sources = { source.id: source.name for source in cast(list[Source], sources.items) - if source.is_enabled - and source.id - and source.name - and source.id not in HIDDEN_SOURCE_IDS + if source.is_enabled and source.id and source.name and source.is_playable } # Some sources are not Beolink expandable, meaning that they can't be joined by diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 3519fcd9a48a7..94b84189ccc16 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -63,6 +63,9 @@ def __init__( self._client.get_playback_progress_notifications( self.on_playback_progress_notification ) + self._client.get_playback_source_notifications( + self.on_playback_source_notification + ) self._client.get_playback_state_notifications( self.on_playback_state_notification ) @@ -157,6 +160,14 @@ def on_playback_state_notification(self, notification: RenderingState) -> None: notification, ) + def on_playback_source_notification(self, notification: Source) -> None: + """Send playback_source dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebsocketNotification.PLAYBACK_SOURCE}", + notification, + ) + def on_source_change_notification(self, notification: Source) -> None: """Send source_change dispatch.""" async_dispatcher_send( diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index a644b395c694d..6c19a29c1daaa 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -124,7 +124,7 @@ def mock_mozart_client() -> Generator[AsyncMock]: client.get_available_sources = AsyncMock() client.get_available_sources.return_value = SourceArray( items=[ - # Is in the HIDDEN_SOURCE_IDS constant, so should not be user selectable + # Is not playable, so should not be user selectable Source( name="AirPlay", id="airPlay", @@ -137,14 +137,16 @@ def mock_mozart_client() -> Generator[AsyncMock]: id="tidal", is_enabled=True, is_multiroom_available=True, + is_playable=True, ), Source( name="Line-In", id="lineIn", is_enabled=True, is_multiroom_available=False, + is_playable=True, ), - # Is disabled, so should not be user selectable + # Is disabled and not playable, so should not be user selectable Source( name="Powerlink", id="pl", diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 7f2e52cfc8767..3769aef5cd3ab 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -130,6 +130,7 @@ TEST_SOURCES = TEST_AUDIO_SOURCES + TEST_VIDEO_SOURCES TEST_FALLBACK_SOURCES = [ "Audio Streamer", + "Bluetooth", "Spotify Connect", "Line-In", "Optical", diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 844e9bfe61b42..8f23af9e04a09 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -10,6 +10,7 @@ PlayQueueSettings, RenderingState, Source, + SourceArray, WebsocketNotificationTag, ) import pytest @@ -195,6 +196,37 @@ async def test_async_update_sources_remote( assert mock_mozart_client.get_remote_menu.call_count == 2 +async def test_async_update_sources_availability( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the playback_source WebSocket event updates available playback sources.""" + # Remove video sources to simplify test + mock_mozart_client.get_remote_menu.return_value = {} + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + playback_source_callback = ( + mock_mozart_client.get_playback_source_notifications.call_args[0][0] + ) + + assert mock_mozart_client.get_available_sources.call_count == 1 + + # Add a source that is available and playable + mock_mozart_client.get_available_sources.return_value = SourceArray( + items=[BangOlufsenSource.TIDAL] + ) + + # Send playback_source. The source is not actually used, so its attributes don't matter + playback_source_callback(Source()) + + assert mock_mozart_client.get_available_sources.call_count == 2 + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states.attributes[ATTR_INPUT_SOURCE_LIST] == [BangOlufsenSource.TIDAL.name] + + async def test_async_update_playback_metadata( hass: HomeAssistant, mock_mozart_client: AsyncMock, From 25449b424fe6a938e287de1637be2165a456fe5d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 Nov 2024 12:05:23 +0100 Subject: [PATCH 0199/1070] Bump go2rtc-client to 0.0.1b4 (#129942) --- homeassistant/components/go2rtc/__init__.py | 5 ++++- homeassistant/components/go2rtc/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/go2rtc/test_init.py | 12 ++++++++---- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 9ffe9e25f78b1..a07a62305f2ee 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -222,7 +222,10 @@ async def async_handle_async_webrtc_offer( if (stream := streams.get(camera.entity_id)) is None or not any( stream_source == producer.url for producer in stream.producers ): - await self._rest_client.streams.add(camera.entity_id, stream_source) + await self._rest_client.streams.add( + camera.entity_id, + [stream_source, f"ffmpeg:{camera.entity_id}#audio=opus"], + ) @callback def on_messages(message: ReceiveMessages) -> None: diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index b30b7cb1cc1f7..e69140a51dbb3 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", - "requirements": ["go2rtc-client==0.0.1b3"], + "requirements": ["go2rtc-client==0.0.1b4"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 68ac451a9f0b4..aeaa4aa7dcda0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ ciso8601==2.3.1 cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 -go2rtc-client==0.0.1b3 +go2rtc-client==0.0.1b4 ha-ffmpeg==3.2.1 habluetooth==3.6.0 hass-nabucasa==0.84.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2be7bb32ff22f..3ac09644b5d59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -990,7 +990,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b3 +go2rtc-client==0.0.1b4 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c589b664ff171..d8b4a50c2549b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -840,7 +840,7 @@ gios==5.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b3 +go2rtc-client==0.0.1b4 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 21d4d0a047e3d..61b0ca97406c7 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -237,24 +237,28 @@ async def test() -> None: await test() - rest_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream") + rest_client.streams.add.assert_called_once_with( + entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + ) # Stream exists but the source is different rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://different")]) + entity_id: Stream([Producer("rtsp://different", [])]) } receive_message_callback.reset_mock() ws_client.reset_mock() await test() - rest_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream") + rest_client.streams.add.assert_called_once_with( + entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + ) # If the stream is already added, the stream should not be added again. rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://stream")]) + entity_id: Stream([Producer("rtsp://stream", [])]) } receive_message_callback.reset_mock() From a7ba4bd086960672fa40fe3f54be81e7306ece14 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 13:09:05 +0100 Subject: [PATCH 0200/1070] Use read-only options in emoncms options flow (#129926) * Use read-only options in emoncms options flow * Don't store URL and API_KEY in entry options --- .../components/emoncms/config_flow.py | 20 ++++++++++--------- homeassistant/components/emoncms/sensor.py | 9 +++++---- tests/components/emoncms/test_config_flow.py | 14 ++++++------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index e2e08217b3cb6..b294a5cd3d47b 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -72,7 +72,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> EmoncmsOptionsFlow: """Get the options flow for this handler.""" - return EmoncmsOptionsFlow() + return EmoncmsOptionsFlow(config_entry) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -175,18 +175,23 @@ async def async_step_import(self, import_info: ConfigType) -> ConfigFlowResult: class EmoncmsOptionsFlow(OptionsFlow): """Emoncms Options flow handler.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize emoncms options flow.""" + self._url = config_entry.data[CONF_URL] + self._api_key = config_entry.data[CONF_API_KEY] + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" errors: dict[str, str] = {} description_placeholders = {} - data = self.options if self.options else self.config_entry.data - url = data[CONF_URL] - api_key = data[CONF_API_KEY] - include_only_feeds = data.get(CONF_ONLY_INCLUDE_FEEDID, []) + include_only_feeds = self.config_entry.options.get( + CONF_ONLY_INCLUDE_FEEDID, + self.config_entry.data.get(CONF_ONLY_INCLUDE_FEEDID, []), + ) options: list = include_only_feeds - result = await get_feed_list(self.hass, url, api_key) + result = await get_feed_list(self.hass, self._url, self._api_key) if not result[CONF_SUCCESS]: errors["base"] = "api_error" description_placeholders = {"details": result[CONF_MESSAGE]} @@ -196,10 +201,7 @@ async def async_step_init( if user_input: include_only_feeds = user_input[CONF_ONLY_INCLUDE_FEEDID] return self.async_create_entry( - title=sensor_name(url), data={ - CONF_URL: url, - CONF_API_KEY: api_key, CONF_ONLY_INCLUDE_FEEDID: include_only_feeds, }, ) diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 4add7c9625d92..d8dec12800aa7 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -138,10 +138,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the emoncms sensors.""" - config = entry.options if entry.options else entry.data - name = sensor_name(config[CONF_URL]) - exclude_feeds = config.get(CONF_EXCLUDE_FEEDID) - include_only_feeds = config.get(CONF_ONLY_INCLUDE_FEEDID) + name = sensor_name(entry.data[CONF_URL]) + exclude_feeds = entry.data.get(CONF_EXCLUDE_FEEDID) + include_only_feeds = entry.options.get( + CONF_ONLY_INCLUDE_FEEDID, entry.data.get(CONF_ONLY_INCLUDE_FEEDID) + ) if exclude_feeds is None and include_only_feeds is None: return diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index 43710967a0154..b3afc714c5928 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -97,10 +97,6 @@ async def test_user_flow( assert len(mock_setup_entry.mock_calls) == 1 -USER_OPTIONS = { - CONF_ONLY_INCLUDE_FEEDID: ["1"], -} - CONFIG_ENTRY = { CONF_API_KEY: "my_api_key", CONF_ONLY_INCLUDE_FEEDID: ["1"], @@ -116,15 +112,19 @@ async def test_options_flow( ) -> None: """Options flow - success test.""" await setup_integration(hass, config_entry) + assert config_entry.options == {} result = await hass.config_entries.options.async_init(config_entry.entry_id) await hass.async_block_till_done() result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input=USER_OPTIONS, + user_input={ + CONF_ONLY_INCLUDE_FEEDID: ["1"], + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == CONFIG_ENTRY - assert config_entry.options == CONFIG_ENTRY + assert config_entry.options == { + CONF_ONLY_INCLUDE_FEEDID: ["1"], + } async def test_options_flow_failure( From 2c1db109866d40eb9ed1945a7f5aa2218501b0a1 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Wed, 6 Nov 2024 13:10:23 +0100 Subject: [PATCH 0201/1070] Map "stop" to MediaPlayerState.IDLE in bluesound integration (#129904) Co-authored-by: Joost Lekkerkerker --- .../components/bluesound/media_player.py | 13 ++++++------ .../components/bluesound/test_media_player.py | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 20cf51ff2f90b..1d46af2cc4b50 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -364,12 +364,13 @@ def state(self) -> MediaPlayerState: if self.is_grouped and not self.is_master: return MediaPlayerState.IDLE - status = self._status.state - if status in ("pause", "stop"): - return MediaPlayerState.PAUSED - if status in ("stream", "play"): - return MediaPlayerState.PLAYING - return MediaPlayerState.IDLE + match self._status.state: + case "pause": + return MediaPlayerState.PAUSED + case "stream" | "play": + return MediaPlayerState.PLAYING + case _: + return MediaPlayerState.IDLE @property def media_title(self) -> str | None: diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index 966f311765036..894528265e1b2 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -130,6 +130,26 @@ async def test_attributes_set( assert state == snapshot(exclude=props("media_position_updated_at")) +async def test_stop_maps_to_idle( + hass: HomeAssistant, + setup_config_entry: None, + player_mocks: PlayerMocks, +) -> None: + """Test the media player stop maps to idle.""" + player_mocks.player_data.status_long_polling_mock.set( + dataclasses.replace( + player_mocks.player_data.status_long_polling_mock.get(), state="stop" + ) + ) + + # give the long polling loop a chance to update the state; this could be any async call + await hass.async_block_till_done() + + assert ( + hass.states.get("media_player.player_name1111").state == MediaPlayerState.IDLE + ) + + async def test_status_updated( hass: HomeAssistant, setup_config_entry: None, From 27e81fe0edc2fa8f6156cf4f8a69f03ecfd7bd55 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 6 Nov 2024 13:23:43 +0100 Subject: [PATCH 0202/1070] Improve error messages in Habitica (#129948) Improve error messages --- homeassistant/components/habitica/coordinator.py | 4 ++-- homeassistant/components/habitica/strings.json | 4 ++-- tests/components/habitica/test_button.py | 4 ++-- tests/components/habitica/test_init.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index 4e949b703fb3e..cce2c684ba851 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -59,9 +59,9 @@ async def _async_update_data(self) -> HabiticaData: tasks_response.extend(await self.api.tasks.user.get(type="completedTodos")) except ClientResponseError as error: if error.status == HTTPStatus.TOO_MANY_REQUESTS: - _LOGGER.debug("Currently rate limited, skipping update") + _LOGGER.debug("Rate limit exceeded, will try again later") return self.data - raise UpdateFailed(f"Error communicating with API: {error}") from error + raise UpdateFailed(f"Unable to connect to Habitica: {error}") from error return HabiticaData(user=user_response, tasks=tasks_response) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 45824c484e94f..f7d2f20b8f9fb 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -209,10 +209,10 @@ "message": "Unable to create new to-do `{name}` for Habitica, please try again" }, "setup_rate_limit_exception": { - "message": "Currently rate limited, try again later" + "message": "Rate limit exceeded, try again later" }, "service_call_unallowed": { - "message": "Unable to carry out this action, because the required conditions are not met" + "message": "Unable to complete action, the required conditions are not met" }, "service_call_exception": { "message": "Unable to connect to Habitica, try again later" diff --git a/tests/components/habitica/test_button.py b/tests/components/habitica/test_button.py index e7eda1609c87a..6bd62f3a58e1b 100644 --- a/tests/components/habitica/test_button.py +++ b/tests/components/habitica/test_button.py @@ -207,7 +207,7 @@ async def test_button_press( [ ( HTTPStatus.TOO_MANY_REQUESTS, - "Currently rate limited", + "Rate limit exceeded, try again later", ServiceValidationError, ), ( @@ -217,7 +217,7 @@ async def test_button_press( ), ( HTTPStatus.UNAUTHORIZED, - "Unable to carry out this action", + "Unable to complete action, the required conditions are not met", ServiceValidationError, ), ], diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index 0ee2d87295453..fd8a18b2d449e 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -165,4 +165,4 @@ async def test_coordinator_rate_limited( async_fire_time_changed(hass) await hass.async_block_till_done() - assert "Currently rate limited, skipping update" in caplog.text + assert "Rate limit exceeded, will try again later" in caplog.text From c6cb2884f444e480dcb87e693d8680a8f4e19b2a Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 6 Nov 2024 13:40:17 +0100 Subject: [PATCH 0203/1070] Add motion sensor setting to tplink (#129393) --- homeassistant/components/tplink/icons.json | 6 +++ homeassistant/components/tplink/strings.json | 3 ++ homeassistant/components/tplink/switch.py | 3 ++ .../components/tplink/fixtures/features.json | 5 ++ .../tplink/snapshots/test_switch.ambr | 46 +++++++++++++++++++ 5 files changed, 63 insertions(+) diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index 3a83349c61306..0abd68543c5f0 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -71,6 +71,12 @@ }, "child_lock": { "default": "mdi:account-lock" + }, + "pir_enabled": { + "default": "mdi:motion-sensor-off", + "state": { + "on": "mdi:motion-sensor" + } } }, "sensor": { diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index e15f3cfba0388..8e5118c272087 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -193,6 +193,9 @@ }, "child_lock": { "name": "Child lock" + }, + "pir_enabled": { + "name": "Motion sensor" } }, "number": { diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 9ef58484ea861..c9285d86ba60c 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -51,6 +51,9 @@ class TPLinkSwitchEntityDescription( TPLinkSwitchEntityDescription( key="child_lock", ), + TPLinkSwitchEntityDescription( + key="pir_enabled", + ), ) SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS} diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index f0cfcc92ea1ac..f60132fd2c286 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -39,6 +39,11 @@ "type": "Switch", "category": "Config" }, + "pir_enabled": { + "value": true, + "type": "Switch", + "category": "Config" + }, "current_consumption": { "value": 5.23, "type": "Sensor", diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index f6e9ad51410dc..36c630474c836 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -311,6 +311,52 @@ 'state': 'on', }) # --- +# name: test_states[switch.my_device_motion_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.my_device_motion_sensor', + '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': 'Motion sensor', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pir_enabled', + 'unique_id': '123456789ABCDEFGH_pir_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_motion_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Motion sensor', + }), + 'context': , + 'entity_id': 'switch.my_device_motion_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_states[switch.my_device_smooth_transitions-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 96de4b3828c1ec3f17e7573e58a846ef43a6a647 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 6 Nov 2024 22:40:37 +1000 Subject: [PATCH 0204/1070] Improve history coordinator in Teslemetry (#128235) --- homeassistant/components/teslemetry/__init__.py | 17 +++++++++++------ homeassistant/components/teslemetry/entity.py | 2 ++ homeassistant/components/teslemetry/models.py | 2 +- homeassistant/components/teslemetry/sensor.py | 3 +-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index b884f9bbc5cbb..aa1d2b426603b 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -135,11 +135,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes: site_id = product["energy_site_id"] - if not ( - product["components"]["battery"] - or product["components"]["solar"] - or "wall_connectors" in product["components"] - ): + powerwall = ( + product["components"]["battery"] or product["components"]["solar"] + ) + wall_connector = "wall_connectors" in product["components"] + if not powerwall and not wall_connector: LOGGER.debug( "Skipping Energy Site %s as it has no components", site_id, @@ -162,7 +162,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - info_coordinator=TeslemetryEnergySiteInfoCoordinator( hass, api, product ), - history_coordinator=TeslemetryEnergyHistoryCoordinator(hass, api), + history_coordinator=( + TeslemetryEnergyHistoryCoordinator(hass, api) + if powerwall + else None + ), id=site_id, device=device, ) @@ -185,6 +189,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - *( energysite.history_coordinator.async_config_entry_first_refresh() for energysite in energysites + if energysite.history_coordinator ), ) diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index ca40d4d00ce4e..d14f3a42734c5 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -175,6 +175,8 @@ def __init__( ) -> None: """Initialize common aspects of a Teslemetry Energy Site Info entity.""" + assert data.history_coordinator + self.api = data.api self._attr_unique_id = f"{data.id}-{key}" self._attr_device_info = data.device diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 7f8bd37425a8d..d3969b30a7caa 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -49,6 +49,6 @@ class TeslemetryEnergyData: api: EnergySpecific live_coordinator: TeslemetryEnergySiteLiveCoordinator info_coordinator: TeslemetryEnergySiteInfoCoordinator - history_coordinator: TeslemetryEnergyHistoryCoordinator + history_coordinator: TeslemetryEnergyHistoryCoordinator | None id: int device: DeviceInfo diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index ba7d930fcd055..95876cc2cf9db 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -482,8 +482,7 @@ async def async_setup_entry( TeslemetryEnergyHistorySensorEntity(energysite, description) for energysite in entry.runtime_data.energysites for description in ENERGY_HISTORY_DESCRIPTIONS - if energysite.info_coordinator.data.get("components_battery") - or energysite.info_coordinator.data.get("components_solar") + if energysite.history_coordinator ), ) ) From 57d1001603b6df3f604f35344dc94dda936c8388 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Wed, 6 Nov 2024 15:19:58 +0200 Subject: [PATCH 0205/1070] Move Jewish Calendar to runtime data (#129609) --- .../components/jewish_calendar/__init__.py | 39 +++++++++--------- .../jewish_calendar/binary_sensor.py | 10 ++--- .../components/jewish_calendar/entity.py | 40 +++++++++++-------- .../components/jewish_calendar/sensor.py | 17 +++----- 4 files changed, 52 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index fd238e8d615b4..4598cf7cd91a4 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -7,12 +7,11 @@ from hdate import Location import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_ELEVATION, CONF_LANGUAGE, CONF_LATITUDE, - CONF_LOCATION, CONF_LONGITUDE, CONF_NAME, CONF_TIME_ZONE, @@ -36,6 +35,7 @@ DEFAULT_NAME, DOMAIN, ) +from .entity import JewishCalendarConfigEntry, JewishCalendarData from .sensor import INFO_SENSORS, TIME_SENSORS PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -120,7 +120,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: JewishCalendarConfigEntry +) -> bool: """Set up a configuration entry for Jewish calendar.""" language = config_entry.data.get(CONF_LANGUAGE, DEFAULT_LANGUAGE) diaspora = config_entry.data.get(CONF_DIASPORA, DEFAULT_DIASPORA) @@ -143,13 +145,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) ) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = { - CONF_LANGUAGE: language, - CONF_DIASPORA: diaspora, - CONF_LOCATION: location, - CONF_CANDLE_LIGHT_MINUTES: candle_lighting_offset, - CONF_HAVDALAH_OFFSET_MINUTES: havdalah_offset, - } + config_entry.runtime_data = JewishCalendarData( + language, + diaspora, + location, + candle_lighting_offset, + havdalah_offset, + ) # Update unique ID to be unrelated to user defined options old_prefix = get_unique_prefix( @@ -163,7 +165,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + async def update_listener( + hass: HomeAssistant, config_entry: JewishCalendarConfigEntry + ) -> None: # Trigger update of states for all platforms await hass.config_entries.async_reload(config_entry.entry_id) @@ -171,16 +175,11 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: JewishCalendarConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) @callback diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 060650ee25cc0..9fd1371f8a8fa 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -14,15 +14,13 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import event from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN -from .entity import JewishCalendarEntity +from .entity import JewishCalendarConfigEntry, JewishCalendarEntity @dataclass(frozen=True) @@ -63,14 +61,12 @@ class JewishCalendarBinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: JewishCalendarConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Jewish Calendar binary sensors.""" - entry = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - JewishCalendarBinarySensor(config_entry, entry, description) + JewishCalendarBinarySensor(config_entry, description) for description in BINARY_SENSORS ) diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index c11925df95488..ad5ac8e21374a 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -1,18 +1,27 @@ """Entity representing a Jewish Calendar sensor.""" -from typing import Any +from dataclasses import dataclass + +from hdate import Location from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LANGUAGE, CONF_LOCATION from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -from .const import ( - CONF_CANDLE_LIGHT_MINUTES, - CONF_DIASPORA, - CONF_HAVDALAH_OFFSET_MINUTES, - DOMAIN, -) +from .const import DOMAIN + +type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] + + +@dataclass +class JewishCalendarData: + """Jewish Calendar runtime dataclass.""" + + language: str + diaspora: bool + location: Location + candle_lighting_offset: int + havdalah_offset: int class JewishCalendarEntity(Entity): @@ -22,8 +31,7 @@ class JewishCalendarEntity(Entity): def __init__( self, - config_entry: ConfigEntry, - data: dict[str, Any], + config_entry: JewishCalendarConfigEntry, description: EntityDescription, ) -> None: """Initialize a Jewish Calendar entity.""" @@ -32,10 +40,10 @@ def __init__( self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, config_entry.entry_id)}, - name=config_entry.title, ) - self._location = data[CONF_LOCATION] - self._hebrew = data[CONF_LANGUAGE] == "hebrew" - self._candle_lighting_offset = data[CONF_CANDLE_LIGHT_MINUTES] - self._havdalah_offset = data[CONF_HAVDALAH_OFFSET_MINUTES] - self._diaspora = data[CONF_DIASPORA] + data = config_entry.runtime_data + self._location = data.location + self._hebrew = data.language == "hebrew" + self._candle_lighting_offset = data.candle_lighting_offset + self._havdalah_offset = data.havdalah_offset + self._diaspora = data.diaspora diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 87b4375b8b2ec..c32647af07c6d 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -14,15 +14,13 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import SUN_EVENT_SUNSET, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import get_astral_event_date import homeassistant.util.dt as dt_util -from .const import DOMAIN -from .entity import JewishCalendarEntity +from .entity import JewishCalendarConfigEntry, JewishCalendarEntity _LOGGER = logging.getLogger(__name__) @@ -169,17 +167,15 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: JewishCalendarConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Jewish calendar sensors .""" - entry = hass.data[DOMAIN][config_entry.entry_id] sensors = [ - JewishCalendarSensor(config_entry, entry, description) - for description in INFO_SENSORS + JewishCalendarSensor(config_entry, description) for description in INFO_SENSORS ] sensors.extend( - JewishCalendarTimeSensor(config_entry, entry, description) + JewishCalendarTimeSensor(config_entry, description) for description in TIME_SENSORS ) @@ -193,12 +189,11 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): def __init__( self, - config_entry: ConfigEntry, - data: dict[str, Any], + config_entry: JewishCalendarConfigEntry, description: SensorEntityDescription, ) -> None: """Initialize the Jewish calendar sensor.""" - super().__init__(config_entry, data, description) + super().__init__(config_entry, description) self._attrs: dict[str, str] = {} async def async_update(self) -> None: From 29fa7f827a62772ceaf01f8e2867f5658719f629 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Nov 2024 14:20:14 +0100 Subject: [PATCH 0206/1070] Fix audit-licenses check for multiple Python versions [ci] (#129951) --- .github/workflows/ci.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cae9795d71540..b4c1ad8a74d38 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -622,13 +622,13 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.3.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version: ${{ matrix.python-version }} check-latest: true - - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment + - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv uses: actions/cache/restore@v4.1.2 with: @@ -823,7 +823,7 @@ jobs: fail-fast: false matrix: python-version: ${{ fromJson(needs.info.outputs.python_versions) }} - name: Split tests for full run Python ${{ matrix.python-version }} + name: Split tests for full run steps: - name: Install additional OS dependencies run: | From 0430e6794e0fbe5d5b5757b88119b076f32340f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 6 Nov 2024 14:44:17 +0100 Subject: [PATCH 0207/1070] Delete binary door deprecation issue on unload at Home Connect (#129947) --- .../components/home_connect/binary_sensor.py | 12 +++++++++++- tests/components/home_connect/test_binary_sensor.py | 12 +++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 935aae5cbda93..f044a3fdfb414 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -13,7 +13,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from .api import HomeConnectDevice from .const import ( @@ -206,3 +210,9 @@ async def async_added_to_hass(self) -> None: "items": "\n".join([f"- {item}" for item in items]), }, ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + async_delete_issue( + self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" + ) diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 9b3e6e8bd026d..b564b003af62c 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -152,6 +152,7 @@ async def test_create_issue( """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "binary_sensor.washer_door" get_appliances.return_value = [appliance] + issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" assert await async_setup_component( hass, @@ -196,6 +197,11 @@ async def test_create_issue( assert scripts_with_entity(hass, entity_id)[0] == "script.test" assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue( - DOMAIN, f"deprecated_binary_common_door_sensor_{entity_id}" - ) + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 From 0ca4f3e1ba547e32841585faddd5ebf3831c080c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 Nov 2024 14:52:21 +0100 Subject: [PATCH 0208/1070] Bump go2rtc-client to 0.0.1b5 (#129952) --- homeassistant/components/go2rtc/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/go2rtc/test_init.py | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index e69140a51dbb3..4a4f5eb1c2fce 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", - "requirements": ["go2rtc-client==0.0.1b4"], + "requirements": ["go2rtc-client==0.0.1b5"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index aeaa4aa7dcda0..94e32d1ff18f2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ ciso8601==2.3.1 cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 -go2rtc-client==0.0.1b4 +go2rtc-client==0.0.1b5 ha-ffmpeg==3.2.1 habluetooth==3.6.0 hass-nabucasa==0.84.0 diff --git a/requirements_all.txt b/requirements_all.txt index 3ac09644b5d59..17994cd5c56e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -990,7 +990,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b4 +go2rtc-client==0.0.1b5 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8b4a50c2549b..8b272ad4cd368 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -840,7 +840,7 @@ gios==5.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b4 +go2rtc-client==0.0.1b5 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 61b0ca97406c7..18a46fdd4d1c2 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -244,7 +244,7 @@ async def test() -> None: # Stream exists but the source is different rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://different", [])]) + entity_id: Stream([Producer("rtsp://different")]) } receive_message_callback.reset_mock() @@ -258,7 +258,7 @@ async def test() -> None: # If the stream is already added, the stream should not be added again. rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://stream", [])]) + entity_id: Stream([Producer("rtsp://stream")]) } receive_message_callback.reset_mock() From 29ba14081693e025c8c30bbb771aab0a322852f9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 6 Nov 2024 14:53:59 +0100 Subject: [PATCH 0209/1070] Update frontend to 20241106.0 (#129953) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index ff399512c8b3b..2df14df4523bd 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241105.0"] + "requirements": ["home-assistant-frontend==20241106.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 94e32d1ff18f2..9a6aca1ce1015 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241105.0 +home-assistant-frontend==20241106.0 home-assistant-intents==2024.11.4 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 17994cd5c56e3..37bbdcb2ac397 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241105.0 +home-assistant-frontend==20241106.0 # homeassistant.components.conversation home-assistant-intents==2024.11.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b272ad4cd368..00b4c722c0b40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241105.0 +home-assistant-frontend==20241106.0 # homeassistant.components.conversation home-assistant-intents==2024.11.4 From 7ce74cb5ec9c21a26acb6d84dc6e4f113f00d4a0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 15:14:59 +0100 Subject: [PATCH 0210/1070] Use read-only options in onkyo options flow (#129929) --- homeassistant/components/onkyo/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 623fa9b2a9018..a8ced6fae640c 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -343,7 +343,9 @@ async def async_step_init( return self.async_create_entry( data={ - OPTION_VOLUME_RESOLUTION: self.options[OPTION_VOLUME_RESOLUTION], + OPTION_VOLUME_RESOLUTION: self.config_entry.options[ + OPTION_VOLUME_RESOLUTION + ], OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME], OPTION_INPUT_SOURCES: sources_store, } @@ -351,7 +353,7 @@ async def async_step_init( schema_dict: dict[Any, Selector] = {} - max_volume: float = self.options[OPTION_MAX_VOLUME] + max_volume: float = self.config_entry.options[OPTION_MAX_VOLUME] schema_dict[vol.Required(OPTION_MAX_VOLUME, default=max_volume)] = ( NumberSelector( NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX) From 51d694884830cf16d98a749fba8066ee7bed0435 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 15:15:35 +0100 Subject: [PATCH 0211/1070] Use read-only options in google cloud options flow (#129927) --- homeassistant/components/google_cloud/config_flow.py | 4 ++-- homeassistant/components/google_cloud/helpers.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_cloud/config_flow.py b/homeassistant/components/google_cloud/config_flow.py index 8b8fd751df9d6..fa6c952022b51 100644 --- a/homeassistant/components/google_cloud/config_flow.py +++ b/homeassistant/components/google_cloud/config_flow.py @@ -169,7 +169,7 @@ async def async_step_init( ) ), **tts_options_schema( - self.options, voices, from_config_flow=True + self.config_entry.options, voices, from_config_flow=True ).schema, vol.Optional( CONF_STT_MODEL, @@ -182,6 +182,6 @@ async def async_step_init( ), } ), - self.options, + self.config_entry.options, ), ) diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py index 3c6141561323f..f6e89fae7fa49 100644 --- a/homeassistant/components/google_cloud/helpers.py +++ b/homeassistant/components/google_cloud/helpers.py @@ -52,7 +52,7 @@ async def async_tts_voices( def tts_options_schema( - config_options: dict[str, Any], + config_options: Mapping[str, Any], voices: dict[str, list[str]], from_config_flow: bool = False, ) -> vol.Schema: From dea31e574461983e21eec6c8659dcaad6d8fe97f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 15:38:24 +0100 Subject: [PATCH 0212/1070] Ensure that all files in a folder are in the same test bucket (#129946) --- script/split_tests.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/script/split_tests.py b/script/split_tests.py index e124f72255255..c64de46a0682c 100755 --- a/script/split_tests.py +++ b/script/split_tests.py @@ -49,16 +49,27 @@ def split_tests(self, test_folder: TestFolder) -> None: test_folder.get_all_flatten(), reverse=True, key=lambda x: x.total_tests ) for tests in sorted_tests: - print(f"{tests.total_tests:>{digits}} tests in {tests.path}") if tests.added_to_bucket: # Already added to bucket continue + print(f"{tests.total_tests:>{digits}} tests in {tests.path}") smallest_bucket = min(self._buckets, key=lambda x: x.total_tests) + is_file = isinstance(tests, TestFile) if ( smallest_bucket.total_tests + tests.total_tests < self._tests_per_bucket - ) or isinstance(tests, TestFile): + ) or is_file: smallest_bucket.add(tests) + # Ensure all files from the same folder are in the same bucket + # to ensure that syrupy correctly identifies unused snapshots + if is_file: + for other_test in tests.parent.children.values(): + if other_test is tests or isinstance(other_test, TestFolder): + continue + print( + f"{other_test.total_tests:>{digits}} tests in {other_test.path} (same bucket)" + ) + smallest_bucket.add(other_test) # verify that all tests are added to a bucket if not test_folder.added_to_bucket: @@ -79,6 +90,7 @@ class TestFile: total_tests: int path: Path added_to_bucket: bool = field(default=False, init=False) + parent: TestFolder | None = field(default=None, init=False) def add_to_bucket(self) -> None: """Add test file to bucket.""" @@ -125,6 +137,7 @@ def __repr__(self) -> str: def add_test_file(self, file: TestFile) -> None: """Add test file to folder.""" path = file.path + file.parent = self relative_path = path.relative_to(self.path) if not relative_path.parts: raise ValueError("Path is not a child of this folder") From 9f427893b135079183ac02e47fbf6e7c31de61f6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Nov 2024 17:00:20 +0100 Subject: [PATCH 0213/1070] Remove deprecation issues for LCN once entities removed (#129955) --- homeassistant/components/lcn/binary_sensor.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 1e29a36da4ece..d0ce4815f1922 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -15,7 +15,11 @@ from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -115,6 +119,9 @@ async def async_will_remove_from_hass(self) -> None: await self.device_connection.cancel_status_request_handler( self.setpoint_variable ) + async_delete_issue( + self.hass, DOMAIN, f"deprecated_binary_sensor_{self.entity_id}" + ) def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" @@ -201,6 +208,9 @@ async def async_will_remove_from_hass(self) -> None: await super().async_will_remove_from_hass() if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.source) + async_delete_issue( + self.hass, DOMAIN, f"deprecated_binary_sensor_{self.entity_id}" + ) def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" From fe0a822721cd777e2dfb216185c6a7f2d126c8be Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Nov 2024 17:37:23 +0100 Subject: [PATCH 0214/1070] Call async_refresh_providers when camera entity feature changes (#129941) --- homeassistant/components/camera/__init__.py | 20 +++++++++ tests/components/camera/conftest.py | 2 +- tests/components/camera/test_init.py | 49 +++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 67c2432129f4f..6d65ea255c717 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -472,6 +472,8 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_state: None = None # State is determined by is_on _attr_supported_features: CameraEntityFeature = CameraEntityFeature(0) + __supports_stream: CameraEntityFeature | None = None + def __init__(self) -> None: """Initialize a camera.""" self._cache: dict[str, Any] = {} @@ -783,6 +785,9 @@ def async_update_token(self) -> None: async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_internal_added_to_hass() + self.__supports_stream = ( + self.supported_features_compat & CameraEntityFeature.STREAM + ) await self.async_refresh_providers(write_state=False) async def async_refresh_providers(self, *, write_state: bool = True) -> None: @@ -892,6 +897,21 @@ def camera_capabilities(self) -> CameraCapabilities: return CameraCapabilities(frontend_stream_types) + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine. + + Schedules async_refresh_providers if support of streams have changed. + """ + super().async_write_ha_state() + if self.__supports_stream != ( + supports_stream := self.supported_features_compat + & CameraEntityFeature.STREAM + ): + self.__supports_stream = supports_stream + self._invalidate_camera_capabilities_cache() + self.hass.async_create_task(self.async_refresh_providers()) + class CameraView(HomeAssistantView): """Base CameraView.""" diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index d6343959d411c..f0c418711c756 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -157,7 +157,7 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]: @pytest.fixture async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None: - """Initialize a test WebRTC cameras.""" + """Initialize test WebRTC cameras with native RTC support.""" # Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer # and native support is checked by verify the function "async_handle_web_rtc_offer" was diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 621ac8b7fb319..32024694b7ea0 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -1005,3 +1005,52 @@ async def test_webrtc_provider_not_added_for_native_webrtc( assert camera_obj._webrtc_provider is None assert camera_obj._supports_native_sync_webrtc is not expect_native_async_webrtc assert camera_obj._supports_native_async_webrtc is expect_native_async_webrtc + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +async def test_camera_capabilities_changing_non_native_support( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test WebRTC camera capabilities.""" + cam = get_camera_from_entity_id(hass, "camera.demo_camera") + assert ( + cam.supported_features + == camera.CameraEntityFeature.ON_OFF | camera.CameraEntityFeature.STREAM + ) + + await _test_capabilities( + hass, + hass_ws_client, + cam.entity_id, + {StreamType.HLS}, + {StreamType.HLS, StreamType.WEB_RTC}, + ) + + cam._attr_supported_features = camera.CameraEntityFeature(0) + cam.async_write_ha_state() + await hass.async_block_till_done() + + await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set()) + + +@pytest.mark.usefixtures("mock_test_webrtc_cameras") +@pytest.mark.parametrize(("entity_id"), ["camera.sync", "camera.async"]) +async def test_camera_capabilities_changing_native_support( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_id: str, +) -> None: + """Test WebRTC camera capabilities.""" + cam = get_camera_from_entity_id(hass, entity_id) + assert cam.supported_features == camera.CameraEntityFeature.STREAM + + await _test_capabilities( + hass, hass_ws_client, cam.entity_id, {StreamType.WEB_RTC}, {StreamType.WEB_RTC} + ) + + cam._attr_supported_features = camera.CameraEntityFeature(0) + cam.async_write_ha_state() + await hass.async_block_till_done() + + await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set()) From d4adb1f2980a2cfc04dccc222dad5f9885e2f912 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 Nov 2024 17:59:04 +0100 Subject: [PATCH 0215/1070] Bump go2rtc-client to 0.1.0 (#129965) --- homeassistant/components/go2rtc/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index 4a4f5eb1c2fce..ea9308e5e182a 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", - "requirements": ["go2rtc-client==0.0.1b5"], + "requirements": ["go2rtc-client==0.1.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9a6aca1ce1015..15ce798ab90ef 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ ciso8601==2.3.1 cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 -go2rtc-client==0.0.1b5 +go2rtc-client==0.1.0 ha-ffmpeg==3.2.1 habluetooth==3.6.0 hass-nabucasa==0.84.0 diff --git a/requirements_all.txt b/requirements_all.txt index 37bbdcb2ac397..ef79b8ad6b622 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -990,7 +990,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b5 +go2rtc-client==0.1.0 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00b4c722c0b40..b3c05f3a524bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -840,7 +840,7 @@ gios==5.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b5 +go2rtc-client==0.1.0 # homeassistant.components.goalzero goalzero==0.2.2 From b808c0c5eb35a29f65b4149653d037c5da6ec3f6 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 6 Nov 2024 19:15:25 +0100 Subject: [PATCH 0216/1070] Add state invitation to list access sensor in Bring integration (#129960) --- homeassistant/components/bring/icons.json | 3 +- homeassistant/components/bring/sensor.py | 2 +- homeassistant/components/bring/strings.json | 3 +- .../bring/fixtures/items_invitation.json | 44 +++++++++++++++++++ .../bring/fixtures/items_shared.json | 44 +++++++++++++++++++ .../bring/snapshots/test_sensor.ambr | 4 ++ tests/components/bring/test_sensor.py | 36 ++++++++++++++- 7 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 tests/components/bring/fixtures/items_invitation.json create mode 100644 tests/components/bring/fixtures/items_shared.json diff --git a/homeassistant/components/bring/icons.json b/homeassistant/components/bring/icons.json index 74c3b2e393b4b..c670ef87700d6 100644 --- a/homeassistant/components/bring/icons.json +++ b/homeassistant/components/bring/icons.json @@ -16,7 +16,8 @@ "list_access": { "default": "mdi:account-lock", "state": { - "shared": "mdi:account-group" + "shared": "mdi:account-group", + "invitation": "mdi:account-multiple-plus" } } }, diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 57ceb09953522..746ed397e1bcd 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -79,7 +79,7 @@ class BringSensor(StrEnum): translation_key=BringSensor.LIST_ACCESS, value_fn=lambda lst, _: lst["status"].lower(), entity_category=EntityCategory.DIAGNOSTIC, - options=["registered", "shared"], + options=["registered", "shared", "invitation"], device_class=SensorDeviceClass.ENUM, ), ) diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 61121cdca60c7..9a93881b5d297 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -66,7 +66,8 @@ "name": "List access", "state": { "registered": "Private", - "shared": "Shared" + "shared": "Shared", + "invitation": "Invitation pending" } } } diff --git a/tests/components/bring/fixtures/items_invitation.json b/tests/components/bring/fixtures/items_invitation.json new file mode 100644 index 0000000000000..82ef623e43953 --- /dev/null +++ b/tests/components/bring/fixtures/items_invitation.json @@ -0,0 +1,44 @@ +{ + "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "status": "INVITATION", + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] +} diff --git a/tests/components/bring/fixtures/items_shared.json b/tests/components/bring/fixtures/items_shared.json new file mode 100644 index 0000000000000..9ac999729d37b --- /dev/null +++ b/tests/components/bring/fixtures/items_shared.json @@ -0,0 +1,44 @@ +{ + "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "status": "SHARED", + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] +} diff --git a/tests/components/bring/snapshots/test_sensor.ambr b/tests/components/bring/snapshots/test_sensor.ambr index 513b4e6469eea..97e1d1b4bd9bb 100644 --- a/tests/components/bring/snapshots/test_sensor.ambr +++ b/tests/components/bring/snapshots/test_sensor.ambr @@ -55,6 +55,7 @@ 'options': list([ 'registered', 'shared', + 'invitation', ]), }), 'config_entry_id': , @@ -92,6 +93,7 @@ 'options': list([ 'registered', 'shared', + 'invitation', ]), }), 'context': , @@ -344,6 +346,7 @@ 'options': list([ 'registered', 'shared', + 'invitation', ]), }), 'config_entry_id': , @@ -381,6 +384,7 @@ 'options': list([ 'registered', 'shared', + 'invitation', ]), }), 'context': , diff --git a/tests/components/bring/test_sensor.py b/tests/components/bring/test_sensor.py index a36b01631652b..974818ccedf42 100644 --- a/tests/components/bring/test_sensor.py +++ b/tests/components/bring/test_sensor.py @@ -1,17 +1,18 @@ """Test for sensor platform of the Bring! integration.""" from collections.abc import Generator -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bring.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -42,3 +43,34 @@ async def test_setup( await snapshot_platform( hass, entity_registry, snapshot, bring_config_entry.entry_id ) + + +@pytest.mark.parametrize( + ("fixture", "entity_state"), + [ + ("items_invitation", "invitation"), + ("items_shared", "shared"), + ("items", "registered"), + ], +) +async def test_list_access_states( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + fixture: str, + entity_state: str, +) -> None: + """Snapshot test states of list access sensor.""" + + mock_bring_client.get_list.return_value = load_json_object_fixture( + f"{fixture}.json", DOMAIN + ) + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("sensor.einkauf_list_access")) + assert state.state == entity_state From 9a2a177b28aa27dc6679da3e2ca666aec395fedb Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 6 Nov 2024 21:46:08 +0000 Subject: [PATCH 0217/1070] Bump ring library ring-doorbell to 0.9.9 (#129966) --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 4e0514ba7f9f3..63c47cb2979b6 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], "quality_scale": "silver", - "requirements": ["ring-doorbell==0.9.8"] + "requirements": ["ring-doorbell==0.9.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index ef79b8ad6b622..dc7d3416aaa00 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2559,7 +2559,7 @@ rfk101py==0.0.1 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell==0.9.8 +ring-doorbell==0.9.9 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b3c05f3a524bb..f3a8d6c28746f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2047,7 +2047,7 @@ reolink-aio==0.10.4 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell==0.9.8 +ring-doorbell==0.9.9 # homeassistant.components.roku rokuecp==0.19.3 From 53c486ccd1b2dfe5a3f60dd222b257d4516a73bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Nov 2024 15:59:31 -0600 Subject: [PATCH 0218/1070] Bump aiohttp to 3.11.0b3 (#129363) --- homeassistant/components/websocket_api/http.py | 8 +------- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/components/generic/test_camera.py | 4 +++- tests/components/websocket_api/test_auth.py | 2 +- tests/components/websocket_api/test_http.py | 6 +++--- 7 files changed, 11 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 11aca19bab9ef..e7d57aebab611 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -330,13 +330,7 @@ async def async_handle(self) -> web.WebSocketResponse: if TYPE_CHECKING: assert writer is not None - # aiohttp 3.11.0 changed the method name from _send_frame to send_frame - if hasattr(writer, "send_frame"): - send_frame = writer.send_frame # pragma: no cover - else: - send_frame = writer._send_frame # noqa: SLF001 - - send_bytes_text = partial(send_frame, opcode=WSMsgType.TEXT) + send_bytes_text = partial(writer.send_frame, opcode=WSMsgType.TEXT) auth = AuthPhase( logger, hass, self._send_message, self._cancel, request, send_bytes_text ) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 15ce798ab90ef..49d2f4f01cfe7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.10.10 +aiohttp==3.11.0b3 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 4a2857b5065d4..282a4e51ff7c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.10.10", + "aiohttp==3.11.0b3", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index a5beecec8ff9a..ef0a423467aee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.10.10 +aiohttp==3.11.0b3 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 59ff513ccc9f6..d3ef0a39241cb 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -275,7 +275,9 @@ async def test_limit_refetch( with ( pytest.raises(aiohttp.ServerTimeoutError), - patch("asyncio.timeout", side_effect=TimeoutError()), + patch.object( + client.session._connector, "connect", side_effect=asyncio.TimeoutError + ), ): resp = await client.get("/api/camera_proxy/camera.config_test") diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 20a728cf3cd97..d55d2f97017f4 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -293,6 +293,6 @@ async def test_auth_sending_unknown_type_disconnects( auth_msg = await ws.receive_json() assert auth_msg["type"] == TYPE_AUTH_REQUIRED - await ws._writer._send_frame(b"1" * 130, 0x30) + await ws._writer.send_frame(b"1" * 130, 0x30) auth_msg = await ws.receive() assert auth_msg.type == WSMsgType.close diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 2530d8859421e..03e30c11ee9fb 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -5,7 +5,7 @@ from typing import Any, cast from unittest.mock import patch -from aiohttp import WSMsgType, WSServerHandshakeError, web +from aiohttp import ServerDisconnectedError, WSMsgType, web import pytest from homeassistant.components.websocket_api import ( @@ -374,7 +374,7 @@ async def test_prepare_fail_timeout( "homeassistant.components.websocket_api.http.web.WebSocketResponse.prepare", side_effect=(TimeoutError, web.WebSocketResponse.prepare), ), - pytest.raises(WSServerHandshakeError), + pytest.raises(ServerDisconnectedError), ): await hass_ws_client(hass) @@ -392,7 +392,7 @@ async def test_prepare_fail_connection_reset( "homeassistant.components.websocket_api.http.web.WebSocketResponse.prepare", side_effect=(ConnectionResetError, web.WebSocketResponse.prepare), ), - pytest.raises(WSServerHandshakeError), + pytest.raises(ServerDisconnectedError), ): await hass_ws_client(hass) From 03d5b18974f54f742fb0c1f9fa4970b7a7a23c0d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 23:28:01 +0100 Subject: [PATCH 0219/1070] Remove options property from OptionFlow (#129890) * Remove options property from OptionFlow * Update test_config_entries.py * Partial revert of "Remove deprecated property setters in option flows (#129773)" * Partial revert "Use new helper properties in crownstone options flow (#129774)" * Restore onewire init * Restore onvif * Restore roborock * Use deepcopy in onewire * Restore steam_online * Restore initial options property in OptionsFlowWithConfigEntry * re-add options property in SchemaOptionsFlowHandler * Restore test * Cleanup --- .../components/crownstone/config_flow.py | 5 +-- homeassistant/components/demo/config_flow.py | 6 +++- .../components/nmap_tracker/config_flow.py | 6 +++- .../components/onewire/config_flow.py | 7 +++- homeassistant/components/onvif/config_flow.py | 6 +++- homeassistant/components/plex/config_flow.py | 2 ++ .../components/roborock/config_flow.py | 7 +++- homeassistant/components/sia/config_flow.py | 5 +-- .../components/somfy_mylink/config_flow.py | 6 ++-- .../components/steam_online/config_flow.py | 6 +++- homeassistant/components/unifi/config_flow.py | 10 ++++-- homeassistant/config_entries.py | 28 +++------------ .../helpers/schema_config_entry_flow.py | 7 ++-- tests/test_config_entries.py | 35 ++----------------- 14 files changed, 63 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py index 4cfbb10a4bd13..bf6e92047147d 100644 --- a/homeassistant/components/crownstone/config_flow.py +++ b/homeassistant/components/crownstone/config_flow.py @@ -143,7 +143,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> CrownstoneOptionsFlowHandler: """Return the Crownstone options.""" - return CrownstoneOptionsFlowHandler() + return CrownstoneOptionsFlowHandler(config_entry) def __init__(self) -> None: """Initialize the flow.""" @@ -210,9 +210,10 @@ def async_create_new_entry(self) -> ConfigFlowResult: class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow): """Handle Crownstone options.""" - def __init__(self) -> None: + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Crownstone options.""" super().__init__(OPTIONS_FLOW, self.async_create_new_entry) + self.options = config_entry.options.copy() async def async_step_init( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py index 2b27689bdaf81..53c1678aa818a 100644 --- a/homeassistant/components/demo/config_flow.py +++ b/homeassistant/components/demo/config_flow.py @@ -35,7 +35,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler() + return OptionsFlowHandler(config_entry) async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Set the config entry up from yaml.""" @@ -45,6 +45,10 @@ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResu class OptionsFlowHandler(OptionsFlow): """Handle options.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.options = dict(config_entry.options) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 36645278baeb5..e05150995aa92 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -141,6 +141,10 @@ async def _async_build_schema_with_user_input( class OptionsFlowHandler(OptionsFlow): """Handle a option flow for homekit.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.options = dict(config_entry.options) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -211,4 +215,4 @@ def _async_is_unique_host_list(self, user_input: dict[str, Any]) -> bool: @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler() + return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 3ee0563410cbe..abb4c8849747f 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from copy import deepcopy from typing import Any import voluptuous as vol @@ -104,7 +105,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OnewireOptionsFlowHandler: """Get the options flow for this handler.""" - return OnewireOptionsFlowHandler() + return OnewireOptionsFlowHandler(config_entry) class OnewireOptionsFlowHandler(OptionsFlow): @@ -125,6 +126,10 @@ class OnewireOptionsFlowHandler(OptionsFlow): current_device: str """Friendly name of the currently selected device.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.options = deepcopy(dict(config_entry.options)) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 830f74b94e8bd..66e566af0bf90 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -109,7 +109,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OnvifOptionsFlowHandler: """Get the options flow for this handler.""" - return OnvifOptionsFlowHandler() + return OnvifOptionsFlowHandler(config_entry) def __init__(self) -> None: """Initialize the ONVIF config flow.""" @@ -389,6 +389,10 @@ async def async_setup_profiles( class OnvifOptionsFlowHandler(OptionsFlow): """Handle ONVIF options.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize ONVIF options flow.""" + self.options = dict(config_entry.options) + async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the ONVIF options.""" return await self.async_step_onvif_devices() diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 2206931080422..ae7cbb1257468 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from copy import deepcopy import logging from typing import TYPE_CHECKING, Any @@ -384,6 +385,7 @@ class PlexOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Plex options flow.""" + self.options = deepcopy(dict(config_entry.options)) self.server_id = config_entry.data[CONF_SERVER_IDENTIFIER] async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index e01bb904adfc4..200614b024e2d 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from copy import deepcopy import logging from typing import Any @@ -172,12 +173,16 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> RoborockOptionsFlowHandler: """Create the options flow.""" - return RoborockOptionsFlowHandler() + return RoborockOptionsFlowHandler(config_entry) class RoborockOptionsFlowHandler(OptionsFlow): """Handle an option flow for Roborock.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.options = deepcopy(dict(config_entry.options)) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/sia/config_flow.py b/homeassistant/components/sia/config_flow.py index c421151f7bb6a..a23978145e72d 100644 --- a/homeassistant/components/sia/config_flow.py +++ b/homeassistant/components/sia/config_flow.py @@ -103,7 +103,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> SIAOptionsFlowHandler: """Get the options flow for this handler.""" - return SIAOptionsFlowHandler() + return SIAOptionsFlowHandler(config_entry) def __init__(self) -> None: """Initialize the config flow.""" @@ -179,8 +179,9 @@ def _update_data(self, user_input: dict[str, Any]) -> None: class SIAOptionsFlowHandler(OptionsFlow): """Handle SIA options.""" - def __init__(self) -> None: + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize SIA options flow.""" + self.options = deepcopy(dict(config_entry.options)) self.hub: SIAHub | None = None self.accounts_todo: list = [] diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index f92c4909dd5d0..c2d851601750b 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from copy import deepcopy import logging from typing import Any @@ -121,14 +122,15 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler() + return OptionsFlowHandler(config_entry) class OptionsFlowHandler(OptionsFlow): """Handle a option flow for somfy_mylink.""" - def __init__(self) -> None: + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" + self.options = deepcopy(dict(config_entry.options)) self._target_id: str | None = None @callback diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index 605f27edb199b..69009fca8c48d 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -42,7 +42,7 @@ def async_get_options_flow( config_entry: SteamConfigEntry, ) -> SteamOptionsFlowHandler: """Get the options flow for this handler.""" - return SteamOptionsFlowHandler() + return SteamOptionsFlowHandler(config_entry) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -121,6 +121,10 @@ def _batch_ids(ids: list[str]) -> Iterator[list[str]]: class SteamOptionsFlowHandler(OptionsFlow): """Handle Steam client options.""" + def __init__(self, entry: SteamConfigEntry) -> None: + """Initialize options flow.""" + self.options = dict(entry.options) + async def async_step_init( self, user_input: dict[str, dict[str, str]] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 44969191fe67e..63c8533aa2ece 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -21,7 +21,6 @@ from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -38,6 +37,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac +from . import UnifiConfigEntry from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, @@ -78,10 +78,10 @@ class UnifiFlowHandler(ConfigFlow, domain=UNIFI_DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: UnifiConfigEntry, ) -> UnifiOptionsFlowHandler: """Get the options flow for this handler.""" - return UnifiOptionsFlowHandler() + return UnifiOptionsFlowHandler(config_entry) def __init__(self) -> None: """Initialize the UniFi Network flow.""" @@ -247,6 +247,10 @@ class UnifiOptionsFlowHandler(OptionsFlow): hub: UnifiHub + def __init__(self, config_entry: UnifiConfigEntry) -> None: + """Initialize UniFi Network options flow.""" + self.options = dict(config_entry.options) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 6a95707dcdaa9..a13225c4dfe6a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3060,7 +3060,6 @@ async def _async_setup_preview( class OptionsFlow(ConfigEntryBaseFlow): """Base class for config options flows.""" - _options: dict[str, Any] handler: str _config_entry: ConfigEntry @@ -3127,28 +3126,6 @@ def config_entry(self, value: ConfigEntry) -> None: ) self._config_entry = value - @property - def options(self) -> dict[str, Any]: - """Return a mutable copy of the config entry options. - - Please note that this is not available inside `__init__` method, and - can only be referenced after initialisation. - """ - if not hasattr(self, "_options"): - self._options = deepcopy(dict(self.config_entry.options)) - return self._options - - @options.setter - def options(self, value: dict[str, Any]) -> None: - """Set the options value.""" - report( - "sets option flow options explicitly, which is deprecated " - "and will stop working in 2025.12", - error_if_integration=False, - error_if_core=True, - ) - self._options = value - class OptionsFlowWithConfigEntry(OptionsFlow): """Base class for options flows with config entry and options.""" @@ -3164,6 +3141,11 @@ def __init__(self, config_entry: ConfigEntry) -> None: error_if_core=True, ) + @property + def options(self) -> dict[str, Any]: + """Return a mutable copy of the config entry options.""" + return self._options + class EntityRegistryDisabledHandler: """Handler when entities related to config entries updated disabled_by.""" diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index b956a58398a5e..af8c4c6402df5 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -421,8 +421,6 @@ def __init__( options, which is the union of stored options and user input from the options flow steps. """ - # Although `self.options` is most likely unused, it is safer to keep both - # `self.options` and `self._common_handler.options` referring to the same object self._options = copy.deepcopy(dict(config_entry.options)) self._common_handler = SchemaCommonFlowHandler(self, options_flow, self.options) self._async_options_flow_finished = async_options_flow_finished @@ -437,6 +435,11 @@ def __init__( if async_setup_preview: setattr(self, "async_setup_preview", async_setup_preview) + @property + def options(self) -> dict[str, Any]: + """Return a mutable copy of the config entry options.""" + return self._options + @staticmethod def _async_step( step_id: str, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 700840eb90edf..3e3f3b4c50444 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5066,31 +5066,6 @@ async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) assert entry.options == {"sub_dict": {"1": "one"}, "sub_list": ["one"]} -@pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) -async def test_options_flow_options_not_mutated(hass: HomeAssistant) -> None: - """Test that OptionsFlow doesn't mutate entry options.""" - entry = MockConfigEntry( - domain="test", - data={"first": True}, - options={"sub_dict": {"1": "one"}, "sub_list": ["one"]}, - ) - entry.add_to_hass(hass) - - options_flow = config_entries.OptionsFlow() - options_flow.handler = entry.entry_id - options_flow.hass = hass - - options_flow.options["sub_dict"]["2"] = "two" - options_flow._options["sub_list"].append("two") - - assert options_flow._options == { - "sub_dict": {"1": "one", "2": "two"}, - "sub_list": ["one", "two"], - } - assert entry.options == {"sub_dict": {"1": "one"}, "sub_list": ["one"]} - - async def test_initializing_flows_canceled_on_shutdown( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -7466,6 +7441,7 @@ async def async_step_init(self, user_input=None): @pytest.mark.usefixtures("mock_integration_frame") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_deprecated_config_entry_setter( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -7493,10 +7469,7 @@ class _OptionsFlow(config_entries.OptionsFlow): def __init__(self, entry) -> None: """Test initialisation.""" - with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): - self.config_entry = entry - with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): - self.options = entry.options + self.config_entry = entry async def async_step_init(self, user_input=None): """Test user step.""" @@ -7525,10 +7498,6 @@ async def async_step_init(self, user_input=None): "Detected that integration 'hue' sets option flow config_entry explicitly, " "which is deprecated and will stop working in 2025.12" in caplog.text ) - assert ( - "Detected that integration 'hue' sets option flow options explicitly, " - "which is deprecated and will stop working in 2025.12" in caplog.text - ) async def test_add_description_placeholder_automatically( From ed4f55406c47748b0989100ab1364a2640ad8e71 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 6 Nov 2024 19:33:51 -0500 Subject: [PATCH 0220/1070] Replace Supervisor resolution API calls with aiohasupervisor (#129599) * Replace Supervisor resolution API calls with aiohasupervisor * Use consistent types to avoid uuid issues * Fix mocking in http test * Changes from feedback * Put hass first * Fix typo --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/hassio/const.py | 14 - homeassistant/components/hassio/handler.py | 37 -- homeassistant/components/hassio/issues.py | 82 ++- homeassistant/components/hassio/repairs.py | 27 +- tests/components/conftest.py | 35 +- tests/components/hassio/test_binary_sensor.py | 14 +- tests/components/hassio/test_diagnostics.py | 14 +- tests/components/hassio/test_handler.py | 2 +- tests/components/hassio/test_init.py | 14 +- tests/components/hassio/test_issues.py | 372 +++++------ tests/components/hassio/test_repairs.py | 625 +++++++++--------- tests/components/hassio/test_sensor.py | 14 +- tests/components/hassio/test_update.py | 14 +- tests/components/hassio/test_websocket_api.py | 17 +- tests/components/http/test_ban.py | 13 +- tests/components/onboarding/test_views.py | 14 +- 16 files changed, 608 insertions(+), 700 deletions(-) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index b337017147b2e..82ce74832c25f 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -137,17 +137,3 @@ class SupervisorEntityModel(StrEnum): CORE = "Home Assistant Core" SUPERVIOSR = "Home Assistant Supervisor" HOST = "Home Assistant Host" - - -class SupervisorIssueContext(StrEnum): - """Context for supervisor issues.""" - - ADDON = "addon" - CORE = "core" - DNS_SERVER = "dns_server" - MOUNT = "mount" - OS = "os" - PLUGIN = "plugin" - SUPERVISOR = "supervisor" - STORE = "store" - SYSTEM = "system" diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index f69ee40293b87..58f2aa8c1444d 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -91,15 +91,6 @@ async def async_create_backup( return await hassio.send_command(command, payload=payload, timeout=None) -@bind_hass -@_api_bool -async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> dict: - """Apply a suggestion from supervisor's resolution center.""" - hassio: HassIO = hass.data[DOMAIN] - command = f"/resolution/suggestion/{suggestion_uuid}" - return await hassio.send_command(command, timeout=None) - - @api_data async def async_get_green_settings(hass: HomeAssistant) -> dict[str, bool]: """Return settings specific to Home Assistant Green.""" @@ -245,26 +236,6 @@ def get_ingress_panels(self) -> Coroutine: """ return self.send_command("/ingress/panels", method="get") - @api_data - def get_resolution_info(self) -> Coroutine: - """Return data for Supervisor resolution center. - - This method returns a coroutine. - """ - return self.send_command("/resolution/info", method="get") - - @api_data - def get_suggestions_for_issue( - self, issue_id: str - ) -> Coroutine[Any, Any, dict[str, Any]]: - """Return suggestions for issue from Supervisor resolution center. - - This method returns a coroutine. - """ - return self.send_command( - f"/resolution/issue/{issue_id}/suggestions", method="get" - ) - @_api_bool async def update_hass_api( self, http_config: dict[str, Any], refresh_token: RefreshToken @@ -304,14 +275,6 @@ def update_diagnostics(self, diagnostics: bool) -> Coroutine: "/supervisor/options", payload={"diagnostics": diagnostics} ) - @_api_bool - def apply_suggestion(self, suggestion_uuid: str) -> Coroutine: - """Apply a suggestion from supervisor's resolution center. - - This method returns a coroutine. - """ - return self.send_command(f"/resolution/suggestion/{suggestion_uuid}") - async def send_command( self, command: str, diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 944bc99a6b922..16697659077fd 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -7,6 +7,10 @@ from datetime import datetime import logging from typing import Any, NotRequired, TypedDict +from uuid import UUID + +from aiohasupervisor import SupervisorError +from aiohasupervisor.models import ContextType, Issue as SupervisorIssue from homeassistant.core import HassJob, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -20,12 +24,8 @@ from .const import ( ATTR_DATA, ATTR_HEALTHY, - ATTR_ISSUES, - ATTR_SUGGESTIONS, ATTR_SUPPORTED, - ATTR_UNHEALTHY, ATTR_UNHEALTHY_REASONS, - ATTR_UNSUPPORTED, ATTR_UNSUPPORTED_REASONS, ATTR_UPDATE_KEY, ATTR_WS_EVENT, @@ -45,10 +45,9 @@ PLACEHOLDER_KEY_REFERENCE, REQUEST_REFRESH_DELAY, UPDATE_KEY_SUPERVISOR, - SupervisorIssueContext, ) from .coordinator import get_addons_info -from .handler import HassIO, HassioAPIError +from .handler import HassIO, get_supervisor_client ISSUE_KEY_UNHEALTHY = "unhealthy" ISSUE_KEY_UNSUPPORTED = "unsupported" @@ -120,9 +119,9 @@ class SuggestionDataType(TypedDict): class Suggestion: """Suggestion from Supervisor which resolves an issue.""" - uuid: str + uuid: UUID type: str - context: SupervisorIssueContext + context: ContextType reference: str | None = None @property @@ -134,9 +133,9 @@ def key(self) -> str: def from_dict(cls, data: SuggestionDataType) -> Suggestion: """Convert from dictionary representation.""" return cls( - uuid=data["uuid"], + uuid=UUID(data["uuid"]), type=data["type"], - context=SupervisorIssueContext(data["context"]), + context=ContextType(data["context"]), reference=data["reference"], ) @@ -155,9 +154,9 @@ class IssueDataType(TypedDict): class Issue: """Issue from Supervisor.""" - uuid: str + uuid: UUID type: str - context: SupervisorIssueContext + context: ContextType reference: str | None = None suggestions: list[Suggestion] = field(default_factory=list, compare=False) @@ -171,9 +170,9 @@ def from_dict(cls, data: IssueDataType) -> Issue: """Convert from dictionary representation.""" suggestions: list[SuggestionDataType] = data.get("suggestions", []) return cls( - uuid=data["uuid"], + uuid=UUID(data["uuid"]), type=data["type"], - context=SupervisorIssueContext(data["context"]), + context=ContextType(data["context"]), reference=data["reference"], suggestions=[ Suggestion.from_dict(suggestion) for suggestion in suggestions @@ -190,7 +189,8 @@ def __init__(self, hass: HomeAssistant, client: HassIO) -> None: self._client = client self._unsupported_reasons: set[str] = set() self._unhealthy_reasons: set[str] = set() - self._issues: dict[str, Issue] = {} + self._issues: dict[UUID, Issue] = {} + self._supervisor_client = get_supervisor_client(hass) @property def unhealthy_reasons(self) -> set[str]: @@ -283,7 +283,7 @@ def add_issue(self, issue: Issue) -> None: async_create_issue( self._hass, DOMAIN, - issue.uuid, + issue.uuid.hex, is_fixable=bool(issue.suggestions), severity=IssueSeverity.WARNING, translation_key=issue.key, @@ -292,19 +292,37 @@ def add_issue(self, issue: Issue) -> None: self._issues[issue.uuid] = issue - async def add_issue_from_data(self, data: IssueDataType) -> None: + async def add_issue_from_data(self, data: SupervisorIssue) -> None: """Add issue from data to list after getting latest suggestions.""" try: - data["suggestions"] = ( - await self._client.get_suggestions_for_issue(data["uuid"]) - )[ATTR_SUGGESTIONS] - except HassioAPIError: + suggestions = ( + await self._supervisor_client.resolution.suggestions_for_issue( + data.uuid + ) + ) + except SupervisorError: _LOGGER.error( "Could not get suggestions for supervisor issue %s, skipping it", - data["uuid"], + data.uuid.hex, ) return - self.add_issue(Issue.from_dict(data)) + self.add_issue( + Issue( + uuid=data.uuid, + type=str(data.type), + context=data.context, + reference=data.reference, + suggestions=[ + Suggestion( + uuid=suggestion.uuid, + type=str(suggestion.type), + context=suggestion.context, + reference=suggestion.reference, + ) + for suggestion in suggestions + ], + ) + ) def remove_issue(self, issue: Issue) -> None: """Remove an issue from the list. Delete a repair if necessary.""" @@ -312,13 +330,13 @@ def remove_issue(self, issue: Issue) -> None: return if issue.key in ISSUE_KEYS_FOR_REPAIRS: - async_delete_issue(self._hass, DOMAIN, issue.uuid) + async_delete_issue(self._hass, DOMAIN, issue.uuid.hex) del self._issues[issue.uuid] def get_issue(self, issue_id: str) -> Issue | None: """Get issue from key.""" - return self._issues.get(issue_id) + return self._issues.get(UUID(issue_id)) async def setup(self) -> None: """Create supervisor events listener.""" @@ -331,8 +349,8 @@ async def setup(self) -> None: async def _update(self, _: datetime | None = None) -> None: """Update issues from Supervisor resolution center.""" try: - data = await self._client.get_resolution_info() - except HassioAPIError as err: + data = await self._supervisor_client.resolution.info() + except SupervisorError as err: _LOGGER.error("Failed to update supervisor issues: %r", err) async_call_later( self._hass, @@ -340,18 +358,16 @@ async def _update(self, _: datetime | None = None) -> None: HassJob(self._update, cancel_on_shutdown=True), ) return - self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) - self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) + self.unhealthy_reasons = set(data.unhealthy) + self.unsupported_reasons = set(data.unsupported) # Remove any cached issues that weren't returned - for issue_id in set(self._issues.keys()) - { - issue["uuid"] for issue in data[ATTR_ISSUES] - }: + for issue_id in set(self._issues) - {issue.uuid for issue in data.issues}: self.remove_issue(self._issues[issue_id]) # Add/update any issues that came back await asyncio.gather( - *[self.add_issue_from_data(issue) for issue in data[ATTR_ISSUES]] + *[self.add_issue_from_data(issue) for issue in data.issues] ) @callback diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 0fcd96ace383d..0e8122c08b995 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -6,6 +6,8 @@ from types import MethodType from typing import Any +from aiohasupervisor import SupervisorError +from aiohasupervisor.models import ContextType import voluptuous as vol from homeassistant.components.repairs import RepairsFlow @@ -20,9 +22,8 @@ PLACEHOLDER_KEY_ADDON, PLACEHOLDER_KEY_COMPONENTS, PLACEHOLDER_KEY_REFERENCE, - SupervisorIssueContext, ) -from .handler import async_apply_suggestion +from .handler import get_supervisor_client from .issues import Issue, Suggestion HELP_URLS = { @@ -51,9 +52,10 @@ class SupervisorIssueRepairFlow(RepairsFlow): _data: dict[str, Any] | None = None _issue: Issue | None = None - def __init__(self, issue_id: str) -> None: + def __init__(self, hass: HomeAssistant, issue_id: str) -> None: """Initialize repair flow.""" self._issue_id = issue_id + self._supervisor_client = get_supervisor_client(hass) super().__init__() @property @@ -124,9 +126,12 @@ async def _async_step_apply_suggestion( if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED: return self._async_form_for_suggestion(suggestion) - if await async_apply_suggestion(self.hass, suggestion.uuid): - return self.async_create_entry(data={}) - return self.async_abort(reason="apply_suggestion_fail") + try: + await self._supervisor_client.resolution.apply_suggestion(suggestion.uuid) + except SupervisorError: + return self.async_abort(reason="apply_suggestion_fail") + + return self.async_create_entry(data={}) @staticmethod def _async_step( @@ -163,9 +168,9 @@ def description_placeholders(self) -> dict[str, str] | None: if issue.key == self.issue.key or issue.type != self.issue.type: continue - if issue.context == SupervisorIssueContext.CORE: + if issue.context == ContextType.CORE: components.insert(0, "Home Assistant") - elif issue.context == SupervisorIssueContext.ADDON: + elif issue.context == ContextType.ADDON: components.append( next( ( @@ -210,11 +215,11 @@ async def async_create_fix_flow( supervisor_issues = get_issues_info(hass) issue = supervisor_issues and supervisor_issues.get_issue(issue_id) if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG: - return DockerConfigIssueRepairFlow(issue_id) + return DockerConfigIssueRepairFlow(hass, issue_id) if issue and issue.key in { ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_BOOT_FAIL, }: - return AddonIssueRepairFlow(issue_id) + return AddonIssueRepairFlow(hass, issue_id) - return SupervisorIssueRepairFlow(issue_id) + return SupervisorIssueRepairFlow(hass, issue_id) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index ba5d12afd0133..1ec656d44c5a2 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -8,7 +8,13 @@ from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch -from aiohasupervisor.models import Discovery, Repository, StoreAddon, StoreInfo +from aiohasupervisor.models import ( + Discovery, + Repository, + ResolutionInfo, + StoreAddon, + StoreInfo, +) import pytest from homeassistant.config_entries import ( @@ -473,6 +479,26 @@ def supervisor_is_connected_fixture(supervisor_client: AsyncMock) -> AsyncMock: return supervisor_client.supervisor.ping +@pytest.fixture(name="resolution_info") +def resolution_info_fixture(supervisor_client: AsyncMock) -> AsyncMock: + """Mock resolution info from supervisor.""" + supervisor_client.resolution.info.return_value = ResolutionInfo( + suggestions=[], + unsupported=[], + unhealthy=[], + issues=[], + checks=[], + ) + return supervisor_client.resolution.info + + +@pytest.fixture(name="resolution_suggestions_for_issue") +def resolution_suggestions_for_issue_fixture(supervisor_client: AsyncMock) -> AsyncMock: + """Mock suggestions by issue from supervisor resolution.""" + supervisor_client.resolution.suggestions_for_issue.return_value = [] + return supervisor_client.resolution.suggestions_for_issue + + @pytest.fixture(name="supervisor_client") def supervisor_client() -> Generator[AsyncMock]: """Mock the supervisor client.""" @@ -481,6 +507,7 @@ def supervisor_client() -> Generator[AsyncMock]: supervisor_client.discovery = AsyncMock() supervisor_client.homeassistant = AsyncMock() supervisor_client.os = AsyncMock() + supervisor_client.resolution = AsyncMock() supervisor_client.supervisor = AsyncMock() with ( patch( @@ -504,7 +531,11 @@ def supervisor_client() -> Generator[AsyncMock]: return_value=supervisor_client, ), patch( - "homeassistant.components.hassio.get_supervisor_client", + "homeassistant.components.hassio.issues.get_supervisor_client", + return_value=supervisor_client, + ), + patch( + "homeassistant.components.hassio.repairs.get_supervisor_client", return_value=supervisor_client, ), ): diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index c97be736248a6..9878dd67a2181 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -25,6 +25,7 @@ def mock_all( store_info: AsyncMock, addon_changelog: AsyncMock, addon_stats: AsyncMock, + resolution_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -140,19 +141,6 @@ def mock_all( aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/network/info", json={ diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index c238d9d2a15d3..c95cde67b8aac 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -24,6 +24,7 @@ def mock_all( store_info: AsyncMock, addon_stats: AsyncMock, addon_changelog: AsyncMock, + resolution_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -143,19 +144,6 @@ def mock_all( aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/network/info", json={ diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index e125e09ae7e0f..56f0dcb706c4d 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -208,7 +208,7 @@ async def test_api_ingress_panels( @pytest.mark.parametrize( ("api_call", "method", "payload"), [ - ("get_resolution_info", "GET", None), + ("get_network_info", "GET", None), ("update_diagnostics", "POST", True), ], ) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 23259543478b7..5c11370ae74cd 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -67,6 +67,7 @@ def mock_all( addon_info: AsyncMock, addon_stats: AsyncMock, addon_changelog: AsyncMock, + resolution_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -204,19 +205,6 @@ def mock_addon_info(slug: str): aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/network/info", json={ diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 1a3d3d83f95bc..7ce11a18fb509 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -4,11 +4,28 @@ from collections.abc import Generator from datetime import timedelta -from http import HTTPStatus import os from typing import Any -from unittest.mock import ANY, patch +from unittest.mock import ANY, AsyncMock, patch +from uuid import UUID, uuid4 +from aiohasupervisor import ( + SupervisorBadRequestError, + SupervisorError, + SupervisorTimeoutError, +) +from aiohasupervisor.models import ( + Check, + CheckType, + ContextType, + Issue, + IssueType, + ResolutionInfo, + Suggestion, + SuggestionType, + UnhealthyReason, + UnsupportedReason, +) from freezegun.api import FrozenDateTimeFactory import pytest @@ -18,7 +35,6 @@ from .test_init import MOCK_ENVIRON -from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse from tests.typing import WebSocketGenerator @@ -36,49 +52,41 @@ def fixture_supervisor_environ() -> Generator[None]: def mock_resolution_info( - aioclient_mock: AiohttpClientMocker, - unsupported: list[str] | None = None, - unhealthy: list[str] | None = None, - issues: list[dict[str, str]] | None = None, - suggestion_result: str = "ok", + supervisor_client: AsyncMock, + unsupported: list[UnsupportedReason] | None = None, + unhealthy: list[UnhealthyReason] | None = None, + issues: list[Issue] | None = None, + suggestions_by_issue: dict[UUID, list[Suggestion]] | None = None, + suggestion_result: SupervisorError | None = None, ) -> None: """Mock resolution/info endpoint with unsupported/unhealthy reasons and/or issues.""" - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": unsupported or [], - "unhealthy": unhealthy or [], - "suggestions": [], - "issues": [ - {k: v for k, v in issue.items() if k != "suggestions"} - for issue in issues - ] - if issues - else [], - "checks": [ - {"enabled": True, "slug": "supervisor_trust"}, - {"enabled": True, "slug": "free_space"}, - ], - }, - }, + supervisor_client.resolution.info.return_value = ResolutionInfo( + unsupported=unsupported or [], + unhealthy=unhealthy or [], + issues=issues or [], + suggestions=[ + suggestion + for issue_list in suggestions_by_issue.values() + for suggestion in issue_list + ] + if suggestions_by_issue + else [], + checks=[ + Check(enabled=True, slug=CheckType.SUPERVISOR_TRUST), + Check(enabled=True, slug=CheckType.FREE_SPACE), + ], ) - if issues: - suggestions_by_issue = { - issue["uuid"]: issue.get("suggestions", []) for issue in issues - } - for issue_uuid, suggestions in suggestions_by_issue.items(): - aioclient_mock.get( - f"http://127.0.0.1/resolution/issue/{issue_uuid}/suggestions", - json={"result": "ok", "data": {"suggestions": suggestions}}, - ) - for suggestion in suggestions: - aioclient_mock.post( - f"http://127.0.0.1/resolution/suggestion/{suggestion['uuid']}", - json={"result": suggestion_result}, - ) + if suggestions_by_issue: + + async def mock_suggestions_for_issue(uuid: UUID) -> list[Suggestion]: + """Mock of suggestions for issue api.""" + return suggestions_by_issue.get(uuid, []) + + supervisor_client.resolution.suggestions_for_issue.side_effect = ( + mock_suggestions_for_issue + ) + supervisor_client.resolution.apply_suggestion.side_effect = suggestion_result def assert_repair_in_list( @@ -134,11 +142,13 @@ def assert_issue_repair_in_list( @pytest.mark.usefixtures("all_setup_requests") async def test_unhealthy_issues( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test issues added for unhealthy systems.""" - mock_resolution_info(aioclient_mock, unhealthy=["docker", "setup"]) + mock_resolution_info( + supervisor_client, unhealthy=[UnhealthyReason.DOCKER, UnhealthyReason.SETUP] + ) result = await async_setup_component(hass, "hassio", {}) assert result @@ -156,11 +166,14 @@ async def test_unhealthy_issues( @pytest.mark.usefixtures("all_setup_requests") async def test_unsupported_issues( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test issues added for unsupported systems.""" - mock_resolution_info(aioclient_mock, unsupported=["content_trust", "os"]) + mock_resolution_info( + supervisor_client, + unsupported=[UnsupportedReason.CONTENT_TRUST, UnsupportedReason.OS], + ) result = await async_setup_component(hass, "hassio", {}) assert result @@ -180,11 +193,11 @@ async def test_unsupported_issues( @pytest.mark.usefixtures("all_setup_requests") async def test_unhealthy_issues_add_remove( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test unhealthy issues added and removed from dispatches.""" - mock_resolution_info(aioclient_mock) + mock_resolution_info(supervisor_client) result = await async_setup_component(hass, "hassio", {}) assert result @@ -237,11 +250,11 @@ async def test_unhealthy_issues_add_remove( @pytest.mark.usefixtures("all_setup_requests") async def test_unsupported_issues_add_remove( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test unsupported issues added and removed from dispatches.""" - mock_resolution_info(aioclient_mock) + mock_resolution_info(supervisor_client) result = await async_setup_component(hass, "hassio", {}) assert result @@ -294,21 +307,21 @@ async def test_unsupported_issues_add_remove( @pytest.mark.usefixtures("all_setup_requests") async def test_reset_issues_supervisor_restart( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """All issues reset on supervisor restart.""" mock_resolution_info( - aioclient_mock, - unsupported=["os"], - unhealthy=["docker"], + supervisor_client, + unsupported=[UnsupportedReason.OS], + unhealthy=[UnhealthyReason.DOCKER], issues=[ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": None, - } + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference=None, + uuid=(uuid := uuid4()), + ) ], ) @@ -325,15 +338,14 @@ async def test_reset_issues_supervisor_restart( assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") assert_issue_repair_in_list( msg["result"]["issues"], - uuid="1234", + uuid=uuid.hex, context="system", type_="reboot_required", fixable=False, reference=None, ) - aioclient_mock.clear_requests() - mock_resolution_info(aioclient_mock) + mock_resolution_info(supervisor_client) await client.send_json( { "id": 2, @@ -358,11 +370,15 @@ async def test_reset_issues_supervisor_restart( @pytest.mark.usefixtures("all_setup_requests") async def test_reasons_added_and_removed( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test an unsupported/unhealthy reasons being added and removed at same time.""" - mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) + mock_resolution_info( + supervisor_client, + unsupported=[UnsupportedReason.OS], + unhealthy=[UnhealthyReason.DOCKER], + ) result = await async_setup_component(hass, "hassio", {}) assert result @@ -376,9 +392,10 @@ async def test_reasons_added_and_removed( assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="docker") assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") - aioclient_mock.clear_requests() mock_resolution_info( - aioclient_mock, unsupported=["content_trust"], unhealthy=["setup"] + supervisor_client, + unsupported=[UnsupportedReason.CONTENT_TRUST], + unhealthy=[UnhealthyReason.SETUP], ) await client.send_json( { @@ -408,12 +425,14 @@ async def test_reasons_added_and_removed( @pytest.mark.usefixtures("all_setup_requests") async def test_ignored_unsupported_skipped( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Unsupported reasons which have an identical unhealthy reason are ignored.""" mock_resolution_info( - aioclient_mock, unsupported=["privileged"], unhealthy=["privileged"] + supervisor_client, + unsupported=[UnsupportedReason.PRIVILEGED], + unhealthy=[UnhealthyReason.PRIVILEGED], ) result = await async_setup_component(hass, "hassio", {}) @@ -431,12 +450,14 @@ async def test_ignored_unsupported_skipped( @pytest.mark.usefixtures("all_setup_requests") async def test_new_unsupported_unhealthy_reason( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """New unsupported/unhealthy reasons result in a generic repair until next core update.""" mock_resolution_info( - aioclient_mock, unsupported=["fake_unsupported"], unhealthy=["fake_unhealthy"] + supervisor_client, + unsupported=["fake_unsupported"], + unhealthy=["fake_unhealthy"], ) result = await async_setup_component(hass, "hassio", {}) @@ -481,40 +502,43 @@ async def test_new_unsupported_unhealthy_reason( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test repairs added for supervisor issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": None, - }, - { - "uuid": "1235", - "type": "multiple_data_disks", - "context": "system", - "reference": "/dev/sda1", - "suggestions": [ - { - "uuid": "1236", - "type": "rename_data_disk", - "context": "system", - "reference": "/dev/sda1", - } - ], - }, - { - "uuid": "1237", - "type": "should_not_be_repair", - "context": "os", - "reference": None, - }, + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference=None, + uuid=(uuid_issue1 := uuid4()), + ), + Issue( + type=IssueType.MULTIPLE_DATA_DISKS, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=(uuid_issue2 := uuid4()), + ), + Issue( + type="should_not_be_repair", + context=ContextType.OS, + reference=None, + uuid=uuid4(), + ), ], + suggestions_by_issue={ + uuid_issue2: [ + Suggestion( + type=SuggestionType.RENAME_DATA_DISK, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=uuid4(), + auto=False, + ) + ] + }, ) result = await async_setup_component(hass, "hassio", {}) @@ -528,7 +552,7 @@ async def test_supervisor_issues( assert len(msg["result"]["issues"]) == 2 assert_issue_repair_in_list( msg["result"]["issues"], - uuid="1234", + uuid=uuid_issue1.hex, context="system", type_="reboot_required", fixable=False, @@ -536,7 +560,7 @@ async def test_supervisor_issues( ) assert_issue_repair_in_list( msg["result"]["issues"], - uuid="1235", + uuid=uuid_issue2.hex, context="system", type_="multiple_data_disks", fixable=True, @@ -547,61 +571,33 @@ async def test_supervisor_issues( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues_initial_failure( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + resolution_info: AsyncMock, + resolution_suggestions_for_issue: AsyncMock, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test issues manager retries after initial update failure.""" - responses = [ - AiohttpClientMockResponse( - method="get", - url="http://127.0.0.1/resolution/info", - status=HTTPStatus.BAD_REQUEST, - json={ - "result": "error", - "message": "System is not ready with state: setup", - }, - ), - AiohttpClientMockResponse( - method="get", - url="http://127.0.0.1/resolution/info", - status=HTTPStatus.OK, - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": None, - }, - ], - "checks": [ - {"enabled": True, "slug": "supervisor_trust"}, - {"enabled": True, "slug": "free_space"}, - ], - }, - }, + resolution_info.side_effect = [ + SupervisorBadRequestError("System is not ready with state: setup"), + ResolutionInfo( + unsupported=[], + unhealthy=[], + suggestions=[], + issues=[ + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + ) + ], + checks=[ + Check(enabled=True, slug=CheckType.SUPERVISOR_TRUST), + Check(enabled=True, slug=CheckType.FREE_SPACE), + ], ), ] - async def mock_responses(*args): - nonlocal responses - return responses.pop(0) - - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - side_effect=mock_responses, - ) - aioclient_mock.get( - "http://127.0.0.1/resolution/issue/1234/suggestions", - json={"result": "ok", "data": {"suggestions": []}}, - ) - with patch("homeassistant.components.hassio.issues.REQUEST_REFRESH_DELAY", new=0.1): result = await async_setup_component(hass, "hassio", {}) await hass.async_block_till_done() @@ -625,11 +621,11 @@ async def mock_responses(*args): @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues_add_remove( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test supervisor issues added and removed from dispatches.""" - mock_resolution_info(aioclient_mock) + mock_resolution_info(supervisor_client) result = await async_setup_component(hass, "hassio", {}) assert result @@ -643,7 +639,7 @@ async def test_supervisor_issues_add_remove( "data": { "event": "issue_changed", "data": { - "uuid": "1234", + "uuid": (issue_uuid := uuid4().hex), "type": "reboot_required", "context": "system", "reference": None, @@ -661,7 +657,7 @@ async def test_supervisor_issues_add_remove( assert len(msg["result"]["issues"]) == 1 assert_issue_repair_in_list( msg["result"]["issues"], - uuid="1234", + uuid=issue_uuid, context="system", type_="reboot_required", fixable=False, @@ -675,13 +671,13 @@ async def test_supervisor_issues_add_remove( "data": { "event": "issue_changed", "data": { - "uuid": "1234", + "uuid": issue_uuid, "type": "reboot_required", "context": "system", "reference": None, "suggestions": [ { - "uuid": "1235", + "uuid": uuid4().hex, "type": "execute_reboot", "context": "system", "reference": None, @@ -701,7 +697,7 @@ async def test_supervisor_issues_add_remove( assert len(msg["result"]["issues"]) == 1 assert_issue_repair_in_list( msg["result"]["issues"], - uuid="1234", + uuid=issue_uuid, context="system", type_="reboot_required", fixable=True, @@ -715,7 +711,7 @@ async def test_supervisor_issues_add_remove( "data": { "event": "issue_removed", "data": { - "uuid": "1234", + "uuid": issue_uuid, "type": "reboot_required", "context": "system", "reference": None, @@ -736,37 +732,23 @@ async def test_supervisor_issues_add_remove( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues_suggestions_fail( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, + resolution_suggestions_for_issue: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test failing to get suggestions for issue skips it.""" - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": None, - } - ], - "checks": [ - {"enabled": True, "slug": "supervisor_trust"}, - {"enabled": True, "slug": "free_space"}, - ], - }, - }, - ) - aioclient_mock.get( - "http://127.0.0.1/resolution/issue/1234/suggestions", - exc=TimeoutError(), + mock_resolution_info( + supervisor_client, + issues=[ + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + ) + ], ) + resolution_suggestions_for_issue.side_effect = SupervisorTimeoutError result = await async_setup_component(hass, "hassio", {}) assert result @@ -782,11 +764,11 @@ async def test_supervisor_issues_suggestions_fail( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_remove_missing_issue_without_error( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test HA skips message to remove issue that it didn't know about (sync issue).""" - mock_resolution_info(aioclient_mock) + mock_resolution_info(supervisor_client) result = await async_setup_component(hass, "hassio", {}) assert result @@ -816,16 +798,12 @@ async def test_supervisor_remove_missing_issue_without_error( @pytest.mark.usefixtures("all_setup_requests") async def test_system_is_not_ready( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + resolution_info: AsyncMock, caplog: pytest.LogCaptureFixture, ) -> None: """Ensure hassio starts despite error.""" - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "", - "message": "System is not ready with state: setup", - }, + resolution_info.side_effect = SupervisorBadRequestError( + "System is not ready with state: setup" ) assert await async_setup_component(hass, "hassio", {}) @@ -838,11 +816,11 @@ async def test_system_is_not_ready( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues_detached_addon_missing( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test supervisor issue for detached addon due to missing repository.""" - mock_resolution_info(aioclient_mock) + mock_resolution_info(supervisor_client) result = await async_setup_component(hass, "hassio", {}) assert result @@ -856,7 +834,7 @@ async def test_supervisor_issues_detached_addon_missing( "data": { "event": "issue_changed", "data": { - "uuid": "1234", + "uuid": (issue_uuid := uuid4().hex), "type": "detached_addon_missing", "context": "addon", "reference": "test", @@ -874,7 +852,7 @@ async def test_supervisor_issues_detached_addon_missing( assert len(msg["result"]["issues"]) == 1 assert_issue_repair_in_list( msg["result"]["issues"], - uuid="1234", + uuid=issue_uuid, context="addon", type_="detached_addon_missing", fixable=False, diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index f3ccb5948f132..f8cac4e1a976c 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -3,8 +3,17 @@ from collections.abc import Generator from http import HTTPStatus import os -from unittest.mock import patch - +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +from aiohasupervisor import SupervisorError +from aiohasupervisor.models import ( + ContextType, + Issue, + IssueType, + Suggestion, + SuggestionType, +) import pytest from homeassistant.core import HomeAssistant @@ -14,7 +23,6 @@ from .test_init import MOCK_ENVIRON from .test_issues import mock_resolution_info -from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -28,34 +36,39 @@ def fixture_supervisor_environ() -> Generator[None]: @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_repair_flow( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for supervisor issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "multiple_data_disks", - "context": "system", - "reference": "/dev/sda1", - "suggestions": [ - { - "uuid": "1235", - "type": "rename_data_disk", - "context": "system", - "reference": "/dev/sda1", - } - ], - }, + Issue( + type=IssueType.MULTIPLE_DATA_DISKS, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.RENAME_DATA_DISK, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=(sugg_uuid := uuid4()), + auto=False, + ) + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -95,52 +108,53 @@ async def test_supervisor_issue_repair_flow( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_repair_flow_with_multiple_suggestions( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for supervisor issue with multiple suggestions.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": "test", - "suggestions": [ - { - "uuid": "1235", - "type": "execute_reboot", - "context": "system", - "reference": "test", - }, - { - "uuid": "1236", - "type": "test_type", - "context": "system", - "reference": "test", - }, - ], - }, + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference="test", + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REBOOT, + context=ContextType.SYSTEM, + reference="test", + uuid=uuid4(), + auto=False, + ), + Suggestion( + type="test_type", + context=ContextType.SYSTEM, + reference="test", + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -189,52 +203,53 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1236" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confirmation( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for supervisor issue with multiple suggestions and choice requires confirmation.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": None, - "suggestions": [ - { - "uuid": "1235", - "type": "execute_reboot", - "context": "system", - "reference": None, - }, - { - "uuid": "1236", - "type": "test_type", - "context": "system", - "reference": None, - }, - ], - }, + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference=None, + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REBOOT, + context=ContextType.SYSTEM, + reference=None, + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + Suggestion( + type="test_type", + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -302,46 +317,46 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_repair_flow_skip_confirmation( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test confirmation skipped for fix flow for supervisor issue with one suggestion.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": None, - "suggestions": [ - { - "uuid": "1235", - "type": "execute_reboot", - "context": "system", - "reference": None, - } - ], - }, + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference=None, + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REBOOT, + context=ContextType.SYSTEM, + reference=None, + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -381,53 +396,54 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.usefixtures("all_setup_requests") async def test_mount_failed_repair_flow_error( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test repair flow fails when repair fails to apply.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "mount_failed", - "context": "mount", - "reference": "backup_share", - "suggestions": [ - { - "uuid": "1235", - "type": "execute_reload", - "context": "mount", - "reference": "backup_share", - }, - { - "uuid": "1236", - "type": "execute_remove", - "context": "mount", - "reference": "backup_share", - }, - ], - }, + Issue( + type=IssueType.MOUNT_FAILED, + context=ContextType.MOUNT, + reference="backup_share", + uuid=(issue_uuid := uuid4()), + ), ], - suggestion_result=False, + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_RELOAD, + context=ContextType.MOUNT, + reference="backup_share", + uuid=uuid4(), + auto=False, + ), + Suggestion( + type=SuggestionType.EXECUTE_REMOVE, + context=ContextType.MOUNT, + reference="backup_share", + uuid=uuid4(), + auto=False, + ), + ] + }, + suggestion_result=SupervisorError("boom"), ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -459,46 +475,52 @@ async def test_mount_failed_repair_flow_error( "description_placeholders": None, } - assert issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) @pytest.mark.usefixtures("all_setup_requests") async def test_mount_failed_repair_flow( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test repair flow for mount_failed issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "mount_failed", - "context": "mount", - "reference": "backup_share", - "suggestions": [ - { - "uuid": "1235", - "type": "execute_reload", - "context": "mount", - "reference": "backup_share", - }, - { - "uuid": "1236", - "type": "execute_remove", - "context": "mount", - "reference": "backup_share", - }, - ], - }, + Issue( + type=IssueType.MOUNT_FAILED, + context=ContextType.MOUNT, + reference="backup_share", + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_RELOAD, + context=ContextType.MOUNT, + reference="backup_share", + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + Suggestion( + type=SuggestionType.EXECUTE_REMOVE, + context=ContextType.MOUNT, + reference="backup_share", + uuid=uuid4(), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -551,13 +573,8 @@ async def test_mount_failed_repair_flow( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.parametrize( @@ -566,62 +583,69 @@ async def test_mount_failed_repair_flow( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_docker_config_repair_flow( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for supervisor issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "docker_config", - "context": "system", - "reference": None, - "suggestions": [ - { - "uuid": "1235", - "type": "execute_rebuild", - "context": "system", - "reference": None, - } - ], - }, - { - "uuid": "1236", - "type": "docker_config", - "context": "core", - "reference": None, - "suggestions": [ - { - "uuid": "1237", - "type": "execute_rebuild", - "context": "core", - "reference": None, - } - ], - }, - { - "uuid": "1238", - "type": "docker_config", - "context": "addon", - "reference": "test", - "suggestions": [ - { - "uuid": "1239", - "type": "execute_rebuild", - "context": "addon", - "reference": "test", - } - ], - }, + Issue( + type=IssueType.DOCKER_CONFIG, + context=ContextType.SYSTEM, + reference=None, + uuid=(issue1_uuid := uuid4()), + ), + Issue( + type=IssueType.DOCKER_CONFIG, + context=ContextType.CORE, + reference=None, + uuid=(issue2_uuid := uuid4()), + ), + Issue( + type=IssueType.DOCKER_CONFIG, + context=ContextType.ADDON, + reference="test", + uuid=(issue3_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue1_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REBUILD, + context=ContextType.SYSTEM, + reference=None, + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + ], + issue2_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REBUILD, + context=ContextType.CORE, + reference=None, + uuid=uuid4(), + auto=False, + ), + ], + issue3_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REBUILD, + context=ContextType.ADDON, + reference="test", + uuid=uuid4(), + auto=False, + ), + ], + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue1_uuid.hex + ) assert repair_issue client = await hass_client() @@ -661,52 +685,53 @@ async def test_supervisor_issue_docker_config_repair_flow( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue1_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_repair_flow_multiple_data_disks( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for multiple data disks supervisor issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "multiple_data_disks", - "context": "system", - "reference": "/dev/sda1", - "suggestions": [ - { - "uuid": "1235", - "type": "rename_data_disk", - "context": "system", - "reference": "/dev/sda1", - }, - { - "uuid": "1236", - "type": "adopt_data_disk", - "context": "system", - "reference": "/dev/sda1", - }, - ], - }, + Issue( + type=IssueType.MULTIPLE_DATA_DISKS, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.RENAME_DATA_DISK, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=uuid4(), + auto=False, + ), + Suggestion( + type=SuggestionType.ADOPT_DATA_DISK, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -774,13 +799,8 @@ async def test_supervisor_issue_repair_flow_multiple_data_disks( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1236" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.parametrize( @@ -789,34 +809,39 @@ async def test_supervisor_issue_repair_flow_multiple_data_disks( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_detached_addon_removed( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for supervisor issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "detached_addon_removed", - "context": "addon", - "reference": "test", - "suggestions": [ - { - "uuid": "1235", - "type": "execute_remove", - "context": "addon", - "reference": "test", - } - ], - }, + Issue( + type=IssueType.DETACHED_ADDON_REMOVED, + context=ContextType.ADDON, + reference="test", + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REMOVE, + context=ContextType.ADDON, + reference="test", + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -861,13 +886,8 @@ async def test_supervisor_issue_detached_addon_removed( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.parametrize( @@ -876,40 +896,46 @@ async def test_supervisor_issue_detached_addon_removed( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_addon_boot_fail( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for supervisor issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "boot_fail", - "context": "addon", - "reference": "test", - "suggestions": [ - { - "uuid": "1235", - "type": "execute_start", - "context": "addon", - "reference": "test", - }, - { - "uuid": "1236", - "type": "disable_boot", - "context": "addon", - "reference": "test", - }, - ], - }, + Issue( + type="boot_fail", + context=ContextType.ADDON, + reference="test", + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type="execute_start", + context=ContextType.ADDON, + reference="test", + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + Suggestion( + type="disable_boot", + context=ContextType.ADDON, + reference="test", + uuid=uuid4(), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -962,10 +988,5 @@ async def test_supervisor_issue_addon_boot_fail( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 1b58534d52ff1..7160a2cbf1601 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -33,6 +33,7 @@ def mock_all( store_info: AsyncMock, addon_stats: AsyncMock, addon_changelog: AsyncMock, + resolution_info: AsyncMock, ) -> None: """Mock all setup requests.""" _install_default_mocks(aioclient_mock) @@ -146,19 +147,6 @@ def _install_default_mocks(aioclient_mock: AiohttpClientMocker): aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/network/info", json={ diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 0d15eac48c57f..c1775d6e0b409 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -29,6 +29,7 @@ def mock_all( store_info: AsyncMock, addon_stats: AsyncMock, addon_changelog: AsyncMock, + resolution_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -149,19 +150,6 @@ def mock_all( aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/network/info", json={ diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 1023baa89df58..21e6b03678b18 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -26,7 +26,9 @@ @pytest.fixture(autouse=True) def mock_all( - aioclient_mock: AiohttpClientMocker, supervisor_is_connected: AsyncMock + aioclient_mock: AiohttpClientMocker, + supervisor_is_connected: AsyncMock, + resolution_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -67,19 +69,6 @@ def mock_all( aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) @pytest.mark.usefixtures("hassio_env") diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 7ffd026315746..59011de0cfd8e 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -197,6 +197,7 @@ async def test_access_from_supervisor_ip( hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, hassio_env, + resolution_info: AsyncMock, ) -> None: """Test accessing to server from supervisor IP.""" app = web.Application() @@ -218,17 +219,7 @@ async def unauth_handler(request): manager = app[KEY_BAN_MANAGER] - with patch( - "homeassistant.components.hassio.HassIO.get_resolution_info", - return_value={ - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - ): - assert await async_setup_component(hass, "hassio", {"hassio": {}}) + assert await async_setup_component(hass, "hassio", {"hassio": {}}) m_open = mock_open() diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 6df3951249baa..35f6b7d739c22 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -72,23 +72,11 @@ async def mock_supervisor_fixture( aioclient_mock: AiohttpClientMocker, store_info: AsyncMock, supervisor_is_connected: AsyncMock, + resolution_info: AsyncMock, ) -> AsyncGenerator[None]: """Mock supervisor.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/network/info", json={ From bc964ce7f03a73e1e30276a2dfce02a6ec1f7ff0 Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Thu, 7 Nov 2024 02:14:54 -0500 Subject: [PATCH 0221/1070] Update sense energy library to 0.13.3 (#129998) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index f1a01f9d7aac3..d4889c0c5f5bd 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.13.2"] + "requirements": ["sense-energy==0.13.3"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 72d1d045c9a77..df2317c3a6c9a 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.2"] + "requirements": ["sense-energy==0.13.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index dc7d3416aaa00..8baf6ef17318f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2626,7 +2626,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.2 +sense-energy==0.13.3 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3a8d6c28746f..0597a3174f739 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2093,7 +2093,7 @@ securetar==2024.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.2 +sense-energy==0.13.3 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From 56212c6fa5f43624d93059a4d307b28e1a846f9f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 7 Nov 2024 08:24:47 +0100 Subject: [PATCH 0222/1070] Update numpy to 2.1.2 and pandas to 2.2.3 (#129958) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 6 ++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 6 ++---- 9 files changed, 11 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index caae9190bca55..90fa6289b8d92 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@Petro31"], "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", - "requirements": ["numpy==1.26.4"] + "requirements": ["numpy==2.1.2"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 6142fa1349e53..d589c117edd7a 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==1.26.4", "pyiqvia==2022.04.0"] + "requirements": ["numpy==2.1.2", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 23494a067442a..304ef5bbf62e4 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==1.26.4"] + "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.1.2"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 4f2b6f192859f..906ce02f5b1ee 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -9,7 +9,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==1.26.4", + "numpy==2.1.2", "Pillow==10.4.0" ] } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 56b4b811171bf..b2f47738d4a9a 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -7,5 +7,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==1.26.4"] + "requirements": ["numpy==2.1.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 49d2f4f01cfe7..54df8ccf1abc5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -112,7 +112,8 @@ httpcore==1.0.5 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.26.4 +numpy==2.1.2 +pandas~=2.2.3 # Constrain multidict to avoid typing issues # https://github.com/home-assistant/core/pull/67046 @@ -170,9 +171,6 @@ charset-normalizer==3.4.0 # Roborock, NAM, Brother, and GIOS. dacite>=1.7.0 -# Musle wheels for pandas 2.2.0 cannot be build for any architecture. -pandas==2.1.4 - # chacha20poly1305-reuseable==0.12.x is incompatible with cryptography==43.0.x chacha20poly1305-reuseable>=0.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8baf6ef17318f..27b9c357b59e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1491,7 +1491,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.26.4 +numpy==2.1.2 # homeassistant.components.nyt_games nyt_games==0.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0597a3174f739..3444b2b85581b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1239,7 +1239,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.26.4 +numpy==2.1.2 # homeassistant.components.nyt_games nyt_games==0.4.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 0f8354e1f6006..352b209c5fc7a 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -127,7 +127,8 @@ hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.26.4 +numpy==2.1.2 +pandas~=2.2.3 # Constrain multidict to avoid typing issues # https://github.com/home-assistant/core/pull/67046 @@ -185,9 +186,6 @@ # Roborock, NAM, Brother, and GIOS. dacite>=1.7.0 -# Musle wheels for pandas 2.2.0 cannot be build for any architecture. -pandas==2.1.4 - # chacha20poly1305-reuseable==0.12.x is incompatible with cryptography==43.0.x chacha20poly1305-reuseable>=0.13.0 From df16e6d0227ce9d949ac20261252a7142341a385 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 7 Nov 2024 01:29:44 -0600 Subject: [PATCH 0223/1070] Bump intents to 2024.11.6 (#129982) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2c446ac5d7058..8b5c6ef173ff3 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.4"] + "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.6"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 54df8ccf1abc5..e2b04c48b3073 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241106.0 -home-assistant-intents==2024.11.4 +home-assistant-intents==2024.11.6 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 27b9c357b59e7..fa9f83d4cbe88 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ holidays==0.60 home-assistant-frontend==20241106.0 # homeassistant.components.conversation -home-assistant-intents==2024.11.4 +home-assistant-intents==2024.11.6 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3444b2b85581b..bfab485079983 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -953,7 +953,7 @@ holidays==0.60 home-assistant-frontend==20241106.0 # homeassistant.components.conversation -home-assistant-intents==2024.11.4 +home-assistant-intents==2024.11.6 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 1e948c2982ae6..61b623dc32b06 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.4.28,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.2 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.11.4 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.11.6 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 2d2f55a4df9a16fca0e9c6a406985d3cbef4ea72 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Nov 2024 08:52:20 +0100 Subject: [PATCH 0224/1070] Report update_percentage in shelly update entity (#129382) Co-authored-by: Shay Levy --- homeassistant/components/shelly/update.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index fb586ae8b8544..f22547acf50cc 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -238,7 +238,8 @@ def __init__( ) -> None: """Initialize update entity.""" super().__init__(coordinator, key, attribute, description) - self._ota_in_progress: bool | int = False + self._ota_in_progress = False + self._ota_progress_percentage: int | None = None self._attr_release_url = get_release_url( coordinator.device.gen, coordinator.model, description.beta ) @@ -256,11 +257,12 @@ def _ota_progress_callback(self, event: dict[str, Any]) -> None: if self.in_progress is not False: event_type = event["event"] if event_type == OTA_BEGIN: - self._ota_in_progress = 0 + self._ota_progress_percentage = 0 elif event_type == OTA_PROGRESS: - self._ota_in_progress = event["progress_percent"] + self._ota_progress_percentage = event["progress_percent"] elif event_type in (OTA_ERROR, OTA_SUCCESS): self._ota_in_progress = False + self._ota_progress_percentage = None self.async_write_ha_state() @property @@ -278,10 +280,15 @@ def latest_version(self) -> str | None: return self.installed_version @property - def in_progress(self) -> bool | int: + def in_progress(self) -> bool: """Update installation in progress.""" return self._ota_in_progress + @property + def update_percentage(self) -> int | None: + """Update installation progress.""" + return self._ota_progress_percentage + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: @@ -310,6 +317,7 @@ async def async_install( await self.coordinator.async_shutdown_device_and_start_reauth() else: self._ota_in_progress = True + self._ota_progress_percentage = None LOGGER.debug("OTA update call for %s successful", self.coordinator.name) From a657b9bb8417cfbcd1c61713e5a45c799fb1d209 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Nov 2024 09:57:14 +0100 Subject: [PATCH 0225/1070] Add temporary package constraint on flexparser and pint to fix CI (#130016) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/package_constraints.txt | 5 +++++ script/gen_requirements_all.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e2b04c48b3073..5da579fa82780 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -192,3 +192,8 @@ tenacity!=8.4.0 # 5.0.0 breaks Timeout as a context manager # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 + +# latest pint 0.24.3 is not yet compatible with flexparser 0.4 +# https://github.com/hgrecco/pint/issues/1969 +flexparser==0.3.1 +pint==0.24.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 352b209c5fc7a..a71047fddc87b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -207,6 +207,11 @@ # 5.0.0 breaks Timeout as a context manager # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 + +# latest pint 0.24.3 is not yet compatible with flexparser 0.4 +# https://github.com/hgrecco/pint/issues/1969 +flexparser==0.3.1 +pint==0.24.3 """ GENERATED_MESSAGE = ( From cb97f2f13ce263a8b7ce147b1ae8d635b26f8f0b Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 7 Nov 2024 11:06:28 +0200 Subject: [PATCH 0226/1070] Bump zwave-js-server-python to 0.59.0 (#129482) --- homeassistant/components/zwave_js/manifest.json | 2 +- homeassistant/components/zwave_js/services.py | 11 +++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_services.py | 5 ++--- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index a37b35605261a..e3f643486a007 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -10,7 +10,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.58.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.59.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 969a235bb414e..d1cb66ceafcde 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -529,8 +529,15 @@ def process_results( for node_or_endpoint, result in get_valid_responses_from_results( nodes_or_endpoints_list, _results ): - zwave_value = result[0] - cmd_status = result[1] + if value_size is None: + # async_set_config_parameter still returns (Value, SetConfigParameterResult) + zwave_value = result[0] + cmd_status = result[1] + else: + # async_set_raw_config_parameter_value now returns just SetConfigParameterResult + cmd_status = result + zwave_value = f"parameter {property_or_property_name}" + if cmd_status.status == CommandStatus.ACCEPTED: msg = "Set configuration parameter %s on Node %s with value %s" else: diff --git a/requirements_all.txt b/requirements_all.txt index fa9f83d4cbe88..685574a89b238 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3081,7 +3081,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.58.1 +zwave-js-server-python==0.59.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfab485079983..95703e6f03091 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2455,7 +2455,7 @@ zeversolar==0.3.2 zha==0.0.37 # homeassistant.components.zwave_js -zwave-js-server-python==0.58.1 +zwave-js-server-python==0.59.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index ec13d0262f8ae..41477f18b9787 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -497,13 +497,12 @@ async def test_set_config_parameter( caplog.clear() - config_value = aeotec_zw164_siren.values["2-112-0-32"] cmd_result = SetConfigParameterResult("accepted", {"status": 255}) # Test accepted return with patch( "homeassistant.components.zwave_js.services.Endpoint.async_set_raw_config_parameter_value", - return_value=(config_value, cmd_result), + return_value=cmd_result, ) as mock_set_raw_config_parameter_value: await hass.services.async_call( DOMAIN, @@ -534,7 +533,7 @@ async def test_set_config_parameter( cmd_result.status = "queued" with patch( "homeassistant.components.zwave_js.services.Endpoint.async_set_raw_config_parameter_value", - return_value=(config_value, cmd_result), + return_value=cmd_result, ) as mock_set_raw_config_parameter_value: await hass.services.async_call( DOMAIN, From bbefa971d8c89793940a3e6804c2b39166573946 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Nov 2024 10:32:23 +0100 Subject: [PATCH 0227/1070] Add missing placeholder description to twitch (#130013) --- homeassistant/components/twitch/config_flow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/twitch/config_flow.py b/homeassistant/components/twitch/config_flow.py index dbaef59c2364e..ed196897c113a 100644 --- a/homeassistant/components/twitch/config_flow.py +++ b/homeassistant/components/twitch/config_flow.py @@ -78,7 +78,10 @@ async def async_oauth_create_entry( reauth_entry = self._get_reauth_entry() self._abort_if_unique_id_mismatch( reason="wrong_account", - description_placeholders={"title": reauth_entry.title}, + description_placeholders={ + "title": reauth_entry.title, + "username": str(reauth_entry.unique_id), + }, ) new_channels = reauth_entry.options[CONF_CHANNELS] From 43c2658962b3db3e5a2bcb6c9971b895546c860a Mon Sep 17 00:00:00 2001 From: sean t Date: Thu, 7 Nov 2024 17:34:54 +0800 Subject: [PATCH 0228/1070] Bump agent-py to 0.0.24 (#130018) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/agent_dvr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/agent_dvr/manifest.json b/homeassistant/components/agent_dvr/manifest.json index 9a6c528c33665..4ec142963637e 100644 --- a/homeassistant/components/agent_dvr/manifest.json +++ b/homeassistant/components/agent_dvr/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/agent_dvr", "iot_class": "local_polling", "loggers": ["agent"], - "requirements": ["agent-py==0.0.23"] + "requirements": ["agent-py==0.0.24"] } diff --git a/requirements_all.txt b/requirements_all.txt index 685574a89b238..32e71aa083a0f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -152,7 +152,7 @@ advantage-air==0.4.4 afsapi==0.2.7 # homeassistant.components.agent_dvr -agent-py==0.0.23 +agent-py==0.0.24 # homeassistant.components.geo_json_events aio-geojson-generic-client==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 95703e6f03091..0c73e10df1847 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -140,7 +140,7 @@ advantage-air==0.4.4 afsapi==0.2.7 # homeassistant.components.agent_dvr -agent-py==0.0.23 +agent-py==0.0.24 # homeassistant.components.geo_json_events aio-geojson-generic-client==0.4 From 838ef0bb9f2ff7e42b4bd15ddf5be2a4df91367e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 7 Nov 2024 19:36:43 +1000 Subject: [PATCH 0229/1070] Fix Trunks in Teslemetry and Tesla Fleet (#129986) --- homeassistant/components/tesla_fleet/cover.py | 8 +------- homeassistant/components/teslemetry/cover.py | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/tesla_fleet/cover.py b/homeassistant/components/tesla_fleet/cover.py index 2a14c4f039b05..f270734424f4c 100644 --- a/homeassistant/components/tesla_fleet/cover.py +++ b/homeassistant/components/tesla_fleet/cover.py @@ -177,13 +177,7 @@ def __init__(self, vehicle: TeslaFleetVehicleData, scopes: list[Scope]) -> None: def _async_update_attrs(self) -> None: """Update the entity attributes.""" - value = self._value - if value == CLOSED: - self._attr_is_closed = True - elif value == OPEN: - self._attr_is_closed = False - else: - self._attr_is_closed = None + self._attr_is_closed = self._value == CLOSED async def async_open_cover(self, **kwargs: Any) -> None: """Open rear trunk.""" diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index 190f729d99f34..8775da931d598 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -182,13 +182,7 @@ def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: def _async_update_attrs(self) -> None: """Update the entity attributes.""" - value = self._value - if value == CLOSED: - self._attr_is_closed = True - elif value == OPEN: - self._attr_is_closed = False - else: - self._attr_is_closed = None + self._attr_is_closed = self._value == CLOSED async def async_open_cover(self, **kwargs: Any) -> None: """Open rear trunk.""" From 2adbf7c9330220cef55864cade4154130be190e8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 7 Nov 2024 01:50:40 -0800 Subject: [PATCH 0230/1070] Bump google-nest-sdm to 6.1.4 (#130005) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 976e870cc8396..581113f0c96ad 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==6.1.3"] + "requirements": ["google-nest-sdm==6.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 32e71aa083a0f..449fcba2f5a23 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1015,7 +1015,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==6.1.3 +google-nest-sdm==6.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c73e10df1847..04706cc054621 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -865,7 +865,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==6.1.3 +google-nest-sdm==6.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From 49bf5db5ff7f80fb8bca6c27e8b590e9ecba98fc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 7 Nov 2024 12:55:54 +0100 Subject: [PATCH 0231/1070] Update pytest warnings filter (#130027) --- pyproject.toml | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 282a4e51ff7c3..a96cb3b405be8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -486,10 +486,13 @@ filterwarnings = [ "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", # -- tracked upstream / open PRs + # - pyOpenSSL v24.2.1 # https://github.com/certbot/certbot/issues/9828 - v2.11.0 + # https://github.com/certbot/certbot/issues/9992 "ignore:X509Extension support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - # https://github.com/beetbox/mediafile/issues/67 - v0.12.0 - "ignore:'imghdr' is deprecated and slated for removal in Python 3.13:DeprecationWarning:mediafile", + "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", + "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:josepy.util", + # - other # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3 # https://github.com/foxel/python_ndms2_client/pull/8 "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", @@ -526,6 +529,8 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:onvif.client", # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", + # https://github.com/cereal2nd/velbus-aio/pull/126 - >2024.10.0 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:velbusaio.handler", # -- fixed for Python 3.13 # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2 @@ -549,7 +554,7 @@ filterwarnings = [ "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", - # https://github.com/lextudio/pysnmp/blob/v7.1.8/pysnmp/smi/compiler.py#L23-L31 - v7.1.8 - 2024-10-15 + # https://github.com/lextudio/pysnmp/blob/v7.1.10/pysnmp/smi/compiler.py#L23-L31 - v7.1.10 - 2024-11-04 "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysmi.reader.url", # wrong stacklevel # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 @@ -579,7 +584,7 @@ filterwarnings = [ # - pkg_resources # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast", - # https://pypi.org/project/habitipy/ - v0.3.1 - 2019-01-14 / 2024-04-28 + # https://pypi.org/project/habitipy/ - v0.3.3 - 2024-10-28 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", @@ -587,14 +592,6 @@ filterwarnings = [ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", - # https://pypi.org/project/velbus-aio/ - v2024.7.6 - 2024-07-31 - # https://github.com/Cereal2nd/velbus-aio/blob/2024.7.6/velbusaio/handler.py#L22 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:velbusaio.handler", - # - pyOpenSSL v24.2.1 - # https://pypi.org/project/acme/ - v2.11.0 - 2024-06-06 - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - # https://pypi.org/project/josepy/ - v1.14.0 - 2023-11-01 - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:josepy.util", # -- Python 3.13 # HomeAssistant @@ -608,7 +605,7 @@ filterwarnings = [ # https://github.com/Uberi/speech_recognition/blob/3.11.0/speech_recognition/__init__.py#L7 "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition", # https://pypi.org/project/voip-utils/ - v0.2.0 - 2024-09-06 - # https://github.com/home-assistant-libs/voip-utils/blob/v0.2.0/voip_utils/rtp_audio.py#L3 + # https://github.com/home-assistant-libs/voip-utils/blob/0.2.0/voip_utils/rtp_audio.py#L3 "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:voip_utils.rtp_audio", # -- Python 3.13 - unmaintained projects, last release about 2+ years From a3ba7803db895b5e083c7f7d84fd3bb0e70bad25 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Nov 2024 13:12:00 +0100 Subject: [PATCH 0232/1070] Add checks for translation placeholders (#129963) * Add checks for translation placeholders * Remove async * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review --- tests/components/conftest.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 1ec656d44c5a2..00738cd252fc2 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Generator from importlib.util import find_spec from pathlib import Path +import string from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch @@ -542,17 +543,40 @@ def supervisor_client() -> Generator[AsyncMock]: yield supervisor_client +def _validate_translation_placeholders( + full_key: str, + translation: str, + description_placeholders: dict[str, str] | None, +) -> str | None: + """Raise if translation exists with missing placeholders.""" + tuples = list(string.Formatter().parse(translation)) + for _, placeholder, _, _ in tuples: + if placeholder is None: + continue + if ( + description_placeholders is None + or placeholder not in description_placeholders + ): + pytest.fail( + f"Description not found for placeholder `{placeholder}` in {full_key}" + ) + + async def _ensure_translation_exists( hass: HomeAssistant, ignore_translations: dict[str, StoreInfo], category: str, component: str, key: str, + description_placeholders: dict[str, str] | None, ) -> None: """Raise if translation doesn't exist.""" full_key = f"component.{component}.{category}.{key}" translations = await async_get_translations(hass, "en", category, [component]) - if full_key in translations: + if (translation := translations.get(full_key)) is not None: + _validate_translation_placeholders( + full_key, translation, description_placeholders + ) return if full_key in ignore_translations: @@ -610,6 +634,7 @@ async def _async_handle_step( category, component, f"error.{error}", + result["description_placeholders"], ) return result @@ -624,6 +649,7 @@ async def _async_handle_step( category, component, f"abort.{result["reason"]}", + result["description_placeholders"], ) return result From 0e324c074a3d307bfc839f0cf4d36092c4466d4c Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Thu, 7 Nov 2024 14:25:38 +0100 Subject: [PATCH 0233/1070] Bump PySuez to 1.3.1 (#129825) --- .../components/suez_water/config_flow.py | 10 +-- .../components/suez_water/coordinator.py | 90 ++++--------------- .../components/suez_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/suez_water/conftest.py | 35 ++++---- .../components/suez_water/test_config_flow.py | 84 ++++++++--------- tests/components/suez_water/test_init.py | 6 +- tests/components/suez_water/test_sensor.py | 8 +- 9 files changed, 88 insertions(+), 151 deletions(-) diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py index 28b211dc8080a..a7ade642888a2 100644 --- a/homeassistant/components/suez_water/config_flow.py +++ b/homeassistant/components/suez_water/config_flow.py @@ -5,8 +5,7 @@ import logging from typing import Any -from pysuez import SuezClient -from pysuez.client import PySuezError +from pysuez import PySuezError, SuezClient import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -26,7 +25,7 @@ ) -def validate_input(data: dict[str, Any]) -> None: +async def validate_input(data: dict[str, Any]) -> None: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. @@ -36,9 +35,8 @@ def validate_input(data: dict[str, Any]) -> None: data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_COUNTER_ID], - provider=None, ) - if not client.check_credentials(): + if not await client.check_credentials(): raise InvalidAuth except PySuezError as ex: raise CannotConnect from ex @@ -58,7 +56,7 @@ async def async_step_user( await self.async_set_unique_id(user_input[CONF_USERNAME]) self._abort_if_unique_id_configured() try: - await self.hass.async_add_executor_job(validate_input, user_input) + await validate_input(user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py index adcbd39c01b7f..55f3ba348d4e7 100644 --- a/homeassistant/components/suez_water/coordinator.py +++ b/homeassistant/components/suez_water/coordinator.py @@ -1,39 +1,20 @@ """Suez water update coordinator.""" -import asyncio -from dataclasses import dataclass -from datetime import date - -from pysuez import SuezClient -from pysuez.client import PySuezError +from pysuez import AggregatedData, PySuezError, SuezClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import _LOGGER, HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN -@dataclass -class AggregatedSensorData: - """Hold suez water aggregated sensor data.""" - - value: float - current_month: dict[date, float] - previous_month: dict[date, float] - previous_year: dict[str, float] - current_year: dict[str, float] - history: dict[date, float] - highest_monthly_consumption: float - attribution: str - - -class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedSensorData]): +class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedData]): """Suez water coordinator.""" - _sync_client: SuezClient + _suez_client: SuezClient config_entry: ConfigEntry def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: @@ -48,61 +29,22 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: ) async def _async_setup(self) -> None: - self._sync_client = await self.hass.async_add_executor_job(self._get_client) + self._suez_client = SuezClient( + username=self.config_entry.data[CONF_USERNAME], + password=self.config_entry.data[CONF_PASSWORD], + counter_id=self.config_entry.data[CONF_COUNTER_ID], + ) + if not await self._suez_client.check_credentials(): + raise ConfigEntryError("Invalid credentials for suez water") - async def _async_update_data(self) -> AggregatedSensorData: + async def _async_update_data(self) -> AggregatedData: """Fetch data from API endpoint.""" - async with asyncio.timeout(30): - return await self.hass.async_add_executor_job(self._fetch_data) - - def _fetch_data(self) -> AggregatedSensorData: - """Fetch latest data from Suez.""" try: - self._sync_client.update() + data = await self._suez_client.fetch_aggregated_data() except PySuezError as err: + _LOGGER.exception(err) raise UpdateFailed( f"Suez coordinator error communicating with API: {err}" ) from err - current_month = {} - for item in self._sync_client.attributes["thisMonthConsumption"]: - current_month[item] = self._sync_client.attributes["thisMonthConsumption"][ - item - ] - previous_month = {} - for item in self._sync_client.attributes["previousMonthConsumption"]: - previous_month[item] = self._sync_client.attributes[ - "previousMonthConsumption" - ][item] - highest_monthly_consumption = self._sync_client.attributes[ - "highestMonthlyConsumption" - ] - previous_year = self._sync_client.attributes["lastYearOverAll"] - current_year = self._sync_client.attributes["thisYearOverAll"] - history = {} - for item in self._sync_client.attributes["history"]: - history[item] = self._sync_client.attributes["history"][item] - _LOGGER.debug("Retrieved consumption: " + str(self._sync_client.state)) - return AggregatedSensorData( - self._sync_client.state, - current_month, - previous_month, - previous_year, - current_year, - history, - highest_monthly_consumption, - self._sync_client.attributes["attribution"], - ) - - def _get_client(self) -> SuezClient: - try: - client = SuezClient( - username=self.config_entry.data[CONF_USERNAME], - password=self.config_entry.data[CONF_PASSWORD], - counter_id=self.config_entry.data[CONF_COUNTER_ID], - provider=None, - ) - if not client.check_credentials(): - raise ConfigEntryError - except PySuezError as ex: - raise ConfigEntryNotReady from ex - return client + _LOGGER.debug("Successfully fetched suez data") + return data diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index fa7f8f6461d1b..5eb05b9acb7c9 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/suez_water", "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], - "requirements": ["pysuezV2==0.2.2"] + "requirements": ["pysuezV2==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 449fcba2f5a23..e1c224ad87057 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2284,7 +2284,7 @@ pysqueezebox==0.10.0 pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuezV2==0.2.2 +pysuezV2==1.3.1 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04706cc054621..68aec855ec555 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1841,7 +1841,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.10.0 # homeassistant.components.suez_water -pysuezV2==0.2.2 +pysuezV2==1.3.1 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index bcb817a502572..0cbf16095bf72 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -1,11 +1,12 @@ """Common fixtures for the Suez Water tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest from homeassistant.components.suez_water.const import DOMAIN +from homeassistant.components.suez_water.coordinator import AggregatedData from tests.common import MockConfigEntry @@ -37,7 +38,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="suez_client") -def mock_suez_client() -> Generator[MagicMock]: +def mock_suez_data() -> Generator[AsyncMock]: """Create mock for suez_water external api.""" with ( patch( @@ -48,28 +49,30 @@ def mock_suez_client() -> Generator[MagicMock]: new=mock_client, ), ): - client = mock_client.return_value - client.check_credentials.return_value = True - client.update.return_value = None - client.state = 160 - client.attributes = { - "thisMonthConsumption": { + suez_client = mock_client.return_value + suez_client.check_credentials.return_value = True + + result = AggregatedData( + value=160, + current_month={ "2024-01-01": 130, "2024-01-02": 145, }, - "previousMonthConsumption": { + previous_month={ "2024-12-01": 154, "2024-12-02": 166, }, - "highestMonthlyConsumption": 2558, - "lastYearOverAll": 1000, - "thisYearOverAll": 1500, - "history": { + current_year=1500, + previous_year=1000, + attribution="suez water mock test", + highest_monthly_consumption=2558, + history={ "2024-01-01": 130, "2024-01-02": 145, "2024-12-01": 154, "2024-12-02": 166, }, - "attribution": "suez water mock test", - } - yield client + ) + + suez_client.fetch_aggregated_data.return_value = result + yield suez_client diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py index ddf7bcd3d8012..766fd8c5fa53f 100644 --- a/tests/components/suez_water/test_config_flow.py +++ b/tests/components/suez_water/test_config_flow.py @@ -1,8 +1,8 @@ """Test the Suez Water config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock -from pysuez.client import PySuezError +from pysuez.exception import PySuezError import pytest from homeassistant import config_entries @@ -15,7 +15,9 @@ from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, suez_client: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -23,12 +25,11 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch("homeassistant.components.suez_water.config_flow.SuezClient"): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_DATA, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" @@ -38,37 +39,28 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: async def test_form_invalid_auth( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, mock_setup_entry: AsyncMock, suez_client: AsyncMock ) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "homeassistant.components.suez_water.config_flow.SuezClient.__init__", - return_value=None, - ), - patch( - "homeassistant.components.suez_water.config_flow.SuezClient.check_credentials", - return_value=False, - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_DATA, - ) + suez_client.check_credentials.return_value = False + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} - with patch("homeassistant.components.suez_water.config_flow.SuezClient"): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_DATA, - ) - await hass.async_block_till_done() + suez_client.check_credentials.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" @@ -104,32 +96,32 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: ("exception", "error"), [(PySuezError, "cannot_connect"), (Exception, "unknown")] ) async def test_form_error( - hass: HomeAssistant, mock_setup_entry: AsyncMock, exception: Exception, error: str + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + suez_client: AsyncMock, + error: str, ) -> None: """Test we handle errors.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.suez_water.config_flow.SuezClient", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_DATA, - ) + suez_client.check_credentials.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - with patch( - "homeassistant.components.suez_water.config_flow.SuezClient", - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_DATA, - ) + suez_client.check_credentials.return_value = True + suez_client.check_credentials.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" diff --git a/tests/components/suez_water/test_init.py b/tests/components/suez_water/test_init.py index b9a8875a8a12f..78d086af38f9d 100644 --- a/tests/components/suez_water/test_init.py +++ b/tests/components/suez_water/test_init.py @@ -1,5 +1,7 @@ """Test Suez_water integration initialization.""" +from unittest.mock import AsyncMock + from homeassistant.components.suez_water.coordinator import PySuezError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -11,7 +13,7 @@ async def test_initialization_invalid_credentials( hass: HomeAssistant, - suez_client, + suez_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test that suez_water can't be loaded with invalid credentials.""" @@ -24,7 +26,7 @@ async def test_initialization_invalid_credentials( async def test_initialization_setup_api_error( hass: HomeAssistant, - suez_client, + suez_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test that suez_water needs to retry loading if api failed to connect.""" diff --git a/tests/components/suez_water/test_sensor.py b/tests/components/suez_water/test_sensor.py index d3da159ee2846..1cd40dff75bb5 100644 --- a/tests/components/suez_water/test_sensor.py +++ b/tests/components/suez_water/test_sensor.py @@ -1,6 +1,6 @@ """Test Suez_water sensor platform.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion @@ -20,7 +20,7 @@ async def test_sensors_valid_state( hass: HomeAssistant, snapshot: SnapshotAssertion, - suez_client: MagicMock, + suez_client: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: @@ -34,7 +34,7 @@ async def test_sensors_valid_state( async def test_sensors_failed_update( hass: HomeAssistant, - suez_client, + suez_client: AsyncMock, mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: @@ -51,7 +51,7 @@ async def test_sensors_failed_update( assert entity_ids[0] assert state.state != STATE_UNAVAILABLE - suez_client.update.side_effect = PySuezError("Should fail to update") + suez_client.fetch_aggregated_data.side_effect = PySuezError("Should fail to update") freezer.tick(DATA_REFRESH_INTERVAL) async_fire_time_changed(hass) From c5e3ba536c385a6340433b4892defc8cf2881190 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Nov 2024 17:07:23 +0100 Subject: [PATCH 0234/1070] Don't create repairs asking user to remove duplicate ignored config entries (#130056) --- homeassistant/config_entries.py | 11 +++++++++++ tests/test_config_entries.py | 8 +++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a13225c4dfe6a..7209ad8cbcae0 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2437,6 +2437,17 @@ def async_update_issues(self) -> None: for domain, unique_ids in self._entries._domain_unique_id_index.items(): # noqa: SLF001 for unique_id, entries in unique_ids.items(): + # We might mutate the list of entries, so we need a copy to not mess up + # the index + entries = list(entries) + + # There's no need to raise an issue for ignored entries, we can + # safely remove them once we no longer allow unique id collisions. + # Iterate over a copy of the copy to allow mutating while iterating + for entry in list(entries): + if entry.source == SOURCE_IGNORE: + entries.remove(entry) + if len(entries) < 2: continue issue_id = f"{ISSUE_UNIQUE_ID_COLLISION}_{domain}_{unique_id}" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 3e3f3b4c50444..54008a394b5eb 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -7224,6 +7224,12 @@ async def test_unique_id_collision_issues( for _ in range(6): test3.append(MockConfigEntry(domain="test3", unique_id="not_unique")) await manager.async_add(test3[-1]) + # Add an ignored config entry + await manager.async_add( + MockConfigEntry( + domain="test2", unique_id="group_1", source=config_entries.SOURCE_IGNORE + ) + ) # Check we get one issue for domain test2 and one issue for domain test3 assert len(issue_registry.issues) == 2 @@ -7270,7 +7276,7 @@ async def test_unique_id_collision_issues( (HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_2"), } - # Remove the last test2 group2 duplicate, a new issue is created + # Remove the last test2 group2 duplicate, the issue is cleared await manager.async_remove(test2_group_2[1].entry_id) assert not issue_registry.issues From c1ecc13cb35ece9570743e84795e7dfd81d3a804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Wickstr=C3=B6m?= Date: Thu, 7 Nov 2024 18:18:36 +0200 Subject: [PATCH 0235/1070] Bump huum to 0.7.11 (#130047) * Update huum dependency 0.7.10 -> 0.7.11 This change includes an explicit MIT license for the package. * Remove huum from license exceptions list --- homeassistant/components/huum/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/licenses.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 7629f529b91bf..cc393f3785ff6 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", - "requirements": ["huum==0.7.10"] + "requirements": ["huum==0.7.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index e1c224ad87057..3641d949e0da0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1145,7 +1145,7 @@ httplib2==0.20.4 huawei-lte-api==1.10.0 # homeassistant.components.huum -huum==0.7.10 +huum==0.7.11 # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68aec855ec555..2cc01f44c6560 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -968,7 +968,7 @@ httplib2==0.20.4 huawei-lte-api==1.10.0 # homeassistant.components.huum -huum==0.7.10 +huum==0.7.11 # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/script/licenses.py b/script/licenses.py index 4f5432ad519fd..f4d534365bcb4 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -188,7 +188,6 @@ def from_dict(cls, data: PackageMetadata) -> PackageDefinition: "crownstone-uart", # https://github.com/crownstone/crownstone-lib-python-uart/pull/12 "eliqonline", # https://github.com/molobrakos/eliqonline/pull/17 "enocean", # https://github.com/kipe/enocean/pull/142 - "huum", # https://github.com/frwickst/pyhuum/pull/8 "imutils", # https://github.com/PyImageSearch/imutils/pull/292 "iso4217", # Public domain "kiwiki_client", # https://github.com/c7h/kiwiki_client/pull/6 From ef767c2b9ffd3d636bc5a01cc7c51c823cff45db Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Nov 2024 17:35:58 +0100 Subject: [PATCH 0236/1070] Improve tests for frame helper (#130046) * Improve tests for frame helper * Improve comments * Add ids * Apply suggestions from code review --- tests/conftest.py | 26 ++++++++++-- tests/helpers/test_frame.py | 85 +++++++++++++++++++++++++++++++++++++ tests/test_loader.py | 80 +++++++++++++++++++--------------- 3 files changed, 153 insertions(+), 38 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c60018413e75c..35b65c5653cc1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1772,10 +1772,30 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]: @pytest.fixture -def mock_integration_frame() -> Generator[Mock]: - """Mock as if we're calling code from inside an integration.""" +def integration_frame_path() -> str: + """Return the path to the integration frame. + + Can be parametrized with + `@pytest.mark.parametrize("integration_frame_path", ["path_to_frame"])` + + - "custom_components/XYZ" for a custom integration + - "homeassistant/components/XYZ" for a core integration + - "homeassistant/XYZ" for core (no integration) + + Defaults to core component `hue` + """ + return "homeassistant/components/hue" + + +@pytest.fixture +def mock_integration_frame(integration_frame_path: str) -> Generator[Mock]: + """Mock where we are calling code from. + + Defaults to calling from `hue` core integration, and can be parametrized + with `integration_frame_path`. + """ correct_frame = Mock( - filename="/home/paulus/homeassistant/components/hue/light.py", + filename=f"/home/paulus/{integration_frame_path}/light.py", lineno="23", line="self.light.is_on", ) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index b3fbb0faaf4fa..1961bf1429969 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -1,5 +1,6 @@ """Test the frame helper.""" +from typing import Any from unittest.mock import ANY, Mock, patch import pytest @@ -247,3 +248,87 @@ async def test_report_error_if_integration( ), ): frame.report("did a bad thing", error_if_integration=True) + + +@pytest.mark.parametrize( + ("integration_frame_path", "keywords", "expected_error", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", + {}, + True, + 0, + id="core default", + ), + pytest.param( + "homeassistant/components/test_core_integration", + {}, + False, + 1, + id="core integration default", + ), + pytest.param( + "custom_components/test_custom_integration", + {}, + False, + 1, + id="custom integration default", + ), + pytest.param( + "custom_components/test_integration_frame", + {"log_custom_component_only": True}, + False, + 1, + id="log_custom_component_only with custom integration", + ), + pytest.param( + "homeassistant/components/test_integration_frame", + {"log_custom_component_only": True}, + False, + 0, + id="log_custom_component_only with core integration", + ), + pytest.param( + "homeassistant/test_integration_frame", + {"error_if_core": False}, + False, + 1, + id="disable error_if_core", + ), + pytest.param( + "custom_components/test_integration_frame", + {"error_if_integration": True}, + True, + 1, + id="error_if_integration with custom integration", + ), + pytest.param( + "homeassistant/components/test_integration_frame", + {"error_if_integration": True}, + True, + 1, + id="error_if_integration with core integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_report( + caplog: pytest.LogCaptureFixture, + keywords: dict[str, Any], + expected_error: bool, + expected_log: int, +) -> None: + """Test report.""" + + what = "test_report_string" + + errored = False + try: + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + frame.report(what, **keywords) + except RuntimeError: + errored = True + + assert errored == expected_error + + assert caplog.text.count(what) == expected_log diff --git a/tests/test_loader.py b/tests/test_loader.py index c4bcbed0107c6..57d3d6fa83253 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -6,7 +6,7 @@ import sys import threading from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch from awesomeversion import AwesomeVersion import pytest @@ -1295,26 +1295,29 @@ async def test_config_folder_not_in_path() -> None: import tests.testing_config.check_config_not_in_path # noqa: F401 +@pytest.mark.parametrize( + ("integration_frame_path", "expected"), + [ + pytest.param( + "custom_components/test_integration_frame", True, id="custom integration" + ), + pytest.param( + "homeassistant/components/test_integration_frame", + False, + id="core integration", + ), + pytest.param("homeassistant/test_integration_frame", False, id="core"), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_hass_components_use_reported( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + expected: bool, ) -> None: - """Test that use of hass.components is reported.""" - mock_integration_frame.filename = ( - "/home/paulus/homeassistant/custom_components/demo/light.py" - ) - integration_frame = frame.IntegrationFrame( - custom_integration=True, - frame=mock_integration_frame, - integration="test_integration_frame", - module="custom_components.test_integration_frame", - relative_filename="custom_components/test_integration_frame/__init__.py", - ) - + """Test whether use of hass.components is reported.""" with ( - patch( - "homeassistant.helpers.frame.get_integration_frame", - return_value=integration_frame, - ), patch( "homeassistant.components.http.start_http_server_and_save_config", return_value=None, @@ -1322,10 +1325,11 @@ async def test_hass_components_use_reported( ): await hass.components.http.start_http_server_and_save_config(hass, [], None) - assert ( + reported = ( "Detected that custom integration 'test_integration_frame'" " accesses hass.components.http. This is deprecated" ) in caplog.text + assert reported == expected async def test_async_get_component_preloads_config_and_config_flow( @@ -1987,24 +1991,29 @@ async def test_has_services(hass: HomeAssistant) -> None: assert integration.has_services is True +@pytest.mark.parametrize( + ("integration_frame_path", "expected"), + [ + pytest.param( + "custom_components/test_integration_frame", True, id="custom integration" + ), + pytest.param( + "homeassistant/components/test_integration_frame", + False, + id="core integration", + ), + pytest.param("homeassistant/test_integration_frame", False, id="core"), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_hass_helpers_use_reported( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + expected: bool, ) -> None: - """Test that use of hass.components is reported.""" - integration_frame = frame.IntegrationFrame( - custom_integration=True, - frame=mock_integration_frame, - integration="test_integration_frame", - module="custom_components.test_integration_frame", - relative_filename="custom_components/test_integration_frame/__init__.py", - ) - + """Test whether use of hass.helpers is reported.""" with ( - patch.object(frame, "_REPORTED_INTEGRATIONS", new=set()), - patch( - "homeassistant.helpers.frame.get_integration_frame", - return_value=integration_frame, - ), patch( "homeassistant.helpers.aiohttp_client.async_get_clientsession", return_value=None, @@ -2012,10 +2021,11 @@ async def test_hass_helpers_use_reported( ): hass.helpers.aiohttp_client.async_get_clientsession() - assert ( + reported = ( "Detected that custom integration 'test_integration_frame' " "accesses hass.helpers.aiohttp_client. This is deprecated" ) in caplog.text + assert reported == expected async def test_manifest_json_fragment_round_trip(hass: HomeAssistant) -> None: From 536e6868923ae7956f06b90baeb8f5bb1f15dfb1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Nov 2024 17:38:10 +0100 Subject: [PATCH 0237/1070] Don't create repairs asking user to remove duplicate flipr config entries (#130058) * Don't create repairs asking user to remove duplicate flipr config entries * Improve comments --- homeassistant/config_entries.py | 13 +++++++++++- tests/test_config_entries.py | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7209ad8cbcae0..a41f4f2470199 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2158,7 +2158,12 @@ def async_update_entry( if unique_id is not UNDEFINED and entry.unique_id != unique_id: # Deprecated in 2024.11, should fail in 2025.11 if ( - unique_id is not None + # flipr creates duplicates during migration, and asks users to + # remove the duplicate. We don't need warn about it here too. + # We should remove the special case for "flipr" in HA Core 2025.4, + # when the flipr migration period ends + entry.domain != "flipr" + and unique_id is not None and self.async_entry_for_domain_unique_id(entry.domain, unique_id) is not None ): @@ -2436,6 +2441,12 @@ def async_update_issues(self) -> None: issues.add(issue.issue_id) for domain, unique_ids in self._entries._domain_unique_id_index.items(): # noqa: SLF001 + # flipr creates duplicates during migration, and asks users to + # remove the duplicate. We don't need warn about it here too. + # We should remove the special case for "flipr" in HA Core 2025.4, + # when the flipr migration period ends + if domain == "flipr": + continue for unique_id, entries in unique_ids.items(): # We might mutate the list of entries, so we need a copy to not mess up # the index diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 54008a394b5eb..df464f6af1b3e 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -7195,6 +7195,41 @@ async def test_async_update_entry_unique_id_collision( assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) +@pytest.mark.parametrize("domain", ["flipr"]) +async def test_async_update_entry_unique_id_collision_allowed_domain( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, + domain: str, +) -> None: + """Test we warn when async_update_entry creates a unique_id collision. + + This tests we don't warn and don't create issues for domains which have + their own migration path. + """ + assert len(issue_registry.issues) == 0 + + entry1 = MockConfigEntry(domain=domain, unique_id=None) + entry2 = MockConfigEntry(domain=domain, unique_id="not none") + entry3 = MockConfigEntry(domain=domain, unique_id="very unique") + entry4 = MockConfigEntry(domain=domain, unique_id="also very unique") + entry1.add_to_manager(manager) + entry2.add_to_manager(manager) + entry3.add_to_manager(manager) + entry4.add_to_manager(manager) + + manager.async_update_entry(entry2, unique_id=None) + assert len(issue_registry.issues) == 0 + assert len(caplog.record_tuples) == 0 + + manager.async_update_entry(entry4, unique_id="very unique") + assert len(issue_registry.issues) == 0 + assert len(caplog.record_tuples) == 0 + + assert ("already in use") not in caplog.text + + async def test_unique_id_collision_issues( hass: HomeAssistant, manager: config_entries.ConfigEntries, From ee30520b572a244c01c6239e054ab936ff34eefd Mon Sep 17 00:00:00 2001 From: Markus <974709+Links2004@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:16:01 +0100 Subject: [PATCH 0238/1070] Fix esphome mqtt discovery by handling case where payload is a empty string (#129969) Co-authored-by: J. Nick Koston --- homeassistant/components/esphome/config_flow.py | 3 +++ homeassistant/components/esphome/strings.json | 3 ++- tests/components/esphome/test_config_flow.py | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 99dae2e68abcd..cb892b314cd06 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -257,6 +257,9 @@ async def async_step_mqtt( self, discovery_info: MqttServiceInfo ) -> ConfigFlowResult: """Handle MQTT discovery.""" + if not discovery_info.payload: + return self.async_abort(reason="mqtt_missing_payload") + device_info = json_loads_object(discovery_info.payload) if "mac" not in device_info: return self.async_abort(reason="mqtt_missing_mac") diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index ec7e6f674b3ea..18a54772e3038 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -8,7 +8,8 @@ "service_received": "Action received", "mqtt_missing_mac": "Missing MAC address in MQTT properties.", "mqtt_missing_api": "Missing API port in MQTT properties.", - "mqtt_missing_ip": "Missing IP address in MQTT properties." + "mqtt_missing_ip": "Missing IP address in MQTT properties.", + "mqtt_missing_payload": "Missing MQTT Payload." }, "error": { "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address", diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 3051547bd43fb..0a389969c78ba 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1400,6 +1400,14 @@ async def test_discovery_mqtt_no_mac( await mqtt_discovery_test_abort(hass, "{}", "mqtt_missing_mac") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_discovery_mqtt_empty_payload( + hass: HomeAssistant, mock_client, mock_setup_entry: None +) -> None: + """Test discovery aborted if MQTT payload is empty.""" + await mqtt_discovery_test_abort(hass, "", "mqtt_missing_payload") + + @pytest.mark.usefixtures("mock_zeroconf") async def test_discovery_mqtt_no_api( hass: HomeAssistant, mock_client, mock_setup_entry: None From a3b0909e3f1a41d35a0cfc16fc68eb69a07ce9da Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:23:35 +0100 Subject: [PATCH 0239/1070] Add new frame helper to better distinguish custom and core integrations (#130025) * Add new frame helper to clarify options available * Adjust * Improve * Use report_usage in core * Add tests * Use is/is not Co-authored-by: J. Nick Koston * Use enum.auto() --------- Co-authored-by: J. Nick Koston --- homeassistant/core.py | 20 +++---- homeassistant/core_config.py | 8 +-- homeassistant/data_entry_flow.py | 6 +-- homeassistant/helpers/frame.py | 65 ++++++++++++++++++++--- homeassistant/loader.py | 20 ++++--- tests/helpers/test_frame.py | 91 ++++++++++++++++++++++++++++++++ 6 files changed, 177 insertions(+), 33 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index ab852056353aa..cdfb5570b4437 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -656,12 +656,12 @@ def async_add_job[_R, *_Ts]( # late import to avoid circular imports from .helpers import frame # pylint: disable=import-outside-toplevel - frame.report( + frame.report_usage( "calls `async_add_job`, which is deprecated and will be removed in Home " "Assistant 2025.4; Please review " "https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job" " for replacement options", - error_if_core=False, + core_behavior=frame.ReportBehavior.LOG, ) if target is None: @@ -712,12 +712,12 @@ def async_add_hass_job[_R]( # late import to avoid circular imports from .helpers import frame # pylint: disable=import-outside-toplevel - frame.report( + frame.report_usage( "calls `async_add_hass_job`, which is deprecated and will be removed in Home " "Assistant 2025.5; Please review " "https://developers.home-assistant.io/blog/2024/04/07/deprecate_add_hass_job" " for replacement options", - error_if_core=False, + core_behavior=frame.ReportBehavior.LOG, ) return self._async_add_hass_job(hassjob, *args, background=background) @@ -986,12 +986,12 @@ def async_run_job[_R, *_Ts]( # late import to avoid circular imports from .helpers import frame # pylint: disable=import-outside-toplevel - frame.report( + frame.report_usage( "calls `async_run_job`, which is deprecated and will be removed in Home " "Assistant 2025.4; Please review " "https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job" " for replacement options", - error_if_core=False, + core_behavior=frame.ReportBehavior.LOG, ) if asyncio.iscoroutine(target): @@ -1635,10 +1635,10 @@ def async_listen( # late import to avoid circular imports from .helpers import frame # pylint: disable=import-outside-toplevel - frame.report( + frame.report_usage( "calls `async_listen` with run_immediately, which is" " deprecated and will be removed in Home Assistant 2025.5", - error_if_core=False, + core_behavior=frame.ReportBehavior.LOG, ) if event_filter is not None and not is_callback_check_partial(event_filter): @@ -1705,10 +1705,10 @@ def async_listen_once( # late import to avoid circular imports from .helpers import frame # pylint: disable=import-outside-toplevel - frame.report( + frame.report_usage( "calls `async_listen_once` with run_immediately, which is " "deprecated and will be removed in Home Assistant 2025.5", - error_if_core=False, + core_behavior=frame.ReportBehavior.LOG, ) one_time_listener: _OneTimeListener[_DataT] = _OneTimeListener( diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index 25f745f110c3a..5c773c57bc439 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -60,7 +60,7 @@ from .generated.currencies import HISTORIC_CURRENCIES from .helpers import config_validation as cv, issue_registry as ir from .helpers.entity_values import EntityValues -from .helpers.frame import report +from .helpers.frame import ReportBehavior, report_usage from .helpers.storage import Store from .helpers.typing import UNDEFINED, UndefinedType from .util import dt as dt_util, location @@ -695,11 +695,11 @@ def set_time_zone(self, time_zone_str: str) -> None: It will be removed in Home Assistant 2025.6. """ - report( + report_usage( "set the time zone using set_time_zone instead of async_set_time_zone" " which will stop working in Home Assistant 2025.6", - error_if_core=True, - error_if_integration=True, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.ERROR, ) if time_zone := dt_util.get_time_zone(time_zone_str): self.time_zone = time_zone_str diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 1fb6439a8c477..9d041c9b8d330 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -26,7 +26,7 @@ check_if_deprecated_constant, dir_with_deprecated_constants, ) -from .helpers.frame import report +from .helpers.frame import ReportBehavior, report_usage from .loader import async_suggest_report_issue from .util import uuid as uuid_util @@ -530,12 +530,12 @@ async def _async_handle_step( if not isinstance(result["type"], FlowResultType): result["type"] = FlowResultType(result["type"]) # type: ignore[unreachable] - report( + report_usage( ( "does not use FlowResultType enum for data entry flow result type. " "This is deprecated and will stop working in Home Assistant 2025.1" ), - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) if ( diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index fd7e014b2ffed..eda980997139d 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass +import enum import functools import linecache import logging @@ -144,24 +145,72 @@ def report( If error_if_integration is True, raise instead of log if an integration is found when unwinding the stack frame. """ + core_behavior = ReportBehavior.ERROR if error_if_core else ReportBehavior.LOG + core_integration_behavior = ( + ReportBehavior.ERROR if error_if_integration else ReportBehavior.LOG + ) + custom_integration_behavior = core_integration_behavior + + if log_custom_component_only: + if core_behavior is ReportBehavior.LOG: + core_behavior = ReportBehavior.IGNORE + if core_integration_behavior is ReportBehavior.LOG: + core_integration_behavior = ReportBehavior.IGNORE + + report_usage( + what, + core_behavior=core_behavior, + core_integration_behavior=core_integration_behavior, + custom_integration_behavior=custom_integration_behavior, + exclude_integrations=exclude_integrations, + level=level, + ) + + +class ReportBehavior(enum.Enum): + """Enum for behavior on code usage.""" + + IGNORE = enum.auto() + """Ignore the code usage.""" + LOG = enum.auto() + """Log the code usage.""" + ERROR = enum.auto() + """Raise an error on code usage.""" + + +def report_usage( + what: str, + *, + core_behavior: ReportBehavior = ReportBehavior.ERROR, + core_integration_behavior: ReportBehavior = ReportBehavior.LOG, + custom_integration_behavior: ReportBehavior = ReportBehavior.LOG, + exclude_integrations: set[str] | None = None, + level: int = logging.WARNING, +) -> None: + """Report incorrect code usage. + + Similar to `report` but allows more fine-grained reporting. + """ try: integration_frame = get_integration_frame( exclude_integrations=exclude_integrations ) except MissingIntegrationFrame as err: msg = f"Detected code that {what}. Please report this issue." - if error_if_core: + if core_behavior is ReportBehavior.ERROR: raise RuntimeError(msg) from err - if not log_custom_component_only: + if core_behavior is ReportBehavior.LOG: _LOGGER.warning(msg, stack_info=True) return - if ( - error_if_integration - or not log_custom_component_only - or integration_frame.custom_integration - ): - _report_integration(what, integration_frame, level, error_if_integration) + integration_behavior = core_integration_behavior + if integration_frame.custom_integration: + integration_behavior = custom_integration_behavior + + if integration_behavior is not ReportBehavior.IGNORE: + _report_integration( + what, integration_frame, level, integration_behavior is ReportBehavior.ERROR + ) def _report_integration( diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 221a2c7ce1953..d2e04df04c4b0 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1556,16 +1556,18 @@ def __getattr__(self, comp_name: str) -> ModuleWrapper: raise ImportError(f"Unable to load {comp_name}") # Local import to avoid circular dependencies - from .helpers.frame import report # pylint: disable=import-outside-toplevel + # pylint: disable-next=import-outside-toplevel + from .helpers.frame import ReportBehavior, report_usage - report( + report_usage( ( f"accesses hass.components.{comp_name}." " This is deprecated and will stop working in Home Assistant 2025.3, it" f" should be updated to import functions used from {comp_name} directly" ), - error_if_core=False, - log_custom_component_only=True, + core_behavior=ReportBehavior.IGNORE, + core_integration_behavior=ReportBehavior.IGNORE, + custom_integration_behavior=ReportBehavior.LOG, ) wrapped = ModuleWrapper(self._hass, component) @@ -1585,16 +1587,18 @@ def __getattr__(self, helper_name: str) -> ModuleWrapper: helper = importlib.import_module(f"homeassistant.helpers.{helper_name}") # Local import to avoid circular dependencies - from .helpers.frame import report # pylint: disable=import-outside-toplevel + # pylint: disable-next=import-outside-toplevel + from .helpers.frame import ReportBehavior, report_usage - report( + report_usage( ( f"accesses hass.helpers.{helper_name}." " This is deprecated and will stop working in Home Assistant 2025.5, it" f" should be updated to import functions used from {helper_name} directly" ), - error_if_core=False, - log_custom_component_only=True, + core_behavior=ReportBehavior.IGNORE, + core_integration_behavior=ReportBehavior.IGNORE, + custom_integration_behavior=ReportBehavior.LOG, ) wrapped = ModuleWrapper(self._hass, helper) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 1961bf1429969..a2a4890810b83 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -157,6 +157,97 @@ async def test_get_integration_logger_no_integration( assert logger.name == __name__ +@pytest.mark.parametrize( + ("integration_frame_path", "keywords", "expected_error", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", + {}, + True, + 0, + id="core default", + ), + pytest.param( + "homeassistant/components/test_core_integration", + {}, + False, + 1, + id="core integration default", + ), + pytest.param( + "custom_components/test_custom_integration", + {}, + False, + 1, + id="custom integration default", + ), + pytest.param( + "custom_components/test_custom_integration", + {"custom_integration_behavior": frame.ReportBehavior.IGNORE}, + False, + 0, + id="custom integration ignore", + ), + pytest.param( + "custom_components/test_custom_integration", + {"custom_integration_behavior": frame.ReportBehavior.ERROR}, + True, + 1, + id="custom integration error", + ), + pytest.param( + "homeassistant/components/test_integration_frame", + {"core_integration_behavior": frame.ReportBehavior.IGNORE}, + False, + 0, + id="core_integration_behavior ignore", + ), + pytest.param( + "homeassistant/components/test_integration_frame", + {"core_integration_behavior": frame.ReportBehavior.ERROR}, + True, + 1, + id="core_integration_behavior error", + ), + pytest.param( + "homeassistant/test_integration_frame", + {"core_behavior": frame.ReportBehavior.IGNORE}, + False, + 0, + id="core_behavior ignore", + ), + pytest.param( + "homeassistant/test_integration_frame", + {"core_behavior": frame.ReportBehavior.LOG}, + False, + 1, + id="core_behavior log", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_report_usage( + caplog: pytest.LogCaptureFixture, + keywords: dict[str, Any], + expected_error: bool, + expected_log: int, +) -> None: + """Test report.""" + + what = "test_report_string" + + errored = False + try: + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + frame.report_usage(what, **keywords) + except RuntimeError: + errored = True + + assert errored == expected_error + + assert caplog.text.count(what) == expected_log + + @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_prevent_flooding( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock From 8cae8edc5557828f97dd2f9938c3bafdda49d21b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 7 Nov 2024 19:10:24 +0100 Subject: [PATCH 0240/1070] Remove temporary pint constraint (#130070) --- homeassistant/package_constraints.txt | 5 ----- script/gen_requirements_all.py | 5 ----- 2 files changed, 10 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5da579fa82780..e2b04c48b3073 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -192,8 +192,3 @@ tenacity!=8.4.0 # 5.0.0 breaks Timeout as a context manager # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 - -# latest pint 0.24.3 is not yet compatible with flexparser 0.4 -# https://github.com/hgrecco/pint/issues/1969 -flexparser==0.3.1 -pint==0.24.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a71047fddc87b..352b209c5fc7a 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -207,11 +207,6 @@ # 5.0.0 breaks Timeout as a context manager # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 - -# latest pint 0.24.3 is not yet compatible with flexparser 0.4 -# https://github.com/hgrecco/pint/issues/1969 -flexparser==0.3.1 -pint==0.24.3 """ GENERATED_MESSAGE = ( From dac6271e01c6209b0e590be1acf644dcf0209cb4 Mon Sep 17 00:00:00 2001 From: YogevBokobza Date: Thu, 7 Nov 2024 22:06:34 +0200 Subject: [PATCH 0241/1070] Add Switcher Lights support (#129494) * switcher lights integration * fix based on requested changes * Update light.py * switcher fix based on requested changes * fix linting * fix linting * Update light.py * Update light.py * Update homeassistant/components/switcher_kis/light.py * Update light.py --------- Co-authored-by: Shay Levy --- .../components/switcher_kis/light.py | 26 +++++---- tests/components/switcher_kis/consts.py | 56 +++++++++++++++++++ tests/components/switcher_kis/test_light.py | 41 +++++++++++--- 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/switcher_kis/light.py b/homeassistant/components/switcher_kis/light.py index 4b6df6db6edaf..bd87176bcf002 100644 --- a/homeassistant/components/switcher_kis/light.py +++ b/homeassistant/components/switcher_kis/light.py @@ -35,16 +35,20 @@ async def async_setup_entry( def async_add_light(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add light from Switcher device.""" entities: list[LightEntity] = [] - if ( - coordinator.data.device_type.category - == DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT - ): - entities.extend(SwitcherDualLightEntity(coordinator, i) for i in range(2)) - if ( - coordinator.data.device_type.category - == DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT + + if coordinator.data.device_type.category in ( + DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, + DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT, + DeviceCategory.LIGHT, ): - entities.append(SwitcherSingleLightEntity(coordinator, 0)) + number_of_lights = len(cast(SwitcherLight, coordinator.data).light) + if number_of_lights == 1: + entities.append(SwitcherSingleLightEntity(coordinator, 0)) + else: + entities.extend( + SwitcherMultiLightEntity(coordinator, i) + for i in range(number_of_lights) + ) async_add_entities(entities) config_entry.async_on_unload( @@ -133,8 +137,8 @@ def __init__( self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" -class SwitcherDualLightEntity(SwitcherBaseLightEntity): - """Representation of a Switcher dual light entity.""" +class SwitcherMultiLightEntity(SwitcherBaseLightEntity): + """Representation of a Switcher multiple light entity.""" _attr_translation_key = "light" diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index ab0bef4e3352e..fe77ee0236b95 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -5,6 +5,7 @@ DeviceType, ShutterDirection, SwitcherDualShutterSingleLight, + SwitcherLight, SwitcherPowerPlug, SwitcherShutter, SwitcherSingleShutterDualLight, @@ -23,18 +24,27 @@ DUMMY_DEVICE_ID4 = "bbd164" DUMMY_DEVICE_ID5 = "bcdb64" DUMMY_DEVICE_ID6 = "bcdc64" +DUMMY_DEVICE_ID7 = "bcdd64" +DUMMY_DEVICE_ID8 = "bcde64" +DUMMY_DEVICE_ID9 = "bcdf64" DUMMY_DEVICE_KEY1 = "18" DUMMY_DEVICE_KEY2 = "01" DUMMY_DEVICE_KEY3 = "12" DUMMY_DEVICE_KEY4 = "07" DUMMY_DEVICE_KEY5 = "15" DUMMY_DEVICE_KEY6 = "16" +DUMMY_DEVICE_KEY7 = "17" +DUMMY_DEVICE_KEY8 = "18" +DUMMY_DEVICE_KEY9 = "19" DUMMY_DEVICE_NAME1 = "Plug 23BC" DUMMY_DEVICE_NAME2 = "Heater FE12" DUMMY_DEVICE_NAME3 = "Breeze AB39" DUMMY_DEVICE_NAME4 = "Runner DD77" DUMMY_DEVICE_NAME5 = "RunnerS11 6CF5" DUMMY_DEVICE_NAME6 = "RunnerS12 A9BE" +DUMMY_DEVICE_NAME7 = "Light 36BB" +DUMMY_DEVICE_NAME8 = "Light 36CB" +DUMMY_DEVICE_NAME9 = "Light 36DB" DUMMY_DEVICE_PASSWORD = "12345678" DUMMY_ELECTRIC_CURRENT1 = 0.5 DUMMY_ELECTRIC_CURRENT2 = 12.8 @@ -44,18 +54,27 @@ DUMMY_IP_ADDRESS4 = "192.168.100.160" DUMMY_IP_ADDRESS5 = "192.168.100.161" DUMMY_IP_ADDRESS6 = "192.168.100.162" +DUMMY_IP_ADDRESS7 = "192.168.100.163" +DUMMY_IP_ADDRESS8 = "192.168.100.164" +DUMMY_IP_ADDRESS9 = "192.168.100.165" DUMMY_MAC_ADDRESS1 = "A1:B2:C3:45:67:D8" DUMMY_MAC_ADDRESS2 = "A1:B2:C3:45:67:D9" DUMMY_MAC_ADDRESS3 = "A1:B2:C3:45:67:DA" DUMMY_MAC_ADDRESS4 = "A1:B2:C3:45:67:DB" DUMMY_MAC_ADDRESS5 = "A1:B2:C3:45:67:DC" DUMMY_MAC_ADDRESS6 = "A1:B2:C3:45:67:DD" +DUMMY_MAC_ADDRESS7 = "A1:B2:C3:45:67:DE" +DUMMY_MAC_ADDRESS8 = "A1:B2:C3:45:67:DF" +DUMMY_MAC_ADDRESS9 = "A1:B2:C3:45:67:DG" DUMMY_TOKEN_NEEDED1 = False DUMMY_TOKEN_NEEDED2 = False DUMMY_TOKEN_NEEDED3 = False DUMMY_TOKEN_NEEDED4 = False DUMMY_TOKEN_NEEDED5 = True DUMMY_TOKEN_NEEDED6 = True +DUMMY_TOKEN_NEEDED7 = True +DUMMY_TOKEN_NEEDED8 = True +DUMMY_TOKEN_NEEDED9 = True DUMMY_PHONE_ID = "1234" DUMMY_POWER_CONSUMPTION1 = 100 DUMMY_POWER_CONSUMPTION2 = 2780 @@ -75,6 +94,7 @@ DUMMY_TOKEN = "zvVvd7JxtN7CgvkD1Psujw==" DUMMY_LIGHT = [DeviceState.ON] DUMMY_LIGHT_2 = [DeviceState.ON, DeviceState.ON] +DUMMY_LIGHT_3 = [DeviceState.ON, DeviceState.ON, DeviceState.ON] DUMMY_PLUG_DEVICE = SwitcherPowerPlug( DeviceType.POWER_PLUG, @@ -162,4 +182,40 @@ DUMMY_REMOTE_ID, ) +DUMMY_LIGHT_DEVICE = SwitcherLight( + DeviceType.LIGHT_SL01, + DeviceState.ON, + DUMMY_DEVICE_ID7, + DUMMY_DEVICE_KEY7, + DUMMY_IP_ADDRESS7, + DUMMY_MAC_ADDRESS7, + DUMMY_DEVICE_NAME7, + DUMMY_TOKEN_NEEDED7, + DUMMY_LIGHT, +) + +DUMMY_DUAL_LIGHT_DEVICE = SwitcherLight( + DeviceType.LIGHT_SL02, + DeviceState.ON, + DUMMY_DEVICE_ID8, + DUMMY_DEVICE_KEY8, + DUMMY_IP_ADDRESS8, + DUMMY_MAC_ADDRESS8, + DUMMY_DEVICE_NAME8, + DUMMY_TOKEN_NEEDED8, + DUMMY_LIGHT_2, +) + +DUMMY_TRIPLE_LIGHT_DEVICE = SwitcherLight( + DeviceType.LIGHT_SL03, + DeviceState.ON, + DUMMY_DEVICE_ID9, + DUMMY_DEVICE_KEY9, + DUMMY_IP_ADDRESS9, + DUMMY_MAC_ADDRESS9, + DUMMY_DEVICE_NAME9, + DUMMY_TOKEN_NEEDED9, + DUMMY_LIGHT_3, +) + DUMMY_SWITCHER_DEVICES = [DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE] diff --git a/tests/components/switcher_kis/test_light.py b/tests/components/switcher_kis/test_light.py index d360cb11291ff..60c851bf6a946 100644 --- a/tests/components/switcher_kis/test_light.py +++ b/tests/components/switcher_kis/test_light.py @@ -21,26 +21,43 @@ from . import init_integration from .consts import ( + DUMMY_DUAL_LIGHT_DEVICE as DEVICE4, DUMMY_DUAL_SHUTTER_SINGLE_LIGHT_DEVICE as DEVICE2, + DUMMY_LIGHT_DEVICE as DEVICE3, DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE as DEVICE, DUMMY_TOKEN as TOKEN, + DUMMY_TRIPLE_LIGHT_DEVICE as DEVICE5, DUMMY_USERNAME as USERNAME, ) ENTITY_ID = f"{LIGHT_DOMAIN}.{slugify(DEVICE.name)}_light_1" -ENTITY_ID2 = f"{LIGHT_DOMAIN}.{slugify(DEVICE.name)}_light_2" -ENTITY_ID3 = f"{LIGHT_DOMAIN}.{slugify(DEVICE2.name)}" +ENTITY_ID_2 = f"{LIGHT_DOMAIN}.{slugify(DEVICE.name)}_light_2" +ENTITY_ID2 = f"{LIGHT_DOMAIN}.{slugify(DEVICE2.name)}" +ENTITY_ID3 = f"{LIGHT_DOMAIN}.{slugify(DEVICE3.name)}" +ENTITY_ID4 = f"{LIGHT_DOMAIN}.{slugify(DEVICE4.name)}_light_1" +ENTITY_ID4_2 = f"{LIGHT_DOMAIN}.{slugify(DEVICE4.name)}_light_2" +ENTITY_ID5 = f"{LIGHT_DOMAIN}.{slugify(DEVICE5.name)}_light_1" +ENTITY_ID5_2 = f"{LIGHT_DOMAIN}.{slugify(DEVICE5.name)}_light_2" +ENTITY_ID5_3 = f"{LIGHT_DOMAIN}.{slugify(DEVICE5.name)}_light_3" @pytest.mark.parametrize( ("device", "entity_id", "light_id", "device_state"), [ (DEVICE, ENTITY_ID, 0, [DeviceState.OFF, DeviceState.ON]), - (DEVICE, ENTITY_ID2, 1, [DeviceState.ON, DeviceState.OFF]), - (DEVICE2, ENTITY_ID3, 0, [DeviceState.OFF]), + (DEVICE, ENTITY_ID_2, 1, [DeviceState.ON, DeviceState.OFF]), + (DEVICE2, ENTITY_ID2, 0, [DeviceState.OFF]), + (DEVICE3, ENTITY_ID3, 0, [DeviceState.OFF]), + (DEVICE4, ENTITY_ID4, 0, [DeviceState.OFF, DeviceState.ON]), + (DEVICE4, ENTITY_ID4_2, 1, [DeviceState.ON, DeviceState.OFF]), + (DEVICE5, ENTITY_ID5, 0, [DeviceState.OFF, DeviceState.ON, DeviceState.ON]), + (DEVICE5, ENTITY_ID5_2, 1, [DeviceState.ON, DeviceState.OFF, DeviceState.ON]), + (DEVICE5, ENTITY_ID5_3, 2, [DeviceState.ON, DeviceState.ON, DeviceState.OFF]), ], ) -@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2]], indirect=True) +@pytest.mark.parametrize( + "mock_bridge", [[DEVICE, DEVICE2, DEVICE3, DEVICE4, DEVICE5]], indirect=True +) async def test_light( hass: HomeAssistant, mock_bridge, @@ -98,11 +115,19 @@ async def test_light( ("device", "entity_id", "light_id", "device_state"), [ (DEVICE, ENTITY_ID, 0, [DeviceState.OFF, DeviceState.ON]), - (DEVICE, ENTITY_ID2, 1, [DeviceState.ON, DeviceState.OFF]), - (DEVICE2, ENTITY_ID3, 0, [DeviceState.OFF]), + (DEVICE, ENTITY_ID_2, 1, [DeviceState.ON, DeviceState.OFF]), + (DEVICE2, ENTITY_ID2, 0, [DeviceState.OFF]), + (DEVICE3, ENTITY_ID3, 0, [DeviceState.OFF]), + (DEVICE4, ENTITY_ID4, 0, [DeviceState.OFF, DeviceState.ON]), + (DEVICE4, ENTITY_ID4_2, 1, [DeviceState.ON, DeviceState.OFF]), + (DEVICE5, ENTITY_ID5, 0, [DeviceState.OFF, DeviceState.ON, DeviceState.ON]), + (DEVICE5, ENTITY_ID5_2, 1, [DeviceState.ON, DeviceState.OFF, DeviceState.ON]), + (DEVICE5, ENTITY_ID5_3, 2, [DeviceState.ON, DeviceState.ON, DeviceState.OFF]), ], ) -@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +@pytest.mark.parametrize( + "mock_bridge", [[DEVICE, DEVICE2, DEVICE3, DEVICE4, DEVICE5]], indirect=True +) async def test_light_control_fail( hass: HomeAssistant, mock_bridge, From 0d19e85a0d8ff03d7d725956fc86c7ea3a0199b1 Mon Sep 17 00:00:00 2001 From: YogevBokobza Date: Fri, 8 Nov 2024 02:59:30 +0200 Subject: [PATCH 0242/1070] Align Switcher cover platform with changes from light platform (#130094) Switcher small fix for cover --- .../components/switcher_kis/cover.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index c56fa7442fb46..dc3b6d96aed19 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -41,16 +41,20 @@ async def async_setup_entry( def async_add_cover(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add cover from Switcher device.""" entities: list[CoverEntity] = [] + if coordinator.data.device_type.category in ( DeviceCategory.SHUTTER, DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, + DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT, ): - entities.append(SwitcherSingleCoverEntity(coordinator, 0)) - if ( - coordinator.data.device_type.category - == DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT - ): - entities.extend(SwitcherDualCoverEntity(coordinator, i) for i in range(2)) + number_of_covers = len(cast(SwitcherShutter, coordinator.data).position) + if number_of_covers == 1: + entities.append(SwitcherSingleCoverEntity(coordinator, 0)) + else: + entities.extend( + SwitcherMultiCoverEntity(coordinator, i) + for i in range(number_of_covers) + ) async_add_entities(entities) config_entry.async_on_unload( @@ -152,8 +156,8 @@ def __init__( self._update_data() -class SwitcherDualCoverEntity(SwitcherBaseCoverEntity): - """Representation of a Switcher dual cover entity.""" +class SwitcherMultiCoverEntity(SwitcherBaseCoverEntity): + """Representation of a Switcher multiple cover entity.""" _attr_translation_key = "cover" From e407b4730d8d6fc612d3fc25526b6c2811ac1130 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 7 Nov 2024 20:03:07 -0800 Subject: [PATCH 0243/1070] Fix `KeyError` in nest integration when the old key format does not exist (#130057) * Fix bug in nest setup when the old key format does not exist * Further simplify the entry.data check * Update homeassistant/components/nest/api.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/nest/api.py | 5 ++--- tests/components/nest/common.py | 12 ++++++++++++ tests/components/nest/test_init.py | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index aa359dcd16759..5c65a70c75dfc 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -114,9 +114,8 @@ async def new_subscriber( implementation, config_entry_oauth2_flow.LocalOAuth2Implementation ): raise TypeError(f"Unexpected auth implementation {implementation}") - subscription_name = entry.data.get( - CONF_SUBSCRIPTION_NAME, entry.data[CONF_SUBSCRIBER_ID] - ) + if (subscription_name := entry.data.get(CONF_SUBSCRIPTION_NAME)) is None: + subscription_name = entry.data[CONF_SUBSCRIBER_ID] auth = AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation), diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index 9c8de0224f0d4..5d4719918a670 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -30,6 +30,7 @@ CLIENT_SECRET = "some-client-secret" CLOUD_PROJECT_ID = "cloud-id-9876" SUBSCRIBER_ID = "projects/cloud-id-9876/subscriptions/subscriber-id-9876" +SUBSCRIPTION_NAME = "projects/cloud-id-9876/subscriptions/subscriber-id-9876" @dataclass @@ -86,6 +87,17 @@ class NestTestConfig: }, ) +TEST_CONFIG_NEW_SUBSCRIPTION = NestTestConfig( + config_entry_data={ + "sdm": {}, + "project_id": PROJECT_ID, + "cloud_project_id": CLOUD_PROJECT_ID, + "subscription_name": SUBSCRIPTION_NAME, + "auth_implementation": "imported-cred", + }, + credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), +) + class FakeSubscriber(GoogleNestSubscriber): """Fake subscriber that supplies a FakeDeviceManager.""" diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index 4c238683130d9..a17803a6cdedd 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -31,6 +31,7 @@ SUBSCRIBER_ID, TEST_CONFIG_ENTRY_LEGACY, TEST_CONFIG_LEGACY, + TEST_CONFIG_NEW_SUBSCRIPTION, TEST_CONFIGFLOW_APP_CREDS, FakeSubscriber, PlatformSetup, @@ -97,6 +98,19 @@ async def test_setup_success( assert entries[0].state is ConfigEntryState.LOADED +@pytest.mark.parametrize("nest_test_config", [(TEST_CONFIG_NEW_SUBSCRIPTION)]) +async def test_setup_success_new_subscription_format( + hass: HomeAssistant, error_caplog: pytest.LogCaptureFixture, setup_platform +) -> None: + """Test successful setup.""" + await setup_platform() + assert not error_caplog.records + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + @pytest.mark.parametrize("subscriber_id", [("invalid-subscriber-format")]) async def test_setup_configuration_failure( hass: HomeAssistant, From 2b7d593ebea7a6c6d7de008f8c8c9218fedd51c2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 8 Nov 2024 07:45:16 +0100 Subject: [PATCH 0244/1070] Avoid collision when replacing existing config entry with same unique id (#130062) --- homeassistant/config_entries.py | 36 ++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a41f4f2470199..0d4cc5fd102bd 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1507,10 +1507,14 @@ async def async_finish_flow( version=result["version"], ) + if existing_entry is not None: + # Unload and remove the existing entry + await self.config_entries._async_remove(existing_entry.entry_id) # noqa: SLF001 await self.config_entries.async_add(entry) if existing_entry is not None: - await self.config_entries.async_remove(existing_entry.entry_id) + # Clean up devices and entities belonging to the existing entry + self.config_entries._async_clean_up(existing_entry) # noqa: SLF001 result["result"] = entry return result @@ -1900,7 +1904,21 @@ async def async_add(self, entry: ConfigEntry) -> None: self._async_schedule_save() async def async_remove(self, entry_id: str) -> dict[str, Any]: - """Remove an entry.""" + """Remove, unload and clean up after an entry.""" + unload_success, entry = await self._async_remove(entry_id) + self._async_clean_up(entry) + + for discovery_domain in entry.discovery_keys: + async_dispatcher_send_internal( + self.hass, + signal_discovered_config_entry_removed(discovery_domain), + entry, + ) + + return {"require_restart": not unload_success} + + async def _async_remove(self, entry_id: str) -> tuple[bool, ConfigEntry]: + """Remove and unload an entry.""" if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry @@ -1916,6 +1934,13 @@ async def async_remove(self, entry_id: str) -> dict[str, Any]: self.async_update_issues() self._async_schedule_save() + return (unload_success, entry) + + @callback + def _async_clean_up(self, entry: ConfigEntry) -> None: + """Clean up after an entry.""" + entry_id = entry.entry_id + dev_reg = device_registry.async_get(self.hass) ent_reg = entity_registry.async_get(self.hass) @@ -1934,13 +1959,6 @@ async def async_remove(self, entry_id: str) -> dict[str, Any]: ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) self._async_dispatch(ConfigEntryChange.REMOVED, entry) - for discovery_domain in entry.discovery_keys: - async_dispatcher_send_internal( - self.hass, - signal_discovered_config_entry_removed(discovery_domain), - entry, - ) - return {"require_restart": not unload_success} @callback def _async_shutdown(self, event: Event) -> None: From d1dab83f10b4781c970b8d7478bf9dfa76cf46cb Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 8 Nov 2024 08:22:47 +0100 Subject: [PATCH 0245/1070] Merge both stun server into one as it's the same server only on a different port (#130019) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/camera/__init__.py | 8 ++++++-- tests/components/camera/test_webrtc.py | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 6d65ea255c717..d31d21d424c84 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -421,8 +421,12 @@ def get_ice_servers() -> list[RTCIceServer]: if hass.config.webrtc.ice_servers: return hass.config.webrtc.ice_servers return [ - RTCIceServer(urls="stun:stun.home-assistant.io:80"), - RTCIceServer(urls="stun:stun.home-assistant.io:3478"), + RTCIceServer( + urls=[ + "stun:stun.home-assistant.io:80", + "stun:stun.home-assistant.io:3478", + ] + ), ] async_register_ice_servers(hass, get_ice_servers) diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 7a1df556c20af..ba5cf35c52f35 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -296,8 +296,12 @@ async def test_ws_get_client_config( assert msg["result"] == { "configuration": { "iceServers": [ - {"urls": "stun:stun.home-assistant.io:80"}, - {"urls": "stun:stun.home-assistant.io:3478"}, + { + "urls": [ + "stun:stun.home-assistant.io:80", + "stun:stun.home-assistant.io:3478", + ] + }, ], }, "getCandidatesUpfront": False, @@ -326,8 +330,12 @@ def get_ice_server() -> list[RTCIceServer]: assert msg["result"] == { "configuration": { "iceServers": [ - {"urls": "stun:stun.home-assistant.io:80"}, - {"urls": "stun:stun.home-assistant.io:3478"}, + { + "urls": [ + "stun:stun.home-assistant.io:80", + "stun:stun.home-assistant.io:3478", + ] + }, { "urls": ["stun:example2.com", "turn:example2.com"], "username": "user", From fa61e02207d4e92a87aeaab71b04d9d9e4a10700 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Nov 2024 01:36:30 -0600 Subject: [PATCH 0246/1070] Bump aiohttp to 3.11.0b4 (#130097) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e2b04c48b3073..9b91c338bf6a0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.11.0b3 +aiohttp==3.11.0b4 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index a96cb3b405be8..4ca6d21178828 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.0b3", + "aiohttp==3.11.0b4", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index ef0a423467aee..0902ca9813d1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.0b3 +aiohttp==3.11.0b4 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From ce94073321259d8e0c27ce6ddbc572626170bf36 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 8 Nov 2024 02:39:41 -0500 Subject: [PATCH 0247/1070] Bump python-roborock to 2.7.2 (#130100) --- homeassistant/components/roborock/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- .../roborock/snapshots/test_diagnostics.ambr | 16 ++++++++++++++++ 6 files changed, 21 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 79a9bf77578f9..c305e4710fcea 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.6.1", + "python-roborock==2.7.2", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9b91c338bf6a0..f83322e045f4a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -168,7 +168,7 @@ get-mac==1000000000.0.0 charset-normalizer==3.4.0 # dacite: Ensure we have a version that is able to handle type unions for -# Roborock, NAM, Brother, and GIOS. +# NAM, Brother, and GIOS. dacite>=1.7.0 # chacha20poly1305-reuseable==0.12.x is incompatible with cryptography==43.0.x diff --git a/requirements_all.txt b/requirements_all.txt index 3641d949e0da0..bc74ea16ce546 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2396,7 +2396,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.6.1 +python-roborock==2.7.2 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2cc01f44c6560..a568f1633757b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1917,7 +1917,7 @@ python-picnic-api==1.1.0 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.6.1 +python-roborock==2.7.2 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 352b209c5fc7a..4a3408632407a 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -183,7 +183,7 @@ charset-normalizer==3.4.0 # dacite: Ensure we have a version that is able to handle type unions for -# Roborock, NAM, Brother, and GIOS. +# NAM, Brother, and GIOS. dacite>=1.7.0 # chacha20poly1305-reuseable==0.12.x is incompatible with cryptography==43.0.x diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 805a498041a63..26ecb729312e0 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -102,6 +102,7 @@ 'id': '120', 'mode': 'ro', 'name': '错误代码', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -109,6 +110,7 @@ 'id': '121', 'mode': 'ro', 'name': '设备状态', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -116,6 +118,7 @@ 'id': '122', 'mode': 'ro', 'name': '设备电量', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -123,6 +126,7 @@ 'id': '123', 'mode': 'rw', 'name': '清扫模式', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -130,6 +134,7 @@ 'id': '124', 'mode': 'rw', 'name': '拖地模式', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -137,6 +142,7 @@ 'id': '125', 'mode': 'rw', 'name': '主刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -144,6 +150,7 @@ 'id': '126', 'mode': 'rw', 'name': '边刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -151,6 +158,7 @@ 'id': '127', 'mode': 'rw', 'name': '滤网寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -381,6 +389,7 @@ 'id': '120', 'mode': 'ro', 'name': '错误代码', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -388,6 +397,7 @@ 'id': '121', 'mode': 'ro', 'name': '设备状态', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -395,6 +405,7 @@ 'id': '122', 'mode': 'ro', 'name': '设备电量', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -402,6 +413,7 @@ 'id': '123', 'mode': 'rw', 'name': '清扫模式', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -409,6 +421,7 @@ 'id': '124', 'mode': 'rw', 'name': '拖地模式', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -416,6 +429,7 @@ 'id': '125', 'mode': 'rw', 'name': '主刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -423,6 +437,7 @@ 'id': '126', 'mode': 'rw', 'name': '边刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -430,6 +445,7 @@ 'id': '127', 'mode': 'rw', 'name': '滤网寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ From 28832cbd3e9413d9bc4b41bec4a0c93d8cab0072 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 8 Nov 2024 08:46:48 +0100 Subject: [PATCH 0248/1070] Update frontend to 20241106.1 (#130086) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2df14df4523bd..1ac7e661abe5f 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241106.0"] + "requirements": ["home-assistant-frontend==20241106.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f83322e045f4a..9df83f3bb23e8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241106.0 +home-assistant-frontend==20241106.1 home-assistant-intents==2024.11.6 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index bc74ea16ce546..99c4191d046b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241106.0 +home-assistant-frontend==20241106.1 # homeassistant.components.conversation home-assistant-intents==2024.11.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a568f1633757b..5c54380143a8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241106.0 +home-assistant-frontend==20241106.1 # homeassistant.components.conversation home-assistant-intents==2024.11.6 From 3062bad19e5de59e43baccd2644696ffd928752b Mon Sep 17 00:00:00 2001 From: Kelvin Dekker <143089625+KelvinDekker@users.noreply.github.com> Date: Fri, 8 Nov 2024 08:47:02 +0100 Subject: [PATCH 0249/1070] Fix typo in insteon strings (#130085) --- homeassistant/components/insteon/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 1464a2dbc8f4d..4df997ac93939 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -112,7 +112,7 @@ "services": { "add_all_link": { "name": "Add all link", - "description": "Tells the Insteom Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.", + "description": "Tells the Insteon Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.", "fields": { "group": { "name": "Group", From 5d5908a03ff6ee5c0c2a20c1133ad1c30c875c98 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 8 Nov 2024 08:47:28 +0100 Subject: [PATCH 0250/1070] Add missing string to tedee plus test (#130081) --- homeassistant/components/tedee/strings.json | 3 +- tests/components/tedee/test_config_flow.py | 37 +++++++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json index 2dc0e23968c81..b6966fa2933db 100644 --- a/homeassistant/components/tedee/strings.json +++ b/homeassistant/components/tedee/strings.json @@ -38,7 +38,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "You selected a different bridge than the one this config entry was configured with, this is not allowed." }, "error": { "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py index d3654783bd6db..2e86286c8da21 100644 --- a/tests/components/tedee/test_config_flow.py +++ b/tests/components/tedee/test_config_flow.py @@ -7,10 +7,11 @@ TedeeDataUpdateException, TedeeLocalAuthException, ) +from pytedee_async.bridge import TedeeBridge import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -134,11 +135,10 @@ async def test_reauth_flow( assert result["reason"] == "reauth_successful" -async def test_reconfigure_flow( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock -) -> None: - """Test that the reconfigure flow works.""" - +async def __do_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> ConfigFlowResult: + """Initialize a reconfigure flow.""" mock_config_entry.add_to_hass(hass) reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) @@ -146,11 +146,19 @@ async def test_reconfigure_flow( assert reconfigure_result["type"] is FlowResultType.FORM assert reconfigure_result["step_id"] == "reconfigure" - result = await hass.config_entries.flow.async_configure( + return await hass.config_entries.flow.async_configure( reconfigure_result["flow_id"], {CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, CONF_HOST: "192.168.1.43"}, ) + +async def test_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock +) -> None: + """Test that the reconfigure flow works.""" + + result = await __do_reconfigure_flow(hass, mock_config_entry) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" @@ -162,3 +170,18 @@ async def test_reconfigure_flow( CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, CONF_WEBHOOK_ID: WEBHOOK_ID, } + + +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock +) -> None: + """Ensure reconfigure flow aborts when the bride changes.""" + + mock_tedee.get_local_bridge.return_value = TedeeBridge( + 0, "1111-1111", "Bridge-R2D2" + ) + + result = await __do_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" From ed1366f463521723fe4589f62403acdcaff6ea37 Mon Sep 17 00:00:00 2001 From: nasWebio <140073814+nasWebio@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:03:32 +0100 Subject: [PATCH 0251/1070] Add NASweb integration (#98118) * Add NASweb integration * Fix DeviceInfo import * Remove commented out code * Change class name for uniquness * Drop CoordinatorEntity inheritance * Rename class Output to more descriptive: RelaySwitch * Update required webio-api version * Implement on-the-fly addition/removal of entities * Set coordinator name matching device name * Set entities with too old status as unavailable * Drop Optional in favor of modern typing * Fix spelling of a variable * Rename commons to more fitting name: helper * Remove redundant code * Let unload fail when there is no coordinator * Fix bad docstring * Rename cord to coordinator for clarity * Remove default value for pop and let it raise exception * Drop workaround and use get_url from helper.network * Use webhook to send data from device * Deinitialize coordinator when no longer needed * Use Python formattable string * Use dataclass to store integration data in hass.data * Raise ConfigEntryNotReady when appropriate * Refactor NASwebData class * Move RelaySwitch to switch.py * Fix ConfigFlow tests * Create issues when entry fails to load * Respond when correctly received status update * Depend on webhook instead of http * Create issue when status is not received during entry set up * Make issue_id unique across integration entries * Remove unnecessary initializations * Inherit CoordinatorEntity to avoid code duplication * Optimize property access via assignment in __init__ * Use preexisting mechanism to fill schema with user input * Fix translation strings * Handle unavailable or unreachable internal url * Implement custom coordinator for push driven data updates * Move module-specific constants to respective modules * Fix requirements_all.txt * Fix CODEOWNERS file * Raise ConfigEntryError instead of issue creation * Fix entity registry import * Use HassKey as key in hass.data * Use typed ConfigEntry * Store runtime data in config entry * Rewrite to be more Pythonic * Move add/remove of switch entities to switch.py * Skip unnecessary check * Remove unnecessary type hints * Remove unnecessary nonlocal * Use a more descriptive docstring * Add docstrings to NASwebCoordinator * Fix formatting * Use correct return type * Fix tests to align with changed code * Remove commented code * Use serial number as config entry id * Catch AbortFlow exception * Update tests to check ConfigEntry Unique ID * Remove unnecessary form abort --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/nasweb/__init__.py | 125 +++++++++++ .../components/nasweb/config_flow.py | 137 ++++++++++++ homeassistant/components/nasweb/const.py | 7 + .../components/nasweb/coordinator.py | 191 ++++++++++++++++ homeassistant/components/nasweb/manifest.json | 14 ++ .../components/nasweb/nasweb_data.py | 64 ++++++ homeassistant/components/nasweb/strings.json | 50 +++++ homeassistant/components/nasweb/switch.py | 133 +++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/nasweb/__init__.py | 1 + tests/components/nasweb/conftest.py | 61 +++++ tests/components/nasweb/test_config_flow.py | 208 ++++++++++++++++++ 18 files changed, 1017 insertions(+) create mode 100644 homeassistant/components/nasweb/__init__.py create mode 100644 homeassistant/components/nasweb/config_flow.py create mode 100644 homeassistant/components/nasweb/const.py create mode 100644 homeassistant/components/nasweb/coordinator.py create mode 100644 homeassistant/components/nasweb/manifest.json create mode 100644 homeassistant/components/nasweb/nasweb_data.py create mode 100644 homeassistant/components/nasweb/strings.json create mode 100644 homeassistant/components/nasweb/switch.py create mode 100644 tests/components/nasweb/__init__.py create mode 100644 tests/components/nasweb/conftest.py create mode 100644 tests/components/nasweb/test_config_flow.py diff --git a/.strict-typing b/.strict-typing index 6a6918543ad54..a980c0901d060 100644 --- a/.strict-typing +++ b/.strict-typing @@ -330,6 +330,7 @@ homeassistant.components.mysensors.* homeassistant.components.myuplink.* homeassistant.components.nam.* homeassistant.components.nanoleaf.* +homeassistant.components.nasweb.* homeassistant.components.neato.* homeassistant.components.nest.* homeassistant.components.netatmo.* diff --git a/CODEOWNERS b/CODEOWNERS index d039097fc82fd..e41267860d83c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -970,6 +970,8 @@ build.json @home-assistant/supervisor /tests/components/nam/ @bieniu /homeassistant/components/nanoleaf/ @milanmeu @joostlek /tests/components/nanoleaf/ @milanmeu @joostlek +/homeassistant/components/nasweb/ @nasWebio +/tests/components/nasweb/ @nasWebio /homeassistant/components/neato/ @Santobert /tests/components/neato/ @Santobert /homeassistant/components/nederlandse_spoorwegen/ @YarmoM diff --git a/homeassistant/components/nasweb/__init__.py b/homeassistant/components/nasweb/__init__.py new file mode 100644 index 0000000000000..1992cc41c75a7 --- /dev/null +++ b/homeassistant/components/nasweb/__init__.py @@ -0,0 +1,125 @@ +"""The NASweb integration.""" + +from __future__ import annotations + +import logging + +from webio_api import WebioAPI +from webio_api.api_client import AuthError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.network import NoURLAvailableError +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN, MANUFACTURER, SUPPORT_EMAIL +from .coordinator import NASwebCoordinator +from .nasweb_data import NASwebData + +PLATFORMS: list[Platform] = [Platform.SWITCH] + +NASWEB_CONFIG_URL = "https://{host}/page" + +_LOGGER = logging.getLogger(__name__) +type NASwebConfigEntry = ConfigEntry[NASwebCoordinator] +DATA_NASWEB: HassKey[NASwebData] = HassKey(DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bool: + """Set up NASweb from a config entry.""" + + if DATA_NASWEB not in hass.data: + data = NASwebData() + data.initialize(hass) + hass.data[DATA_NASWEB] = data + nasweb_data = hass.data[DATA_NASWEB] + + webio_api = WebioAPI( + entry.data[CONF_HOST], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + ) + try: + if not await webio_api.check_connection(): + raise ConfigEntryNotReady( + f"[{entry.data[CONF_HOST]}] Check connection failed" + ) + if not await webio_api.refresh_device_info(): + _LOGGER.error("[%s] Refresh device info failed", entry.data[CONF_HOST]) + raise ConfigEntryError( + translation_key="config_entry_error_internal_error", + translation_placeholders={"support_email": SUPPORT_EMAIL}, + ) + webio_serial = webio_api.get_serial_number() + if webio_serial is None: + _LOGGER.error("[%s] Serial number not available", entry.data[CONF_HOST]) + raise ConfigEntryError( + translation_key="config_entry_error_internal_error", + translation_placeholders={"support_email": SUPPORT_EMAIL}, + ) + if entry.unique_id != webio_serial: + _LOGGER.error( + "[%s] Serial number doesn't match config entry", entry.data[CONF_HOST] + ) + raise ConfigEntryError(translation_key="config_entry_error_serial_mismatch") + + coordinator = NASwebCoordinator( + hass, webio_api, name=f"NASweb[{webio_api.get_name()}]" + ) + entry.runtime_data = coordinator + nasweb_data.notify_coordinator.add_coordinator(webio_serial, entry.runtime_data) + + webhook_url = nasweb_data.get_webhook_url(hass) + if not await webio_api.status_subscription(webhook_url, True): + _LOGGER.error("Failed to subscribe for status updates from webio") + raise ConfigEntryError( + translation_key="config_entry_error_internal_error", + translation_placeholders={"support_email": SUPPORT_EMAIL}, + ) + if not await nasweb_data.notify_coordinator.check_connection(webio_serial): + _LOGGER.error("Did not receive status from device") + raise ConfigEntryError( + translation_key="config_entry_error_no_status_update", + translation_placeholders={"support_email": SUPPORT_EMAIL}, + ) + except TimeoutError as error: + raise ConfigEntryNotReady( + f"[{entry.data[CONF_HOST]}] Check connection reached timeout" + ) from error + except AuthError as error: + raise ConfigEntryError( + translation_key="config_entry_error_invalid_authentication" + ) from error + except NoURLAvailableError as error: + raise ConfigEntryError( + translation_key="config_entry_error_missing_internal_url" + ) from error + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, webio_serial)}, + manufacturer=MANUFACTURER, + name=webio_api.get_name(), + configuration_url=NASWEB_CONFIG_URL.format(host=entry.data[CONF_HOST]), + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + nasweb_data = hass.data[DATA_NASWEB] + coordinator = entry.runtime_data + serial = entry.unique_id + if serial is not None: + nasweb_data.notify_coordinator.remove_coordinator(serial) + if nasweb_data.can_be_deinitialized(): + nasweb_data.deinitialize(hass) + hass.data.pop(DATA_NASWEB) + webhook_url = nasweb_data.get_webhook_url(hass) + await coordinator.webio_api.status_subscription(webhook_url, False) + + return unload_ok diff --git a/homeassistant/components/nasweb/config_flow.py b/homeassistant/components/nasweb/config_flow.py new file mode 100644 index 0000000000000..3a9ad3f7d498e --- /dev/null +++ b/homeassistant/components/nasweb/config_flow.py @@ -0,0 +1,137 @@ +"""Config flow for NASweb integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +from webio_api import WebioAPI +from webio_api.api_client import AuthError + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.network import NoURLAvailableError + +from .const import DOMAIN +from .coordinator import NASwebCoordinator +from .nasweb_data import NASwebData + +NASWEB_SCHEMA_IMG_URL = ( + "https://home-assistant.io/images/integrations/nasweb/nasweb_scheme.png" +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate user-provided data.""" + webio_api = WebioAPI(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]) + if not await webio_api.check_connection(): + raise CannotConnect + try: + await webio_api.refresh_device_info() + except AuthError as e: + raise InvalidAuth from e + + nasweb_data = NASwebData() + nasweb_data.initialize(hass) + try: + webio_serial = webio_api.get_serial_number() + if webio_serial is None: + raise MissingNASwebData("Device serial number is not available") + + coordinator = NASwebCoordinator(hass, webio_api) + webhook_url = nasweb_data.get_webhook_url(hass) + nasweb_data.notify_coordinator.add_coordinator(webio_serial, coordinator) + subscription = await webio_api.status_subscription(webhook_url, True) + if not subscription: + nasweb_data.notify_coordinator.remove_coordinator(webio_serial) + raise MissingNASwebData( + "Failed to subscribe for status updates from device" + ) + + result = await nasweb_data.notify_coordinator.check_connection(webio_serial) + nasweb_data.notify_coordinator.remove_coordinator(webio_serial) + if not result: + if subscription: + await webio_api.status_subscription(webhook_url, False) + raise MissingNASwebStatus("Did not receive status from device") + + name = webio_api.get_name() + finally: + nasweb_data.deinitialize(hass) + return {"title": name, CONF_UNIQUE_ID: webio_serial} + + +class NASwebConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for NASweb.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + await self.async_set_unique_id(info[CONF_UNIQUE_ID]) + self._abort_if_unique_id_configured() + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except NoURLAvailableError: + errors["base"] = "missing_internal_url" + except MissingNASwebData: + errors["base"] = "missing_nasweb_data" + except MissingNASwebStatus: + errors["base"] = "missing_status" + except AbortFlow: + raise + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + description_placeholders={ + "nasweb_schema_img": '
', + }, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class MissingNASwebData(HomeAssistantError): + """Error to indicate missing information from NASweb.""" + + +class MissingNASwebStatus(HomeAssistantError): + """Error to indicate there was no status received from NASweb.""" diff --git a/homeassistant/components/nasweb/const.py b/homeassistant/components/nasweb/const.py new file mode 100644 index 0000000000000..ec750c90c8c69 --- /dev/null +++ b/homeassistant/components/nasweb/const.py @@ -0,0 +1,7 @@ +"""Constants for the NASweb integration.""" + +DOMAIN = "nasweb" +MANUFACTURER = "chomtech.pl" +STATUS_UPDATE_MAX_TIME_INTERVAL = 60 +SUPPORT_EMAIL = "support@chomtech.eu" +WEBHOOK_URL = "{internal_url}/api/webhook/{webhook_id}" diff --git a/homeassistant/components/nasweb/coordinator.py b/homeassistant/components/nasweb/coordinator.py new file mode 100644 index 0000000000000..90dca0f302214 --- /dev/null +++ b/homeassistant/components/nasweb/coordinator.py @@ -0,0 +1,191 @@ +"""Message routing coordinators for handling NASweb push notifications.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from datetime import datetime, timedelta +import logging +import time +from typing import Any + +from aiohttp.web import Request, Response +from webio_api import WebioAPI +from webio_api.const import KEY_DEVICE_SERIAL, KEY_OUTPUTS, KEY_TYPE, TYPE_STATUS_UPDATE + +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers import event +from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol + +from .const import STATUS_UPDATE_MAX_TIME_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class NotificationCoordinator: + """Coordinator redirecting push notifications for this integration to appropriate NASwebCoordinator.""" + + def __init__(self) -> None: + """Initialize coordinator.""" + self._coordinators: dict[str, NASwebCoordinator] = {} + + def add_coordinator(self, serial: str, coordinator: NASwebCoordinator) -> None: + """Add NASwebCoordinator to possible notification targets.""" + self._coordinators[serial] = coordinator + _LOGGER.debug("Added NASwebCoordinator for NASweb[%s]", serial) + + def remove_coordinator(self, serial: str) -> None: + """Remove NASwebCoordinator from possible notification targets.""" + self._coordinators.pop(serial) + _LOGGER.debug("Removed NASwebCoordinator for NASweb[%s]", serial) + + def has_coordinators(self) -> bool: + """Check if there is any registered coordinator for push notifications.""" + return len(self._coordinators) > 0 + + async def check_connection(self, serial: str) -> bool: + """Wait for first status update to confirm connection with NASweb.""" + nasweb_coordinator = self._coordinators.get(serial) + if nasweb_coordinator is None: + _LOGGER.error("Cannot check connection. No device match serial number") + return False + for counter in range(10): + _LOGGER.debug("Checking connection with: %s (%s)", serial, counter) + if nasweb_coordinator.is_connection_confirmed(): + return True + await asyncio.sleep(1) + return False + + async def handle_webhook_request( + self, hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + """Handle webhook request from Push API.""" + if not self.has_coordinators(): + return None + notification = await request.json() + serial = notification.get(KEY_DEVICE_SERIAL, None) + _LOGGER.debug("Received push: %s", notification) + if serial is None: + _LOGGER.warning("Received notification without nasweb identifier") + return None + nasweb_coordinator = self._coordinators.get(serial) + if nasweb_coordinator is None: + _LOGGER.warning("Received notification for not registered nasweb") + return None + await nasweb_coordinator.handle_push_notification(notification) + return Response(body='{"response": "ok"}', content_type="application/json") + + +class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol): + """Coordinator managing status of single NASweb device. + + Since status updates are managed through push notifications, this class schedules + periodic checks to ensure that devices are marked unavailable if updates + haven't been received for a prolonged period. + """ + + def __init__( + self, hass: HomeAssistant, webio_api: WebioAPI, name: str = "NASweb[default]" + ) -> None: + """Initialize NASweb coordinator.""" + self._hass = hass + self.name = name + self.webio_api = webio_api + self._last_update: float | None = None + job_name = f"NASwebCoordinator[{name}]" + self._job = HassJob(self._handle_max_update_interval, job_name) + self._unsub_last_update_check: CALLBACK_TYPE | None = None + self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} + data: dict[str, Any] = {} + data[KEY_OUTPUTS] = self.webio_api.outputs + self.async_set_updated_data(data) + + def is_connection_confirmed(self) -> bool: + """Check whether coordinator received status update from NASweb.""" + return self._last_update is not None + + @callback + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Listen for data updates.""" + schedule_update_check = not self._listeners + + @callback + def remove_listener() -> None: + """Remove update listener.""" + self._listeners.pop(remove_listener) + if not self._listeners: + self._async_unsub_last_update_check() + + self._listeners[remove_listener] = (update_callback, context) + # This is the first listener, set up interval. + if schedule_update_check: + self._schedule_last_update_check() + return remove_listener + + @callback + def async_set_updated_data(self, data: dict[str, Any]) -> None: + """Update data and notify listeners.""" + self.data = data + self.last_update = self._hass.loop.time() + _LOGGER.debug("Updated %s data", self.name) + if self._listeners: + self._schedule_last_update_check() + self.async_update_listeners() + + @callback + def async_update_listeners(self) -> None: + """Update all registered listeners.""" + for update_callback, _ in list(self._listeners.values()): + update_callback() + + async def _handle_max_update_interval(self, now: datetime) -> None: + """Handle max update interval occurrence. + + This method is called when `STATUS_UPDATE_MAX_TIME_INTERVAL` has passed without + receiving a status update. It only needs to trigger state update of entities + which then change their state accordingly. + """ + self._unsub_last_update_check = None + if self._listeners: + self.async_update_listeners() + + def _schedule_last_update_check(self) -> None: + """Schedule a task to trigger entities state update after `STATUS_UPDATE_MAX_TIME_INTERVAL`. + + This method schedules a task (`_handle_max_update_interval`) to be executed after + `STATUS_UPDATE_MAX_TIME_INTERVAL` seconds without status update, which enables entities + to change their state to unavailable. After each status update this task is rescheduled. + """ + self._async_unsub_last_update_check() + now = self._hass.loop.time() + next_check = ( + now + timedelta(seconds=STATUS_UPDATE_MAX_TIME_INTERVAL).total_seconds() + ) + self._unsub_last_update_check = event.async_call_at( + self._hass, + self._job, + next_check, + ) + + def _async_unsub_last_update_check(self) -> None: + """Cancel any scheduled update check call.""" + if self._unsub_last_update_check: + self._unsub_last_update_check() + self._unsub_last_update_check = None + + async def handle_push_notification(self, notification: dict) -> None: + """Handle incoming push notification from NASweb.""" + msg_type = notification.get(KEY_TYPE) + _LOGGER.debug("Received push notification: %s", msg_type) + + if msg_type == TYPE_STATUS_UPDATE: + await self.process_status_update(notification) + self._last_update = time.time() + + async def process_status_update(self, new_status: dict) -> None: + """Process status update from NASweb.""" + self.webio_api.update_device_status(new_status) + new_data = {KEY_OUTPUTS: self.webio_api.outputs} + self.async_set_updated_data(new_data) diff --git a/homeassistant/components/nasweb/manifest.json b/homeassistant/components/nasweb/manifest.json new file mode 100644 index 0000000000000..e7e06419dade1 --- /dev/null +++ b/homeassistant/components/nasweb/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "nasweb", + "name": "NASweb", + "codeowners": ["@nasWebio"], + "config_flow": true, + "dependencies": ["webhook"], + "documentation": "https://www.home-assistant.io/integrations/nasweb", + "homekit": {}, + "integration_type": "hub", + "iot_class": "local_push", + "requirements": ["webio-api==0.1.8"], + "ssdp": [], + "zeroconf": [] +} diff --git a/homeassistant/components/nasweb/nasweb_data.py b/homeassistant/components/nasweb/nasweb_data.py new file mode 100644 index 0000000000000..4f6a37e6cc74a --- /dev/null +++ b/homeassistant/components/nasweb/nasweb_data.py @@ -0,0 +1,64 @@ +"""Dataclass storing integration data in hass.data[DOMAIN].""" + +from dataclasses import dataclass, field +import logging + +from aiohttp.hdrs import METH_POST + +from homeassistant.components.webhook import ( + async_generate_id, + async_register as webhook_register, + async_unregister as webhook_unregister, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.network import get_url + +from .const import DOMAIN, WEBHOOK_URL +from .coordinator import NotificationCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class NASwebData: + """Class storing integration data.""" + + notify_coordinator: NotificationCoordinator = field( + default_factory=NotificationCoordinator + ) + webhook_id = "" + + def is_initialized(self) -> bool: + """Return True if instance was initialized and is ready for use.""" + return bool(self.webhook_id) + + def can_be_deinitialized(self) -> bool: + """Return whether this instance can be deinitialized.""" + return not self.notify_coordinator.has_coordinators() + + def initialize(self, hass: HomeAssistant) -> None: + """Initialize NASwebData instance.""" + if self.is_initialized(): + return + new_webhook_id = async_generate_id() + webhook_register( + hass, + DOMAIN, + "NASweb", + new_webhook_id, + self.notify_coordinator.handle_webhook_request, + allowed_methods=[METH_POST], + ) + self.webhook_id = new_webhook_id + _LOGGER.debug("Registered webhook: %s", self.webhook_id) + + def deinitialize(self, hass: HomeAssistant) -> None: + """Deinitialize NASwebData instance.""" + if not self.is_initialized(): + return + webhook_unregister(hass, self.webhook_id) + + def get_webhook_url(self, hass: HomeAssistant) -> str: + """Return webhook url for Push API.""" + hass_url = get_url(hass, allow_external=False) + return WEBHOOK_URL.format(internal_url=hass_url, webhook_id=self.webhook_id) diff --git a/homeassistant/components/nasweb/strings.json b/homeassistant/components/nasweb/strings.json new file mode 100644 index 0000000000000..b8af8cd54db78 --- /dev/null +++ b/homeassistant/components/nasweb/strings.json @@ -0,0 +1,50 @@ +{ + "config": { + "step": { + "user": { + "title": "Add NASweb device", + "description": "{nasweb_schema_img}NASweb combines the functions of a control panel and the ability to manage building automation. The device monitors the flow of information from sensors and programmable switches and stores settings, definitions and configured actions.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_internal_url": "Make sure Home Assistant has valid internal url", + "missing_nasweb_data": "Something isn't right with device internal configuration. Try restarting the device and HomeAssistant.", + "missing_status": "Did not received any status updates within the expected time window. Make sure the Home Assistant Internal URL is reachable from the NASweb device.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "exceptions": { + "config_entry_error_invalid_authentication": { + "message": "Invalid username/password. Most likely user changed password or was removed. Delete this entry and create new one with correct username/password." + }, + "config_entry_error_internal_error": { + "message": "Something isn't right with device internal configuration. Try restarting the device and HomeAssistant. If the issue persists contact support at {support_email}" + }, + "config_entry_error_no_status_update": { + "message": "Did not received any status updates within the expected time window. Make sure the Home Assistant Internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" + }, + "config_entry_error_missing_internal_url": { + "message": "[%key:component::nasweb::config::error::missing_internal_url%]" + }, + "serial_mismatch": { + "message": "Connected to different NASweb device (serial number mismatch)." + } + }, + "entity": { + "switch": { + "switch_output": { + "name": "Relay Switch {index}" + } + } + } +} diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py new file mode 100644 index 0000000000000..00e5a21da18dc --- /dev/null +++ b/homeassistant/components/nasweb/switch.py @@ -0,0 +1,133 @@ +"""Platform for NASweb output.""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from webio_api import Output as NASwebOutput + +from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + BaseCoordinatorEntity, + BaseDataUpdateCoordinatorProtocol, +) + +from . import NASwebConfigEntry +from .const import DOMAIN, STATUS_UPDATE_MAX_TIME_INTERVAL +from .coordinator import NASwebCoordinator + +OUTPUT_TRANSLATION_KEY = "switch_output" + +_LOGGER = logging.getLogger(__name__) + + +def _get_output(coordinator: NASwebCoordinator, index: int) -> NASwebOutput | None: + for out in coordinator.webio_api.outputs: + if out.index == index: + return out + return None + + +async def async_setup_entry( + hass: HomeAssistant, + config: NASwebConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up switch platform.""" + coordinator = config.runtime_data + current_outputs: set[int] = set() + + @callback + def _check_entities() -> None: + received_outputs = {out.index for out in coordinator.webio_api.outputs} + added = {i for i in received_outputs if i not in current_outputs} + removed = {i for i in current_outputs if i not in received_outputs} + entities_to_add: list[RelaySwitch] = [] + for index in added: + webio_output = _get_output(coordinator, index) + if not isinstance(webio_output, NASwebOutput): + _LOGGER.error("Cannot create RelaySwitch entity without NASwebOutput") + continue + new_output = RelaySwitch(coordinator, webio_output) + entities_to_add.append(new_output) + current_outputs.add(index) + async_add_entities(entities_to_add) + entity_registry = er.async_get(hass) + for index in removed: + unique_id = f"{DOMAIN}.{config.unique_id}.relay_switch.{index}" + if entity_id := entity_registry.async_get_entity_id( + DOMAIN_SWITCH, DOMAIN, unique_id + ): + entity_registry.async_remove(entity_id) + current_outputs.remove(index) + else: + _LOGGER.warning("Failed to remove old output: no entity_id") + + coordinator.async_add_listener(_check_entities) + _check_entities() + + +class RelaySwitch(SwitchEntity, BaseCoordinatorEntity): + """Entity representing NASweb Output.""" + + def __init__( + self, + coordinator: BaseDataUpdateCoordinatorProtocol, + nasweb_output: NASwebOutput, + ) -> None: + """Initialize RelaySwitch.""" + super().__init__(coordinator) + self._output = nasweb_output + self._attr_icon = "mdi:export" + self._attr_has_entity_name = True + self._attr_translation_key = OUTPUT_TRANSLATION_KEY + self._attr_translation_placeholders = {"index": f"{nasweb_output.index:2d}"} + self._attr_unique_id = ( + f"{DOMAIN}.{self._output.webio_serial}.relay_switch.{self._output.index}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._output.webio_serial)}, + ) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_on = self._output.state + if ( + self.coordinator.last_update is None + or time.time() - self._output.last_update >= STATUS_UPDATE_MAX_TIME_INTERVAL + ): + self._attr_available = False + else: + self._attr_available = ( + self._output.available if self._output.available is not None else False + ) + self.async_write_ha_state() + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + Scheduling updates is not necessary, the coordinator takes care of updates via push notifications. + """ + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn On RelaySwitch.""" + await self._output.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn Off RelaySwitch.""" + await self._output.turn_off() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 923b2ec1606df..887fb99a0929c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -391,6 +391,7 @@ "myuplink", "nam", "nanoleaf", + "nasweb", "neato", "nest", "netatmo", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 449d36da4749a..14b8550d296e8 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4016,6 +4016,12 @@ "config_flow": true, "iot_class": "local_push" }, + "nasweb": { + "name": "NASweb", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "neato": { "name": "Neato Botvac", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index c851e586246ee..15d1777f38163 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3056,6 +3056,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.nasweb.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.neato.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 99c4191d046b7..627d9937995ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2977,6 +2977,9 @@ weatherflow4py==1.0.6 # homeassistant.components.cisco_webex_teams webexpythonsdk==2.0.1 +# homeassistant.components.nasweb +webio-api==0.1.8 + # homeassistant.components.webmin webmin-xmlrpc==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c54380143a8b..b726627f1d6e3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2372,6 +2372,9 @@ watchdog==2.3.1 # homeassistant.components.weatherflow_cloud weatherflow4py==1.0.6 +# homeassistant.components.nasweb +webio-api==0.1.8 + # homeassistant.components.webmin webmin-xmlrpc==0.0.2 diff --git a/tests/components/nasweb/__init__.py b/tests/components/nasweb/__init__.py new file mode 100644 index 0000000000000..d4906d710d5f6 --- /dev/null +++ b/tests/components/nasweb/__init__.py @@ -0,0 +1 @@ +"""Tests for the NASweb integration.""" diff --git a/tests/components/nasweb/conftest.py b/tests/components/nasweb/conftest.py new file mode 100644 index 0000000000000..7757f40ee44df --- /dev/null +++ b/tests/components/nasweb/conftest.py @@ -0,0 +1,61 @@ +"""Common fixtures for the NASweb tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nasweb.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +BASE_CONFIG_FLOW = "homeassistant.components.nasweb.config_flow." +BASE_NASWEB_DATA = "homeassistant.components.nasweb.nasweb_data." +BASE_COORDINATOR = "homeassistant.components.nasweb.coordinator." +TEST_SERIAL_NUMBER = "0011223344556677" + + +@pytest.fixture +def validate_input_all_ok() -> Generator[dict[str, AsyncMock | MagicMock]]: + """Yield dictionary of mocked functions required for successful test_form execution.""" + with ( + patch( + BASE_CONFIG_FLOW + "WebioAPI.check_connection", + return_value=True, + ) as check_connection, + patch( + BASE_CONFIG_FLOW + "WebioAPI.refresh_device_info", + return_value=True, + ) as refresh_device_info, + patch( + BASE_NASWEB_DATA + "NASwebData.get_webhook_url", + return_value="http://127.0.0.1:8123/api/webhook/de705e77291402afa0dd961426e9f19bb53631a9f2a106c52cfd2d2266913c04", + ) as get_webhook_url, + patch( + BASE_CONFIG_FLOW + "WebioAPI.get_serial_number", + return_value=TEST_SERIAL_NUMBER, + ) as get_serial, + patch( + BASE_CONFIG_FLOW + "WebioAPI.status_subscription", + return_value=True, + ) as status_subscription, + patch( + BASE_NASWEB_DATA + "NotificationCoordinator.check_connection", + return_value=True, + ) as check_status_confirmation, + ): + yield { + BASE_CONFIG_FLOW + "WebioAPI.check_connection": check_connection, + BASE_CONFIG_FLOW + "WebioAPI.refresh_device_info": refresh_device_info, + BASE_NASWEB_DATA + "NASwebData.get_webhook_url": get_webhook_url, + BASE_CONFIG_FLOW + "WebioAPI.get_serial_number": get_serial, + BASE_CONFIG_FLOW + "WebioAPI.status_subscription": status_subscription, + BASE_NASWEB_DATA + + "NotificationCoordinator.check_connection": check_status_confirmation, + } diff --git a/tests/components/nasweb/test_config_flow.py b/tests/components/nasweb/test_config_flow.py new file mode 100644 index 0000000000000..a5f2dca680d69 --- /dev/null +++ b/tests/components/nasweb/test_config_flow.py @@ -0,0 +1,208 @@ +"""Test the NASweb config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from webio_api.api_client import AuthError + +from homeassistant import config_entries +from homeassistant.components.nasweb.const import DOMAIN +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.network import NoURLAvailableError + +from .conftest import ( + BASE_CONFIG_FLOW, + BASE_COORDINATOR, + BASE_NASWEB_DATA, + TEST_SERIAL_NUMBER, +) + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +TEST_USER_INPUT = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + + +async def _add_test_config_entry(hass: HomeAssistant) -> ConfigFlowResult: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + await hass.async_block_till_done() + return result2 + + +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test the form.""" + result = await _add_test_config_entry(hass) + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("title") == "1.1.1.1" + assert result.get("data") == TEST_USER_INPUT + + config_entry = result.get("result") + assert config_entry is not None + assert config_entry.unique_id == TEST_SERIAL_NUMBER + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch(BASE_CONFIG_FLOW + "WebioAPI.check_connection", return_value=False): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "cannot_connect"} + + +async def test_form_invalid_auth( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + BASE_CONFIG_FLOW + "WebioAPI.refresh_device_info", + side_effect=AuthError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "invalid_auth"} + + +async def test_form_missing_internal_url( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test missing internal url.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + BASE_NASWEB_DATA + "NASwebData.get_webhook_url", side_effect=NoURLAvailableError + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "missing_internal_url"} + + +async def test_form_missing_nasweb_data( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + BASE_CONFIG_FLOW + "WebioAPI.get_serial_number", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "missing_nasweb_data"} + with patch(BASE_CONFIG_FLOW + "WebioAPI.status_subscription", return_value=False): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "missing_nasweb_data"} + + +async def test_missing_status( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test missing status update.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + BASE_COORDINATOR + "NotificationCoordinator.check_connection", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "missing_status"} + + +async def test_form_exception( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test other exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nasweb.config_flow.validate_input", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "unknown"} + + +async def test_form_already_configured( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test already configured device.""" + result = await _add_test_config_entry(hass) + config_entry = result.get("result") + assert config_entry is not None + assert config_entry.unique_id == TEST_SERIAL_NUMBER + + result2_1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2_2 = await hass.config_entries.flow.async_configure( + result2_1["flow_id"], TEST_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2_2.get("type") == FlowResultType.ABORT + assert result2_2.get("reason") == "already_configured" From e3dfa84d6503ba7534d9a3294c55898dfd318696 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Fri, 8 Nov 2024 12:06:29 +0100 Subject: [PATCH 0252/1070] Bang & Olufsen add beolink grouping (#113438) * Add Beolink custom services Add support for media player grouping via beolink Give media player entity name * Fix progress not being set to None as Beolink listener Revert naming changes * Update API simplify Beolink attributes * Improve beolink custom services * Fix Beolink expandable source check Add unexpand return value Set entity name on initialization * Handle entity naming as intended * Fix "null" Beolink self friendly name * Add regex service input validation Add all_discovered to beolink_expand service Improve beolink_expand response * Add service icons * Fix merge Remove unnecessary assignment * Remove invalid typing Update response typing for updated API * Revert to old typed response dict method Remove mypy ignore line Fix jid possibly used before assignment * Re add debugging logging * Fix coroutine Fix formatting * Remove unnecessary update control * Make tests pass Fix remote leader media position bug Improve remote leader BangOlufsenSource comparison * Fix naming and add callback decorators * Move regex service check to variable Suppress KeyError Update tests * Re-add hass running check * Improve comments, naming and type hinting * Remove old temporary fix * Convert logged warning to raised exception for invalid media_player Simplify code using walrus operator * Fix test for invalid media_player grouping * Improve method naming * Improve _beolink_sources explanation * Improve _beolink_sources explanation * Fix tests * Remove service responses Fix and add tests * Change service to action where applicable * Show playback progress for listeners * Fix testing * Remove useless initialization * Fix allstandby name * Fix various casts with assertions Fix comment placement Fix group leader group_members rebase error Replace entity_id method call with attribute * Add syrupy snapshots for Beolink tests, checking entity states Use test JIDs 3 and 4 instead of 2 and 3 to avoid invalid attributes in testing * Add sections for fields using Beolink JIDs directly * Fix typo * FIx rebase mistake * Sort actions alphabetically --- .../components/bang_olufsen/icons.json | 9 + .../components/bang_olufsen/media_player.py | 185 +++- .../components/bang_olufsen/services.yaml | 79 ++ .../components/bang_olufsen/strings.json | 66 ++ .../components/bang_olufsen/websocket.py | 5 + tests/components/bang_olufsen/conftest.py | 26 +- .../snapshots/test_media_player.ambr | 874 ++++++++++++++++++ tests/components/bang_olufsen/test_init.py | 5 +- .../bang_olufsen/test_media_player.py | 271 +++++- 9 files changed, 1485 insertions(+), 35 deletions(-) create mode 100644 homeassistant/components/bang_olufsen/icons.json create mode 100644 homeassistant/components/bang_olufsen/services.yaml create mode 100644 tests/components/bang_olufsen/snapshots/test_media_player.ambr diff --git a/homeassistant/components/bang_olufsen/icons.json b/homeassistant/components/bang_olufsen/icons.json new file mode 100644 index 0000000000000..fec0bf20937f8 --- /dev/null +++ b/homeassistant/components/bang_olufsen/icons.json @@ -0,0 +1,9 @@ +{ + "services": { + "beolink_join": { "service": "mdi:location-enter" }, + "beolink_expand": { "service": "mdi:location-enter" }, + "beolink_unexpand": { "service": "mdi:location-exit" }, + "beolink_leave": { "service": "mdi:close-circle-outline" }, + "beolink_allstandby": { "service": "mdi:close-circle-multiple-outline" } + } +} diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index e8108ee2cf7f7..5dd4557367222 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -11,7 +11,7 @@ from aiohttp import ClientConnectorError from mozart_api import __version__ as MOZART_API_VERSION -from mozart_api.exceptions import ApiException +from mozart_api.exceptions import ApiException, NotFoundException from mozart_api.models import ( Action, Art, @@ -38,6 +38,7 @@ VolumeState, ) from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork +import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -55,10 +56,17 @@ from homeassistant.const import CONF_MODEL, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.util.dt import utcnow from . import BangOlufsenConfigEntry @@ -116,6 +124,58 @@ async def async_setup_entry( ] ) + # Register actions. + platform = async_get_current_platform() + + jid_regex = vol.Match( + r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$" + ) + + platform.async_register_entity_service( + name="beolink_join", + schema={vol.Optional("beolink_jid"): jid_regex}, + func="async_beolink_join", + ) + + platform.async_register_entity_service( + name="beolink_expand", + schema={ + vol.Exclusive("all_discovered", "devices", ""): cv.boolean, + vol.Exclusive( + "beolink_jids", + "devices", + "Define either specific Beolink JIDs or all discovered", + ): vol.All( + cv.ensure_list, + [jid_regex], + ), + }, + func="async_beolink_expand", + ) + + platform.async_register_entity_service( + name="beolink_unexpand", + schema={ + vol.Required("beolink_jids"): vol.All( + cv.ensure_list, + [jid_regex], + ), + }, + func="async_beolink_unexpand", + ) + + platform.async_register_entity_service( + name="beolink_leave", + schema=None, + func="async_beolink_leave", + ) + + platform.async_register_entity_service( + name="beolink_allstandby", + schema=None, + func="async_beolink_allstandby", + ) + class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): """Representation of a media player.""" @@ -156,6 +216,8 @@ def __init__(self, entry: ConfigEntry, client: MozartClient) -> None: # Beolink compatible sources self._beolink_sources: dict[str, bool] = {} self._remote_leader: BeolinkLeader | None = None + # Extra state attributes for showing Beolink: peer(s), listener(s), leader and self + self._beolink_attributes: dict[str, dict[str, dict[str, str]]] = {} async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" @@ -165,6 +227,7 @@ async def async_added_to_hass(self) -> None: CONNECTION_STATUS: self._async_update_connection_state, WebsocketNotification.ACTIVE_LISTENING_MODE: self._async_update_sound_modes, WebsocketNotification.BEOLINK: self._async_update_beolink, + WebsocketNotification.CONFIGURATION: self._async_update_name_and_beolink, WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error, WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink, WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress, @@ -230,6 +293,9 @@ async def _initialize(self) -> None: await self._async_update_sound_modes() + # Update beolink attributes and device name. + await self._async_update_name_and_beolink() + async def async_update(self) -> None: """Update queue settings.""" # The WebSocket event listener is the main handler for connection state. @@ -372,9 +438,44 @@ def _async_update_volume(self, data: VolumeState) -> None: self.async_write_ha_state() + async def _async_update_name_and_beolink(self) -> None: + """Update the device friendly name.""" + beolink_self = await self._client.get_beolink_self() + + # Update device name + device_registry = dr.async_get(self.hass) + assert self.device_entry is not None + + device_registry.async_update_device( + device_id=self.device_entry.id, + name=beolink_self.friendly_name, + ) + + await self._async_update_beolink() + async def _async_update_beolink(self) -> None: """Update the current Beolink leader, listeners, peers and self.""" + self._beolink_attributes = {} + + assert self.device_entry is not None + assert self.device_entry.name is not None + + # Add Beolink self + self._beolink_attributes = { + "beolink": {"self": {self.device_entry.name: self._beolink_jid}} + } + + # Add Beolink peers + peers = await self._client.get_beolink_peers() + + if len(peers) > 0: + self._beolink_attributes["beolink"]["peers"] = {} + for peer in peers: + self._beolink_attributes["beolink"]["peers"][peer.friendly_name] = ( + peer.jid + ) + # Add Beolink listeners / leader self._remote_leader = self._playback_metadata.remote_leader @@ -394,9 +495,14 @@ async def _async_update_beolink(self) -> None: # Add self group_members.append(self.entity_id) + self._beolink_attributes["beolink"]["leader"] = { + self._remote_leader.friendly_name: self._remote_leader.jid, + } + # If not listener, check if leader. else: beolink_listeners = await self._client.get_beolink_listeners() + beolink_listeners_attribute = {} # Check if the device is a leader. if len(beolink_listeners) > 0: @@ -417,6 +523,18 @@ async def _async_update_beolink(self) -> None: for beolink_listener in beolink_listeners ] ) + # Update Beolink attributes + for beolink_listener in beolink_listeners: + for peer in peers: + if peer.jid == beolink_listener.jid: + # Get the friendly names for the listeners from the peers + beolink_listeners_attribute[peer.friendly_name] = ( + beolink_listener.jid + ) + break + self._beolink_attributes["beolink"]["listeners"] = ( + beolink_listeners_attribute + ) self._attr_group_members = group_members @@ -602,6 +720,17 @@ def source(self) -> str | None: return self._source_change.name + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return information that is not returned anywhere else.""" + attributes: dict[str, Any] = {} + + # Add Beolink attributes + if self._beolink_attributes: + attributes.update(self._beolink_attributes) + + return attributes + async def async_turn_off(self) -> None: """Set the device to "networkStandby".""" await self._client.post_standby() @@ -873,23 +1002,30 @@ async def async_join_players(self, group_members: list[str]) -> None: # Beolink compatible B&O device. # Repeated presses / calls will cycle between compatible playing devices. if len(group_members) == 0: - await self._async_beolink_join() + await self.async_beolink_join() return # Get JID for each group member jids = [self._get_beolink_jid(group_member) for group_member in group_members] - await self._async_beolink_expand(jids) + await self.async_beolink_expand(jids) async def async_unjoin_player(self) -> None: """Unjoin Beolink session. End session if leader.""" - await self._async_beolink_leave() + await self.async_beolink_leave() - async def _async_beolink_join(self) -> None: + # Custom actions: + async def async_beolink_join(self, beolink_jid: str | None = None) -> None: """Join a Beolink multi-room experience.""" - await self._client.join_latest_beolink_experience() + if beolink_jid is None: + await self._client.join_latest_beolink_experience() + else: + await self._client.join_beolink_peer(jid=beolink_jid) - async def _async_beolink_expand(self, beolink_jids: list[str]) -> None: + async def async_beolink_expand( + self, beolink_jids: list[str] | None = None, all_discovered: bool = False + ) -> None: """Expand a Beolink multi-room experience with a device or devices.""" + # Ensure that the current source is expandable if not self._beolink_sources[cast(str, self._source_change.id)]: raise ServiceValidationError( @@ -901,10 +1037,37 @@ async def _async_beolink_expand(self, beolink_jids: list[str]) -> None: }, ) + # Expand to all discovered devices + if all_discovered: + peers = await self._client.get_beolink_peers() + + for peer in peers: + try: + await self._client.post_beolink_expand(jid=peer.jid) + except NotFoundException: + _LOGGER.warning("Unable to expand to %s", peer.jid) + # Try to expand to all defined devices + elif beolink_jids: + for beolink_jid in beolink_jids: + try: + await self._client.post_beolink_expand(jid=beolink_jid) + except NotFoundException: + _LOGGER.warning( + "Unable to expand to %s. Is the device available on the network?", + beolink_jid, + ) + + async def async_beolink_unexpand(self, beolink_jids: list[str]) -> None: + """Unexpand a Beolink multi-room experience with a device or devices.""" + # Unexpand all defined devices for beolink_jid in beolink_jids: - await self._client.post_beolink_expand(jid=beolink_jid) + await self._client.post_beolink_unexpand(jid=beolink_jid) - async def _async_beolink_leave(self) -> None: + async def async_beolink_leave(self) -> None: """Leave the current Beolink experience.""" await self._client.post_beolink_leave() + + async def async_beolink_allstandby(self) -> None: + """Set all connected Beolink devices to standby.""" + await self._client.post_beolink_allstandby() diff --git a/homeassistant/components/bang_olufsen/services.yaml b/homeassistant/components/bang_olufsen/services.yaml new file mode 100644 index 0000000000000..e5d61420dffa3 --- /dev/null +++ b/homeassistant/components/bang_olufsen/services.yaml @@ -0,0 +1,79 @@ +beolink_allstandby: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + +beolink_expand: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + fields: + all_discovered: + required: false + example: false + selector: + boolean: + jid_options: + collapsed: false + fields: + beolink_jids: + required: false + example: >- + [ + 1111.2222222.33333333@products.bang-olufsen.com, + 4444.5555555.66666666@products.bang-olufsen.com + ] + selector: + object: + +beolink_join: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + fields: + jid_options: + collapsed: false + fields: + beolink_jid: + required: false + example: 1111.2222222.33333333@products.bang-olufsen.com + selector: + text: + +beolink_leave: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + +beolink_unexpand: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + fields: + jid_options: + collapsed: false + fields: + beolink_jids: + required: true + example: >- + [ + 1111.2222222.33333333@products.bang-olufsen.com, + 4444.5555555.66666666@products.bang-olufsen.com + ] + selector: + object: diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 3e336f7d2d824..aef6f953524fa 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -1,4 +1,8 @@ { + "common": { + "jid_options_name": "JID options", + "jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity." + }, "config": { "error": { "api_exception": "[%key:common::config_flow::error::cannot_connect%]", @@ -25,6 +29,68 @@ } } }, + "services": { + "beolink_allstandby": { + "name": "Beolink all standby", + "description": "Set all Connected Beolink devices to standby." + }, + "beolink_expand": { + "name": "Beolink expand", + "description": "Expand current Beolink experience.", + "fields": { + "all_discovered": { + "name": "All discovered", + "description": "Expand Beolink experience to all discovered devices." + }, + "beolink_jids": { + "name": "Beolink JIDs", + "description": "Specify which Beolink JIDs will join current Beolink experience." + } + }, + "sections": { + "jid_options": { + "name": "[%key:component::bang_olufsen::common::jid_options_name%]", + "description": "[%key:component::bang_olufsen::common::jid_options_description%]" + } + } + }, + "beolink_join": { + "name": "Beolink join", + "description": "Join a Beolink experience.", + "fields": { + "beolink_jid": { + "name": "Beolink JID", + "description": "Manually specify Beolink JID to join." + } + }, + "sections": { + "jid_options": { + "name": "[%key:component::bang_olufsen::common::jid_options_name%]", + "description": "[%key:component::bang_olufsen::common::jid_options_description%]" + } + } + }, + "beolink_leave": { + "name": "Beolink leave", + "description": "Leave a Beolink experience." + }, + "beolink_unexpand": { + "name": "Beolink unexpand", + "description": "Unexpand from current Beolink experience.", + "fields": { + "beolink_jids": { + "name": "Beolink JIDs", + "description": "Specify which Beolink JIDs will leave from current Beolink experience." + } + }, + "sections": { + "jid_options": { + "name": "[%key:component::bang_olufsen::common::jid_options_name%]", + "description": "[%key:component::bang_olufsen::common::jid_options_description%]" + } + } + } + }, "exceptions": { "m3u_invalid_format": { "message": "Media sources with the .m3u extension are not supported." diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 94b84189ccc16..913f7cb32414a 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -120,6 +120,11 @@ def on_notification_notification( self.hass, f"{self._unique_id}_{WebsocketNotification.BEOLINK}", ) + elif notification_type is WebsocketNotification.CONFIGURATION: + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebsocketNotification.CONFIGURATION}", + ) elif notification_type is WebsocketNotification.REMOTE_MENU_CHANGED: async_dispatcher_send( self.hass, diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index 6c19a29c1daaa..cbde856ff89e6 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -35,13 +35,13 @@ TEST_DATA_CREATE_ENTRY, TEST_DATA_CREATE_ENTRY_2, TEST_FRIENDLY_NAME, - TEST_FRIENDLY_NAME_2, TEST_FRIENDLY_NAME_3, - TEST_HOST_2, + TEST_FRIENDLY_NAME_4, TEST_HOST_3, + TEST_HOST_4, TEST_JID_1, - TEST_JID_2, TEST_JID_3, + TEST_JID_4, TEST_NAME, TEST_NAME_2, TEST_SERIAL_NUMBER, @@ -267,29 +267,29 @@ def mock_mozart_client() -> Generator[AsyncMock]: } client.get_beolink_peers = AsyncMock() client.get_beolink_peers.return_value = [ - BeolinkPeer( - friendly_name=TEST_FRIENDLY_NAME_2, - jid=TEST_JID_2, - ip_address=TEST_HOST_2, - ), BeolinkPeer( friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3, ip_address=TEST_HOST_3, ), + BeolinkPeer( + friendly_name=TEST_FRIENDLY_NAME_4, + jid=TEST_JID_4, + ip_address=TEST_HOST_4, + ), ] client.get_beolink_listeners = AsyncMock() client.get_beolink_listeners.return_value = [ - BeolinkPeer( - friendly_name=TEST_FRIENDLY_NAME_2, - jid=TEST_JID_2, - ip_address=TEST_HOST_2, - ), BeolinkPeer( friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3, ip_address=TEST_HOST_3, ), + BeolinkPeer( + friendly_name=TEST_FRIENDLY_NAME_4, + jid=TEST_JID_4, + ip_address=TEST_HOST_4, + ), ] client.get_listening_mode_set = AsyncMock() diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr new file mode 100644 index 0000000000000..e48dc39198bcc --- /dev/null +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -0,0 +1,874 @@ +# serializer version: 1 +# name: test_async_beolink_allstandby + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_expand[all_discovered-True-None-log_messages0-2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_expand[all_discovered-True-expand_side_effect1-log_messages1-2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_expand[beolink_jids-parameter_value2-None-log_messages2-1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_expand[beolink_jids-parameter_value3-expand_side_effect3-log_messages3-1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_join + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_unexpand + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players[group_members0-1-0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players[group_members0-1-0].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_22222222', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_22222222', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players[group_members1-0-1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players[group_members1-0-1].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_22222222', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_22222222', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players_invalid[source0-group_members0-expected_result0-invalid_source] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'media_position': 0, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Chromecast built-in', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players_invalid[source0-group_members0-expected_result0-invalid_source].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_22222222', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_22222222', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players_invalid[source1-group_members1-expected_result1-invalid_grouping_entity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players_invalid[source1-group_members1-expected_result1-invalid_grouping_entity].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_22222222', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_22222222', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_unjoin_player + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_update_beolink_listener + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'leader': dict({ + 'Laundry room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_22222222', + 'media_player.beosound_balance_11111111', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_update_beolink_listener.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_22222222', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_22222222', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- diff --git a/tests/components/bang_olufsen/test_init.py b/tests/components/bang_olufsen/test_init.py index 5b809488ed8d8..c8e4c05f9abaa 100644 --- a/tests/components/bang_olufsen/test_init.py +++ b/tests/components/bang_olufsen/test_init.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry -from .const import TEST_MODEL_BALANCE, TEST_NAME, TEST_SERIAL_NUMBER +from .const import TEST_FRIENDLY_NAME, TEST_MODEL_BALANCE, TEST_SERIAL_NUMBER from tests.common import MockConfigEntry @@ -35,7 +35,8 @@ async def test_setup_entry( identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} ) assert device is not None - assert device.name == TEST_NAME + # Is usually TEST_NAME, but is updated to the device's friendly name by _update_name_and_beolink + assert device.name == TEST_FRIENDLY_NAME assert device.model == TEST_MODEL_BALANCE # Ensure that the connection has been checked WebSocket connection has been initialized diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 8f23af9e04a09..e991ab3d1bcf0 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -4,8 +4,10 @@ import logging from unittest.mock import AsyncMock, patch +from mozart_api.exceptions import NotFoundException from mozart_api.models import ( BeolinkLeader, + BeolinkSelf, PlaybackContentMetadata, PlayQueueSettings, RenderingState, @@ -14,6 +16,8 @@ WebsocketNotificationTag, ) import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props from homeassistant.components.bang_olufsen.const import ( BANG_OLUFSEN_REPEAT_FROM_HA, @@ -46,24 +50,29 @@ ATTR_SOUND_MODE_LIST, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, + SERVICE_JOIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, SERVICE_PLAY_MEDIA, + SERVICE_REPEAT_SET, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, + SERVICE_SHUFFLE_SET, SERVICE_TURN_OFF, + SERVICE_UNJOIN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, MediaPlayerState, MediaType, RepeatMode, ) -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_REPEAT_SET, SERVICE_SHUFFLE_SET +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component from .const import ( @@ -76,7 +85,10 @@ TEST_DEEZER_TRACK, TEST_FALLBACK_SOURCES, TEST_FRIENDLY_NAME_2, + TEST_JID_1, TEST_JID_2, + TEST_JID_3, + TEST_JID_4, TEST_LISTENING_MODE_REF, TEST_MEDIA_PLAYER_ENTITY_ID, TEST_MEDIA_PLAYER_ENTITY_ID_2, @@ -136,6 +148,9 @@ async def test_initialization( mock_mozart_client.get_remote_menu.assert_called_once() mock_mozart_client.get_listening_mode_set.assert_called_once() mock_mozart_client.get_active_listening_mode.assert_called_once() + mock_mozart_client.get_beolink_self.assert_called_once() + mock_mozart_client.get_beolink_peers.assert_called_once() + mock_mozart_client.get_beolink_listeners.assert_called_once() async def test_async_update_sources_audio_only( @@ -530,11 +545,14 @@ async def test_async_update_beolink_line_in( assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.attributes["group_members"] == [] - assert mock_mozart_client.get_beolink_listeners.call_count == 1 + # Called once during _initialize and once during _async_update_beolink + assert mock_mozart_client.get_beolink_listeners.call_count == 2 + assert mock_mozart_client.get_beolink_peers.call_count == 2 async def test_async_update_beolink_listener( hass: HomeAssistant, + snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_config_entry_2: MockConfigEntry, @@ -567,7 +585,56 @@ async def test_async_update_beolink_listener( TEST_MEDIA_PLAYER_ENTITY_ID, ] - assert mock_mozart_client.get_beolink_listeners.call_count == 0 + # Called once for each entity during _initialize + assert mock_mozart_client.get_beolink_listeners.call_count == 2 + # Called once for each entity during _initialize and + # once more during _async_update_beolink for the entity that has the callback associated with it. + assert mock_mozart_client.get_beolink_peers.call_count == 3 + + # Main entity + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + # Secondary entity + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID_2)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + +async def test_async_update_name_and_beolink( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test _async_update_name_and_beolink.""" + # Change response to ensure device name is changed + mock_mozart_client.get_beolink_self.return_value = BeolinkSelf( + friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_1 + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + configuration_callback = ( + mock_mozart_client.get_notification_notifications.call_args[0][0] + ) + # Trigger callback + configuration_callback(WebsocketNotificationTag(value="configuration")) + + await hass.async_block_till_done() + + assert mock_mozart_client.get_beolink_self.call_count == 2 + assert mock_mozart_client.get_beolink_peers.call_count == 2 + assert mock_mozart_client.get_beolink_listeners.call_count == 2 + + # Check that device name has been changed + assert mock_config_entry.unique_id + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + ) + assert device.name == TEST_FRIENDLY_NAME_2 async def test_async_mute_volume( @@ -1343,6 +1410,7 @@ async def test_async_browse_media( ) async def test_async_join_players( hass: HomeAssistant, + snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_config_entry_2: MockConfigEntry, @@ -1367,8 +1435,8 @@ async def test_async_join_players( source_change_callback(BangOlufsenSource.TIDAL) await hass.services.async_call( - "media_player", - "join", + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_GROUP_MEMBERS: group_members, @@ -1379,6 +1447,14 @@ async def test_async_join_players( assert mock_mozart_client.post_beolink_expand.call_count == expand_count assert mock_mozart_client.join_latest_beolink_experience.call_count == join_count + # Main entity + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + # Secondary entity + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID_2)) + assert states == snapshot(exclude=props("media_position_updated_at")) + @pytest.mark.parametrize( ("source", "group_members", "expected_result", "error_type"), @@ -1401,6 +1477,7 @@ async def test_async_join_players( ) async def test_async_join_players_invalid( hass: HomeAssistant, + snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_config_entry_2: MockConfigEntry, @@ -1425,8 +1502,8 @@ async def test_async_join_players_invalid( with expected_result as exc_info: await hass.services.async_call( - "media_player", - "join", + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_GROUP_MEMBERS: group_members, @@ -1441,9 +1518,18 @@ async def test_async_join_players_invalid( assert mock_mozart_client.post_beolink_expand.call_count == 0 assert mock_mozart_client.join_latest_beolink_experience.call_count == 0 + # Main entity + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + # Secondary entity + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID_2)) + assert states == snapshot(exclude=props("media_position_updated_at")) + async def test_async_unjoin_player( hass: HomeAssistant, + snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: @@ -1453,14 +1539,181 @@ async def test_async_unjoin_player( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.services.async_call( - "media_player", - "unjoin", + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, blocking=True, ) mock_mozart_client.post_beolink_leave.assert_called_once() + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + +async def test_async_beolink_join( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_beolink_join with defined JID.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + DOMAIN, + "beolink_join", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + "beolink_jid": TEST_JID_2, + }, + blocking=True, + ) + + mock_mozart_client.join_beolink_peer.assert_called_once_with(jid=TEST_JID_2) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + +@pytest.mark.parametrize( + ( + "parameter", + "parameter_value", + "expand_side_effect", + "log_messages", + "peers_call_count", + ), + [ + # All discovered + # Valid peers + ("all_discovered", True, None, [], 2), + # Invalid peers + ( + "all_discovered", + True, + NotFoundException(), + [f"Unable to expand to {TEST_JID_3}", f"Unable to expand to {TEST_JID_4}"], + 2, + ), + # Beolink JIDs + # Valid peer + ("beolink_jids", [TEST_JID_3, TEST_JID_4], None, [], 1), + # Invalid peer + ( + "beolink_jids", + [TEST_JID_3, TEST_JID_4], + NotFoundException(), + [ + f"Unable to expand to {TEST_JID_3}. Is the device available on the network?", + f"Unable to expand to {TEST_JID_4}. Is the device available on the network?", + ], + 1, + ), + ], +) +async def test_async_beolink_expand( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, + parameter: str, + parameter_value: bool | list[str], + expand_side_effect: NotFoundException | None, + log_messages: list[str], + peers_call_count: int, +) -> None: + """Test async_beolink_expand.""" + mock_mozart_client.post_beolink_expand.side_effect = expand_side_effect + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + source_change_callback = ( + mock_mozart_client.get_source_change_notifications.call_args[0][0] + ) + + # Set the source to a beolink expandable source + source_change_callback(BangOlufsenSource.TIDAL) + + await hass.services.async_call( + DOMAIN, + "beolink_expand", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + parameter: parameter_value, + }, + blocking=True, + ) + + # Check log messages + for log_message in log_messages: + assert log_message in caplog.text + + # Called once during _initialize and once during async_beolink_expand for all_discovered + assert mock_mozart_client.get_beolink_peers.call_count == peers_call_count + + assert mock_mozart_client.post_beolink_expand.call_count == len( + await mock_mozart_client.get_beolink_peers() + ) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + +async def test_async_beolink_unexpand( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test test_async_beolink_unexpand.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + DOMAIN, + "beolink_unexpand", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + "beolink_jids": [TEST_JID_3, TEST_JID_4], + }, + blocking=True, + ) + + assert mock_mozart_client.post_beolink_unexpand.call_count == 2 + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + +async def test_async_beolink_allstandby( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_beolink_allstandby.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + DOMAIN, + "beolink_allstandby", + {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + mock_mozart_client.post_beolink_allstandby.assert_called_once() + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + @pytest.mark.parametrize( ("repeat"), From 24b47b50ead07fdd1d2dd4e2aab17fee3cf1179a Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Fri, 8 Nov 2024 13:29:10 +0100 Subject: [PATCH 0253/1070] Migrate from entry unique id to emoncms unique id (#129133) * Migrate from entry unique id to emoncms unique id * Use a placeholder for the documentation URL * Use async_set_unique_id in config_flow * use _abort_if_unique_id_configured in config_flow * Avoid single-use variable Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Add async_migrate_entry * Remove commented code * Downgrade version if user add server without uuid * Improve code quality * Move code migrating HA to emoncms uuid to init * Fit doc url in less than 88 chars Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Improve code quality * Only update unique_id with async_update_entry Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Make emoncms_client compulsory to get_feed_list * Improve readability with unique id functions * Rmv test to give more sense to _migrate_unique_id --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/emoncms/__init__.py | 47 +++++++++++++++++ .../components/emoncms/config_flow.py | 33 +++++++----- homeassistant/components/emoncms/const.py | 4 ++ homeassistant/components/emoncms/sensor.py | 10 ++-- homeassistant/components/emoncms/strings.json | 7 +++ tests/components/emoncms/conftest.py | 16 ++++++ .../emoncms/snapshots/test_sensor.ambr | 2 +- tests/components/emoncms/test_config_flow.py | 18 +++++++ tests/components/emoncms/test_init.py | 51 ++++++++++++++++++- 9 files changed, 167 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py index 98ed632857801..0cd686b5b5671 100644 --- a/homeassistant/components/emoncms/__init__.py +++ b/homeassistant/components/emoncms/__init__.py @@ -5,8 +5,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from .const import DOMAIN, EMONCMS_UUID_DOC_URL, LOGGER from .coordinator import EmoncmsCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -14,6 +17,49 @@ type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator] +def _migrate_unique_id( + hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_unique_id: str +) -> None: + """Migrate to emoncms unique id if needed.""" + ent_reg = er.async_get(hass) + entry_entities = ent_reg.entities.get_entries_for_config_entry_id(entry.entry_id) + for entity in entry_entities: + if entity.unique_id.split("-")[0] == entry.entry_id: + feed_id = entity.unique_id.split("-")[-1] + LOGGER.debug(f"moving feed {feed_id} to hardware uuid") + ent_reg.async_update_entity( + entity.entity_id, new_unique_id=f"{emoncms_unique_id}-{feed_id}" + ) + hass.config_entries.async_update_entry( + entry, + unique_id=emoncms_unique_id, + ) + + +async def _check_unique_id_migration( + hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_client: EmoncmsClient +) -> None: + """Check if we can migrate to the emoncms uuid.""" + emoncms_unique_id = await emoncms_client.async_get_uuid() + if emoncms_unique_id: + if entry.unique_id != emoncms_unique_id: + _migrate_unique_id(hass, entry, emoncms_unique_id) + else: + async_create_issue( + hass, + DOMAIN, + "migrate database", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="migrate_database", + translation_placeholders={ + "url": entry.data[CONF_URL], + "doc_url": EMONCMS_UUID_DOC_URL, + }, + ) + + async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool: """Load a config entry.""" emoncms_client = EmoncmsClient( @@ -21,6 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b entry.data[CONF_API_KEY], session=async_get_clientsession(hass), ) + await _check_unique_id_migration(hass, entry, emoncms_client) coordinator = EmoncmsCoordinator(hass, emoncms_client) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index b294a5cd3d47b..e0d4d0d03e9fc 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -14,7 +14,7 @@ OptionsFlow, ) from homeassistant.const import CONF_API_KEY, CONF_URL -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import selector from homeassistant.helpers.typing import ConfigType @@ -48,13 +48,10 @@ def sensor_name(url: str) -> str: return f"emoncms@{sensorip}" -async def get_feed_list(hass: HomeAssistant, url: str, api_key: str) -> dict[str, Any]: +async def get_feed_list( + emoncms_client: EmoncmsClient, +) -> dict[str, Any]: """Check connection to emoncms and return feed list if successful.""" - emoncms_client = EmoncmsClient( - url, - api_key, - session=async_get_clientsession(hass), - ) return await emoncms_client.async_request("/feed/list.json") @@ -82,22 +79,25 @@ async def async_step_user( description_placeholders = {} if user_input is not None: + self.url = user_input[CONF_URL] + self.api_key = user_input[CONF_API_KEY] self._async_abort_entries_match( { - CONF_API_KEY: user_input[CONF_API_KEY], - CONF_URL: user_input[CONF_URL], + CONF_API_KEY: self.api_key, + CONF_URL: self.url, } ) - result = await get_feed_list( - self.hass, user_input[CONF_URL], user_input[CONF_API_KEY] + emoncms_client = EmoncmsClient( + self.url, self.api_key, session=async_get_clientsession(self.hass) ) + result = await get_feed_list(emoncms_client) if not result[CONF_SUCCESS]: errors["base"] = "api_error" description_placeholders = {"details": result[CONF_MESSAGE]} else: self.include_only_feeds = user_input.get(CONF_ONLY_INCLUDE_FEEDID) - self.url = user_input[CONF_URL] - self.api_key = user_input[CONF_API_KEY] + await self.async_set_unique_id(await emoncms_client.async_get_uuid()) + self._abort_if_unique_id_configured() options = get_options(result[CONF_MESSAGE]) self.dropdown = { "options": options, @@ -191,7 +191,12 @@ async def async_step_init( self.config_entry.data.get(CONF_ONLY_INCLUDE_FEEDID, []), ) options: list = include_only_feeds - result = await get_feed_list(self.hass, self._url, self._api_key) + emoncms_client = EmoncmsClient( + self._url, + self._api_key, + session=async_get_clientsession(self.hass), + ) + result = await get_feed_list(emoncms_client) if not result[CONF_SUCCESS]: errors["base"] = "api_error" description_placeholders = {"details": result[CONF_MESSAGE]} diff --git a/homeassistant/components/emoncms/const.py b/homeassistant/components/emoncms/const.py index 256db5726bbce..c53f7cc8a9f5b 100644 --- a/homeassistant/components/emoncms/const.py +++ b/homeassistant/components/emoncms/const.py @@ -7,6 +7,10 @@ CONF_MESSAGE = "message" CONF_SUCCESS = "success" DOMAIN = "emoncms" +EMONCMS_UUID_DOC_URL = ( + "https://docs.openenergymonitor.org/emoncms/update.html" + "#upgrading-to-a-version-producing-a-unique-identifier" +) FEED_ID = "id" FEED_NAME = "name" FEED_TAG = "tag" diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index d8dec12800aa7..c696a56913565 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -148,20 +148,20 @@ async def async_setup_entry( return coordinator = entry.runtime_data + # uuid was added in emoncms database 11.5.7 + unique_id = entry.unique_id if entry.unique_id else entry.entry_id elems = coordinator.data if not elems: return - sensors: list[EmonCmsSensor] = [] for idx, elem in enumerate(elems): if include_only_feeds is not None and elem[FEED_ID] not in include_only_feeds: continue - sensors.append( EmonCmsSensor( coordinator, - entry.entry_id, + unique_id, elem["unit"], name, idx, @@ -176,7 +176,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity): def __init__( self, coordinator: EmoncmsCoordinator, - entry_id: str, + unique_id: str, unit_of_measurement: str | None, name: str, idx: int, @@ -189,7 +189,7 @@ def __init__( elem = self.coordinator.data[self.idx] self._attr_name = f"{name} {elem[FEED_NAME]}" self._attr_native_unit_of_measurement = unit_of_measurement - self._attr_unique_id = f"{entry_id}-{elem[FEED_ID]}" + self._attr_unique_id = f"{unique_id}-{elem[FEED_ID]}" if unit_of_measurement in ("kWh", "Wh"): self._attr_device_class = SensorDeviceClass.ENERGY self._attr_state_class = SensorStateClass.TOTAL_INCREASING diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index e2b7602f6f268..0d841f2efb464 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -19,6 +19,9 @@ "include_only_feed_id": "Choose feeds to include" } } + }, + "abort": { + "already_configured": "This server is already configured" } }, "options": { @@ -41,6 +44,10 @@ "missing_include_only_feed_id": { "title": "No feed synchronized with the {domain} sensor", "description": "Configuring {domain} using YAML is being removed.\n\nPlease add manually the feeds you want to synchronize with the `configure` button of the integration." + }, + "migrate_database": { + "title": "Upgrade your emoncms version", + "description": "Your [emoncms]({url}) does not ship a unique identifier.\n\n Please upgrade to at least version 11.5.7 and migrate your emoncms database.\n\n More info on [emoncms documentation]({doc_url})" } } } diff --git a/tests/components/emoncms/conftest.py b/tests/components/emoncms/conftest.py index 29e86f3c59d64..4bd1d68217abb 100644 --- a/tests/components/emoncms/conftest.py +++ b/tests/components/emoncms/conftest.py @@ -91,6 +91,21 @@ def config_entry() -> MockConfigEntry: ) +FLOW_RESULT_SECOND_URL = copy.deepcopy(FLOW_RESULT) +FLOW_RESULT_SECOND_URL[CONF_URL] = "http://1.1.1.2" + + +@pytest.fixture +def config_entry_unique_id() -> MockConfigEntry: + """Mock emoncms config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=SENSOR_NAME, + data=FLOW_RESULT_SECOND_URL, + unique_id="123-53535292", + ) + + FLOW_RESULT_NO_FEED = copy.deepcopy(FLOW_RESULT) FLOW_RESULT_NO_FEED[CONF_ONLY_INCLUDE_FEEDID] = None @@ -143,4 +158,5 @@ async def emoncms_client() -> AsyncGenerator[AsyncMock]: ): client = mock_client.return_value client.async_request.return_value = {"success": True, "message": FEEDS} + client.async_get_uuid.return_value = "123-53535292" yield client diff --git a/tests/components/emoncms/snapshots/test_sensor.ambr b/tests/components/emoncms/snapshots/test_sensor.ambr index 5e718c1d8e840..f6a2745fb1ad5 100644 --- a/tests/components/emoncms/snapshots/test_sensor.ambr +++ b/tests/components/emoncms/snapshots/test_sensor.ambr @@ -30,7 +30,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'XXXXXXXX-1', + 'unique_id': '123-53535292-1', 'unit_of_measurement': , }) # --- diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index b3afc714c5928..5baf3d25b0e01 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -142,3 +142,21 @@ async def test_options_flow_failure( assert result["description_placeholders"]["details"] == "failure" assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" + + +async def test_unique_id_exists( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + emoncms_client: AsyncMock, + config_entry_unique_id: MockConfigEntry, +) -> None: + """Test when entry with same unique id already exists.""" + config_entry_unique_id.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/emoncms/test_init.py b/tests/components/emoncms/test_init.py index b89b6e65a66d8..abe1a02003442 100644 --- a/tests/components/emoncms/test_init.py +++ b/tests/components/emoncms/test_init.py @@ -4,11 +4,14 @@ from unittest.mock import AsyncMock +from homeassistant.components.emoncms.const import DOMAIN, FEED_ID, FEED_NAME from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, issue_registry as ir from . import setup_integration -from .conftest import EMONCMS_FAILURE +from .conftest import EMONCMS_FAILURE, FEEDS from tests.common import MockConfigEntry @@ -38,3 +41,49 @@ async def test_failure( emoncms_client.async_request.return_value = EMONCMS_FAILURE config_entry.add_to_hass(hass) assert not await hass.config_entries.async_setup(config_entry.entry_id) + + +async def test_migrate_uuid( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + emoncms_client: AsyncMock, +) -> None: + """Test migration from home assistant uuid to emoncms uuid.""" + config_entry.add_to_hass(hass) + assert config_entry.unique_id is None + for _, feed in enumerate(FEEDS): + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + f"{config_entry.entry_id}-{feed[FEED_ID]}", + config_entry=config_entry, + suggested_object_id=f"{DOMAIN}_{feed[FEED_NAME]}", + ) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + emoncms_uuid = emoncms_client.async_get_uuid.return_value + assert config_entry.unique_id == emoncms_uuid + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for nb, feed in enumerate(FEEDS): + assert entity_entries[nb].unique_id == f"{emoncms_uuid}-{feed[FEED_ID]}" + assert ( + entity_entries[nb].previous_unique_id + == f"{config_entry.entry_id}-{feed[FEED_ID]}" + ) + + +async def test_no_uuid( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + emoncms_client: AsyncMock, +) -> None: + """Test an issue is created when the emoncms server does not ship an uuid.""" + emoncms_client.async_get_uuid.return_value = None + await setup_integration(hass, config_entry) + + assert issue_registry.async_get_issue(domain=DOMAIN, issue_id="migrate database") From 94d597fd41e4401d08badb9fdffdf6919c47f509 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 8 Nov 2024 13:33:19 +0100 Subject: [PATCH 0254/1070] Add checks for flow title/description placeholders (#129140) * Add checks for title placeholders * Check both title and description * Improve comment --- tests/components/conftest.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 00738cd252fc2..5535ec3b97682 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -569,6 +569,8 @@ async def _ensure_translation_exists( component: str, key: str, description_placeholders: dict[str, str] | None, + *, + translation_required: bool = True, ) -> None: """Raise if translation doesn't exist.""" full_key = f"component.{component}.{category}.{key}" @@ -579,6 +581,9 @@ async def _ensure_translation_exists( ) return + if not translation_required: + return + if full_key in ignore_translations: ignore_translations[full_key] = "used" return @@ -626,6 +631,20 @@ async def _async_handle_step( setattr(flow, "__flow_seen_before", hasattr(flow, "__flow_seen_before")) if result["type"] is FlowResultType.FORM: + if step_id := result.get("step_id"): + # neither title nor description are required + # - title defaults to integration name + # - description is optional + for header in ("title", "description"): + await _ensure_translation_exists( + flow.hass, + _ignore_translations, + category, + component, + f"step.{step_id}.{header}", + result["description_placeholders"], + translation_required=False, + ) if errors := result.get("errors"): for error in errors.values(): await _ensure_translation_exists( From 18cf96b92b55ca8ab66c359327b68fc296b0da08 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 8 Nov 2024 13:42:19 +0100 Subject: [PATCH 0255/1070] Bring emoncms coverage to 100% (#130092) Remove mock_setup_entry from emoncms OptionsFlow test --- tests/components/emoncms/test_config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index 5baf3d25b0e01..1914f23fb0b41 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -106,7 +106,6 @@ async def test_user_flow( async def test_options_flow( hass: HomeAssistant, - mock_setup_entry: AsyncMock, emoncms_client: AsyncMock, config_entry: MockConfigEntry, ) -> None: From 7672215095dbc032d51a0966f027049f58172ae7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 8 Nov 2024 13:46:40 +0100 Subject: [PATCH 0256/1070] Trigger full CI run on homeassistant_hardware integration changes (#130129) Add components/homeassistant_hardware to core files --- .core_files.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.core_files.yaml b/.core_files.yaml index e211b8ca5ec79..6fd3a74df925e 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -79,6 +79,7 @@ components: &components - homeassistant/components/group/** - homeassistant/components/hassio/** - homeassistant/components/homeassistant/** + - homeassistant/components/homeassistant_hardware/** - homeassistant/components/http/** - homeassistant/components/image/** - homeassistant/components/input_boolean/** From 7678be8e2b8c3cf80c3c660ffd383dcc589949d6 Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Fri, 8 Nov 2024 14:01:36 +0100 Subject: [PATCH 0257/1070] Suez water: simplify config flow (#130083) Simplify config flow for suez water. Counter_id can now be automatically be fetched by the integration. The value is provided only in the source code of suez website and therefore not easily accessible to user not familiar with devlopment. Still possible to explicitly set the value for user with multiple value or value defined elsewhere. --- .../components/suez_water/config_flow.py | 17 +++++++- .../components/suez_water/strings.json | 3 +- .../components/suez_water/test_config_flow.py | 39 ++++++++++++++++++- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py index a7ade642888a2..ac09cf4a1d3be 100644 --- a/homeassistant/components/suez_water/config_flow.py +++ b/homeassistant/components/suez_water/config_flow.py @@ -20,7 +20,7 @@ { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_COUNTER_ID): str, + vol.Optional(CONF_COUNTER_ID): str, } ) @@ -31,16 +31,23 @@ async def validate_input(data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ try: + counter_id = data.get(CONF_COUNTER_ID) client = SuezClient( data[CONF_USERNAME], data[CONF_PASSWORD], - data[CONF_COUNTER_ID], + counter_id, ) if not await client.check_credentials(): raise InvalidAuth except PySuezError as ex: raise CannotConnect from ex + if counter_id is None: + try: + data[CONF_COUNTER_ID] = await client.find_counter() + except PySuezError as ex: + raise CounterNotFound from ex + class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Suez Water.""" @@ -61,6 +68,8 @@ async def async_step_user( errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" + except CounterNotFound: + errors["base"] = "counter_not_found" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -80,3 +89,7 @@ class CannotConnect(HomeAssistantError): class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class CounterNotFound(HomeAssistantError): + """Error to indicate we cannot automatically found the counter id.""" diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json index f9abd70fc1986..a1af12abd5599 100644 --- a/homeassistant/components/suez_water/strings.json +++ b/homeassistant/components/suez_water/strings.json @@ -12,7 +12,8 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "counter_not_found": "Could not find counter id automatically" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py index 766fd8c5fa53f..6779b4c7d0220 100644 --- a/tests/components/suez_water/test_config_flow.py +++ b/tests/components/suez_water/test_config_flow.py @@ -6,7 +6,7 @@ import pytest from homeassistant import config_entries -from homeassistant.components.suez_water.const import DOMAIN +from homeassistant.components.suez_water.const import CONF_COUNTER_ID, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -127,3 +127,40 @@ async def test_form_error( assert result["title"] == "test-username" assert result["data"] == MOCK_DATA assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_auto_counter( + hass: HomeAssistant, mock_setup_entry: AsyncMock, suez_client: AsyncMock +) -> None: + """Test form set counter if not set by user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + partial_form = {**MOCK_DATA} + partial_form.pop(CONF_COUNTER_ID) + suez_client.find_counter.side_effect = PySuezError("test counter not found") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + partial_form, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "counter_not_found"} + + suez_client.find_counter.side_effect = None + suez_client.find_counter.return_value = MOCK_DATA[CONF_COUNTER_ID] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + partial_form, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["result"].unique_id == "test-username" + assert result["data"] == MOCK_DATA + assert len(mock_setup_entry.mock_calls) == 1 From f49547d598fd7f1866c2186908969fa352980d91 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 8 Nov 2024 14:19:46 +0100 Subject: [PATCH 0258/1070] Bump uv to 0.5.0 (#130127) --- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index b6d571f308e61..903a121c032d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.4.28 +RUN pip3 install uv==0.5.0 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9df83f3bb23e8..05fabb340ff39 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.4.28 +uv==0.5.0 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 4ca6d21178828..df3e2703d5ca3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.4.28", + "uv==0.5.0", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", diff --git a/requirements.txt b/requirements.txt index 0902ca9813d1b..f9ac034136d77 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,7 +40,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.4.28 +uv==0.5.0 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 61b623dc32b06..97fc6c49d1246 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.4.28,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ From 03c3d09583e2b68a9018402a229d996fce4f440a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 8 Nov 2024 13:41:00 +0000 Subject: [PATCH 0259/1070] Enable overriding connection port for tplink devices (#129619) Enable setting a port override during manual config entry setup. The feature will be undocumented as it's quite a specialized use case generally used for testing purposes. --- homeassistant/components/tplink/__init__.py | 3 + .../components/tplink/config_flow.py | 70 ++++++++++-- tests/components/tplink/conftest.py | 2 +- tests/components/tplink/test_config_flow.py | 104 ++++++++++++++++-- 4 files changed, 163 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index ceeb1120ed8ac..ee1d90e70b4d6 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -31,6 +31,7 @@ CONF_MAC, CONF_MODEL, CONF_PASSWORD, + CONF_PORT, CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback @@ -141,6 +142,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo entry_credentials_hash = entry.data.get(CONF_CREDENTIALS_HASH) entry_use_http = entry.data.get(CONF_USES_HTTP, False) entry_aes_keys = entry.data.get(CONF_AES_KEYS) + port_override = entry.data.get(CONF_PORT) conn_params: Device.ConnectionParameters | None = None if conn_params_dict := entry.data.get(CONF_CONNECTION_PARAMETERS): @@ -157,6 +159,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo timeout=CONNECT_TIMEOUT, http_client=client, aes_keys=entry_aes_keys, + port_override=port_override, ) if conn_params: config.connection_type = conn_params diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index a9f665e12fd00..63f1b4e125b4a 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -32,6 +32,7 @@ CONF_MAC, CONF_MODEL, CONF_PASSWORD, + CONF_PORT, CONF_USERNAME, ) from homeassistant.core import callback @@ -69,6 +70,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): MINOR_VERSION = CONF_CONFIG_ENTRY_MINOR_VERSION host: str | None = None + port: int | None = None def __init__(self) -> None: """Initialize the config flow.""" @@ -260,6 +262,26 @@ async def async_step_discovery_confirm( step_id="discovery_confirm", description_placeholders=placeholders ) + @staticmethod + def _async_get_host_port(host_str: str) -> tuple[str, int | None]: + """Parse the host string for host and port.""" + if "[" in host_str: + _, _, bracketed = host_str.partition("[") + host, _, port_str = bracketed.partition("]") + _, _, port_str = port_str.partition(":") + else: + host, _, port_str = host_str.partition(":") + + if not port_str: + return host, None + + try: + port = int(port_str) + except ValueError: + return host, None + + return host, port + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -270,14 +292,29 @@ async def async_step_user( if user_input is not None: if not (host := user_input[CONF_HOST]): return await self.async_step_pick_device() - self._async_abort_entries_match({CONF_HOST: host}) + + host, port = self._async_get_host_port(host) + + match_dict = {CONF_HOST: host} + if port: + self.port = port + match_dict[CONF_PORT] = port + self._async_abort_entries_match(match_dict) + self.host = host credentials = await get_credentials(self.hass) try: device = await self._async_try_discover_and_update( - host, credentials, raise_on_progress=False, raise_on_timeout=False + host, + credentials, + raise_on_progress=False, + raise_on_timeout=False, + port=port, ) or await self._async_try_connect_all( - host, credentials=credentials, raise_on_progress=False + host, + credentials=credentials, + raise_on_progress=False, + port=port, ) except AuthenticationError: return await self.async_step_user_auth_confirm() @@ -318,7 +355,10 @@ async def async_step_user_auth_confirm( ) else: device = await self._async_try_connect_all( - self.host, credentials=credentials, raise_on_progress=False + self.host, + credentials=credentials, + raise_on_progress=False, + port=self.port, ) except AuthenticationError as ex: errors[CONF_PASSWORD] = "invalid_auth" @@ -420,6 +460,8 @@ def _async_create_entry_from_device(self, device: Device) -> ConfigFlowResult: data[CONF_AES_KEYS] = device.config.aes_keys if device.credentials_hash: data[CONF_CREDENTIALS_HASH] = device.credentials_hash + if port := device.config.port_override: + data[CONF_PORT] = port return self.async_create_entry( title=f"{device.alias} {device.model}", data=data, @@ -430,6 +472,8 @@ async def _async_try_connect_all( host: str, credentials: Credentials | None, raise_on_progress: bool, + *, + port: int | None = None, ) -> Device | None: """Try to connect to the device speculatively. @@ -441,12 +485,15 @@ async def _async_try_connect_all( host, credentials=credentials, http_client=create_async_tplink_clientsession(self.hass), + port=port, ) else: # This will just try the legacy protocol that doesn't require auth # and doesn't use http try: - device = await Device.connect(config=DeviceConfig(host)) + device = await Device.connect( + config=DeviceConfig(host, port_override=port) + ) except Exception: # noqa: BLE001 return None if device: @@ -462,6 +509,8 @@ async def _async_try_discover_and_update( credentials: Credentials | None, raise_on_progress: bool, raise_on_timeout: bool, + *, + port: int | None = None, ) -> Device | None: """Try to discover the device and call update. @@ -470,7 +519,9 @@ async def _async_try_discover_and_update( self._discovered_device = None try: self._discovered_device = await Discover.discover_single( - host, credentials=credentials + host, + credentials=credentials, + port=port, ) except TimeoutError as ex: if raise_on_timeout: @@ -526,6 +577,7 @@ async def async_step_reauth_confirm( reauth_entry = self._get_reauth_entry() entry_data = reauth_entry.data host = entry_data[CONF_HOST] + port = entry_data.get(CONF_PORT) if user_input: username = user_input[CONF_USERNAME] @@ -537,8 +589,12 @@ async def async_step_reauth_confirm( credentials=credentials, raise_on_progress=False, raise_on_timeout=False, + port=port, ) or await self._async_try_connect_all( - host, credentials=credentials, raise_on_progress=False + host, + credentials=credentials, + raise_on_progress=False, + port=port, ) except AuthenticationError as ex: errors[CONF_PASSWORD] = "invalid_auth" diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index 78cc9304bf771..25a4bd202707c 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -37,7 +37,7 @@ def mock_discovery(): device = _mocked_device( device_config=DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()), credentials_hash=CREDENTIALS_HASH_KLAP, - alias=None, + alias="My Bulb", ) devices = { "127.0.0.1": _mocked_device( diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 12a5741058c81..2697696c6679e 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -2,7 +2,7 @@ from contextlib import contextmanager import logging -from unittest.mock import AsyncMock, patch +from unittest.mock import ANY, AsyncMock, patch from kasa import TimeoutError import pytest @@ -30,6 +30,7 @@ CONF_HOST, CONF_MAC, CONF_PASSWORD, + CONF_PORT, CONF_USERNAME, ) from homeassistant.core import HomeAssistant @@ -665,6 +666,93 @@ async def test_manual_auth_errors( await hass.async_block_till_done() +@pytest.mark.parametrize( + ("host_str", "host", "port"), + [ + (f"{IP_ADDRESS}:1234", IP_ADDRESS, 1234), + ("[2001:db8:0::1]:4321", "2001:db8:0::1", 4321), + ], +) +async def test_manual_port_override( + hass: HomeAssistant, + mock_connect: AsyncMock, + mock_discovery: AsyncMock, + host_str, + host, + port, +) -> None: + """Test manually setup.""" + mock_discovery["mock_device"].config.port_override = port + mock_discovery["mock_device"].host = host + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + # side_effects to cause auth confirm as the port override usually only + # works with direct connections. + mock_discovery["discover_single"].side_effect = TimeoutError + mock_connect["connect"].side_effect = AuthenticationError + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: host_str} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user_auth_confirm" + assert not result2["errors"] + + creds = Credentials("fake_username", "fake_password") + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() + mock_discovery["try_connect_all"].assert_called_once_with( + host, credentials=creds, port=port, http_client=ANY + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == DEFAULT_ENTRY_TITLE + assert result3["data"] == { + **CREATE_ENTRY_DATA_KLAP, + CONF_PORT: port, + CONF_HOST: host, + } + assert result3["context"]["unique_id"] == MAC_ADDRESS + + +async def test_manual_port_override_invalid( + hass: HomeAssistant, mock_connect: AsyncMock, mock_discovery: AsyncMock +) -> None: + """Test manually setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: f"{IP_ADDRESS}:foo"} + ) + await hass.async_block_till_done() + + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=None, port=None + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == DEFAULT_ENTRY_TITLE + assert result2["data"] == CREATE_ENTRY_DATA_KLAP + assert result2["context"]["unique_id"] == MAC_ADDRESS + + async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: """Test we get the form with discovery and abort for dhcp source when we get both.""" @@ -1072,7 +1160,7 @@ async def test_reauth( ) credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials + "127.0.0.1", credentials=credentials, port=None ) mock_discovery["mock_device"].update.assert_called_once_with() assert result2["type"] is FlowResultType.ABORT @@ -1107,7 +1195,7 @@ async def test_reauth_try_connect_all( ) credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials + "127.0.0.1", credentials=credentials, port=None ) mock_discovery["try_connect_all"].assert_called_once() assert result2["type"] is FlowResultType.ABORT @@ -1145,7 +1233,7 @@ async def test_reauth_try_connect_all_fail( ) credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials + "127.0.0.1", credentials=credentials, port=None ) mock_discovery["try_connect_all"].assert_called_once() assert result2["errors"] == {"base": "cannot_connect"} @@ -1214,7 +1302,7 @@ async def test_reauth_update_with_encryption_change( assert "Connection type changed for 127.0.0.2" in caplog.text credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.2", credentials=credentials + "127.0.0.2", credentials=credentials, port=None ) mock_discovery["mock_device"].update.assert_called_once_with() assert result2["type"] is FlowResultType.ABORT @@ -1416,7 +1504,7 @@ async def test_reauth_errors( credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials + "127.0.0.1", credentials=credentials, port=None ) mock_discovery["mock_device"].update.assert_called_once_with() assert result2["type"] is FlowResultType.FORM @@ -1434,7 +1522,7 @@ async def test_reauth_errors( ) mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials + "127.0.0.1", credentials=credentials, port=None ) mock_discovery["mock_device"].update.assert_called_once_with() @@ -1643,7 +1731,7 @@ async def test_reauth_update_other_flows( ) credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials + "127.0.0.1", credentials=credentials, port=None ) mock_discovery["mock_device"].update.assert_called_once_with() assert result2["type"] is FlowResultType.ABORT From b711b171930e275ec303d96df4a3c2f572c96057 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 8 Nov 2024 14:50:41 +0100 Subject: [PATCH 0260/1070] Remove Z-Wave incorrect lock service descriptions (#130034) --- homeassistant/components/zwave_js/services.yaml | 10 ---------- homeassistant/components/zwave_js/strings.json | 8 -------- 2 files changed, 18 deletions(-) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index f5063fdfd9383..acf6e9a066519 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -51,16 +51,6 @@ set_lock_configuration: min: 0 max: 65535 unit_of_measurement: sec - outside_handles_can_open_door_configuration: - required: false - example: [true, true, true, false] - selector: - object: - inside_handles_can_open_door_configuration: - required: false - example: [true, true, true, false] - selector: - object: auto_relock_time: required: false example: 1 diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index ca7d5153e6e0b..28789bbf9f4c6 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -523,10 +523,6 @@ "description": "Duration in seconds the latch stays retracted.", "name": "Hold and release time" }, - "inside_handles_can_open_door_configuration": { - "description": "A list of four booleans which indicate which inside handles can open the door.", - "name": "Inside handles can open door configuration" - }, "lock_timeout": { "description": "Seconds until lock mode times out. Should only be used if operation type is `timed`.", "name": "Lock timeout" @@ -535,10 +531,6 @@ "description": "The operation type of the lock.", "name": "Operation Type" }, - "outside_handles_can_open_door_configuration": { - "description": "A list of four booleans which indicate which outside handles can open the door.", - "name": "Outside handles can open door configuration" - }, "twist_assist": { "description": "Enable Twist Assist.", "name": "Twist assist" From 074418f8f7ab051281513db98a11aa185e131d66 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 8 Nov 2024 14:53:46 +0100 Subject: [PATCH 0261/1070] Drop OptionsFlowWithConfigEntry usage in homeassistant_hardware (#130078) * Drop OptionsFlowWithConfigEntry usage in homeassistant_hardware * Add homeassistant_hardware as other components rely on it * Maybe core_files not needed after all --- .../homeassistant_hardware/firmware_config_flow.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 37d12d2bd6124..a91fb00c142d0 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -24,7 +24,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow @@ -496,13 +495,15 @@ async def async_step_confirm( return await self.async_step_pick_firmware() -class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlowWithConfigEntry): +class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow): """Zigbee and Thread options flow handlers.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__(self, config_entry: ConfigEntry, *args: Any, **kwargs: Any) -> None: """Instantiate options flow.""" super().__init__(*args, **kwargs) + self._config_entry = config_entry + self._probed_firmware_type = ApplicationType(self.config_entry.data["firmware"]) # Make `context` a regular dictionary From 1f32e02ba2ca0af4b29201f6cac9e5d2c32ec75c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 8 Nov 2024 15:10:51 +0100 Subject: [PATCH 0262/1070] Add Nord Pool integration (#129983) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/nordpool/__init__.py | 29 + .../components/nordpool/config_flow.py | 92 + homeassistant/components/nordpool/const.py | 14 + .../components/nordpool/coordinator.py | 95 + homeassistant/components/nordpool/entity.py | 32 + homeassistant/components/nordpool/icons.json | 42 + .../components/nordpool/manifest.json | 12 + homeassistant/components/nordpool/sensor.py | 328 +++ .../components/nordpool/strings.json | 56 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 7 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/nordpool/__init__.py | 9 + tests/components/nordpool/conftest.py | 76 + .../nordpool/fixtures/delivery_period.json | 272 ++ .../nordpool/snapshots/test_sensor.ambr | 2215 +++++++++++++++++ tests/components/nordpool/test_config_flow.py | 151 ++ tests/components/nordpool/test_coordinator.py | 114 + tests/components/nordpool/test_init.py | 39 + tests/components/nordpool/test_sensor.py | 25 + 24 files changed, 3628 insertions(+) create mode 100644 homeassistant/components/nordpool/__init__.py create mode 100644 homeassistant/components/nordpool/config_flow.py create mode 100644 homeassistant/components/nordpool/const.py create mode 100644 homeassistant/components/nordpool/coordinator.py create mode 100644 homeassistant/components/nordpool/entity.py create mode 100644 homeassistant/components/nordpool/icons.json create mode 100644 homeassistant/components/nordpool/manifest.json create mode 100644 homeassistant/components/nordpool/sensor.py create mode 100644 homeassistant/components/nordpool/strings.json create mode 100644 tests/components/nordpool/__init__.py create mode 100644 tests/components/nordpool/conftest.py create mode 100644 tests/components/nordpool/fixtures/delivery_period.json create mode 100644 tests/components/nordpool/snapshots/test_sensor.ambr create mode 100644 tests/components/nordpool/test_config_flow.py create mode 100644 tests/components/nordpool/test_coordinator.py create mode 100644 tests/components/nordpool/test_init.py create mode 100644 tests/components/nordpool/test_sensor.py diff --git a/.strict-typing b/.strict-typing index a980c0901d060..b0fd74bce54fa 100644 --- a/.strict-typing +++ b/.strict-typing @@ -340,6 +340,7 @@ homeassistant.components.nfandroidtv.* homeassistant.components.nightscout.* homeassistant.components.nissan_leaf.* homeassistant.components.no_ip.* +homeassistant.components.nordpool.* homeassistant.components.notify.* homeassistant.components.notion.* homeassistant.components.number.* diff --git a/CODEOWNERS b/CODEOWNERS index e41267860d83c..022eda001233e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1012,6 +1012,8 @@ build.json @home-assistant/supervisor /homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe /tests/components/nobo_hub/ @echoromeo @oyvindwe +/homeassistant/components/nordpool/ @gjohansson-ST +/tests/components/nordpool/ @gjohansson-ST /homeassistant/components/notify/ @home-assistant/core /tests/components/notify/ @home-assistant/core /homeassistant/components/notify_events/ @matrozov @papajojo diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py new file mode 100644 index 0000000000000..b688bf74a3705 --- /dev/null +++ b/homeassistant/components/nordpool/__init__.py @@ -0,0 +1,29 @@ +"""The Nord Pool component.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .const import PLATFORMS +from .coordinator import NordPoolDataUpdateCoordinator + +type NordPoolConfigEntry = ConfigEntry[NordPoolDataUpdateCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) -> bool: + """Set up Nord Pool from a config entry.""" + + coordinator = NordPoolDataUpdateCoordinator(hass, entry) + await coordinator.fetch_data(dt_util.utcnow()) + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) -> bool: + """Unload Nord Pool config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nordpool/config_flow.py b/homeassistant/components/nordpool/config_flow.py new file mode 100644 index 0000000000000..d184c04f3cec9 --- /dev/null +++ b/homeassistant/components/nordpool/config_flow.py @@ -0,0 +1,92 @@ +"""Adds config flow for Nord Pool integration.""" + +from __future__ import annotations + +from typing import Any + +from pynordpool import Currency, NordPoolClient, NordPoolError +from pynordpool.const import AREAS +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_CURRENCY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant.util import dt as dt_util + +from .const import CONF_AREAS, DEFAULT_NAME, DOMAIN + +SELECT_AREAS = [ + SelectOptionDict(value=area, label=name) for area, name in AREAS.items() +] +SELECT_CURRENCY = [currency.value for currency in Currency] + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_AREAS, default=[]): SelectSelector( + SelectSelectorConfig( + options=SELECT_AREAS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + sort=True, + ) + ), + vol.Required(CONF_CURRENCY, default="SEK"): SelectSelector( + SelectSelectorConfig( + options=SELECT_CURRENCY, + multiple=False, + mode=SelectSelectorMode.DROPDOWN, + sort=True, + ) + ), + } +) + + +async def test_api(hass: HomeAssistant, user_input: dict[str, Any]) -> dict[str, str]: + """Test fetch data from Nord Pool.""" + client = NordPoolClient(async_get_clientsession(hass)) + try: + data = await client.async_get_delivery_period( + dt_util.now(), + Currency(user_input[CONF_CURRENCY]), + user_input[CONF_AREAS], + ) + except NordPoolError: + return {"base": "cannot_connect"} + + if not data.raw: + return {"base": "no_data"} + + return {} + + +class NordpoolConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Nord Pool integration.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input: + errors = await test_api(self.hass, user_input) + if not errors: + return self.async_create_entry( + title=DEFAULT_NAME, + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/nordpool/const.py b/homeassistant/components/nordpool/const.py new file mode 100644 index 0000000000000..19a978d946cb4 --- /dev/null +++ b/homeassistant/components/nordpool/const.py @@ -0,0 +1,14 @@ +"""Constants for Nord Pool.""" + +import logging + +from homeassistant.const import Platform + +LOGGER = logging.getLogger(__package__) + +DEFAULT_SCAN_INTERVAL = 60 +DOMAIN = "nordpool" +PLATFORMS = [Platform.SENSOR] +DEFAULT_NAME = "Nord Pool" + +CONF_AREAS = "areas" diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py new file mode 100644 index 0000000000000..27016ae2b4b19 --- /dev/null +++ b/homeassistant/components/nordpool/coordinator.py @@ -0,0 +1,95 @@ +"""DataUpdateCoordinator for the Nord Pool integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from pynordpool import ( + Currency, + DeliveryPeriodData, + NordPoolAuthenticationError, + NordPoolClient, + NordPoolError, + NordPoolResponseError, +) + +from homeassistant.const import CONF_CURRENCY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import CONF_AREAS, DOMAIN, LOGGER + +if TYPE_CHECKING: + from . import NordPoolConfigEntry + + +class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodData]): + """A Nord Pool Data Update Coordinator.""" + + config_entry: NordPoolConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: NordPoolConfigEntry) -> None: + """Initialize the Nord Pool coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + ) + self.client = NordPoolClient(session=async_get_clientsession(hass)) + self.unsub: Callable[[], None] | None = None + + def get_next_interval(self, now: datetime) -> datetime: + """Compute next time an update should occur.""" + next_hour = dt_util.utcnow() + timedelta(hours=1) + next_run = datetime( + next_hour.year, + next_hour.month, + next_hour.day, + next_hour.hour, + tzinfo=dt_util.UTC, + ) + LOGGER.debug("Next update at %s", next_run) + return next_run + + async def async_shutdown(self) -> None: + """Cancel any scheduled call, and ignore new runs.""" + await super().async_shutdown() + if self.unsub: + self.unsub() + self.unsub = None + + async def fetch_data(self, now: datetime) -> None: + """Fetch data from Nord Pool.""" + self.unsub = async_track_point_in_utc_time( + self.hass, self.fetch_data, self.get_next_interval(dt_util.utcnow()) + ) + try: + data = await self.client.async_get_delivery_period( + dt_util.now(), + Currency(self.config_entry.data[CONF_CURRENCY]), + self.config_entry.data[CONF_AREAS], + ) + except NordPoolAuthenticationError as error: + LOGGER.error("Authentication error: %s", error) + self.async_set_update_error(error) + return + except NordPoolResponseError as error: + LOGGER.debug("Response error: %s", error) + self.async_set_update_error(error) + return + except NordPoolError as error: + LOGGER.debug("Connection error: %s", error) + self.async_set_update_error(error) + return + + if not data.raw: + self.async_set_update_error(UpdateFailed("No data")) + return + + self.async_set_updated_data(data) diff --git a/homeassistant/components/nordpool/entity.py b/homeassistant/components/nordpool/entity.py new file mode 100644 index 0000000000000..32240aad12cc8 --- /dev/null +++ b/homeassistant/components/nordpool/entity.py @@ -0,0 +1,32 @@ +"""Base entity for Nord Pool.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NordPoolDataUpdateCoordinator + + +class NordpoolBaseEntity(CoordinatorEntity[NordPoolDataUpdateCoordinator]): + """Representation of a Nord Pool base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: NordPoolDataUpdateCoordinator, + entity_description: EntityDescription, + area: str, + ) -> None: + """Initiate Nord Pool base entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{area}-{entity_description.key}" + self.area = area + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, area)}, + name=f"Nord Pool {area}", + ) diff --git a/homeassistant/components/nordpool/icons.json b/homeassistant/components/nordpool/icons.json new file mode 100644 index 0000000000000..85434a2d09b61 --- /dev/null +++ b/homeassistant/components/nordpool/icons.json @@ -0,0 +1,42 @@ +{ + "entity": { + "sensor": { + "updated_at": { + "default": "mdi:clock-outline" + }, + "currency": { + "default": "mdi:currency-usd" + }, + "exchange_rate": { + "default": "mdi:currency-usd" + }, + "current_price": { + "default": "mdi:cash" + }, + "last_price": { + "default": "mdi:cash" + }, + "next_price": { + "default": "mdi:cash" + }, + "block_average": { + "default": "mdi:cash-multiple" + }, + "block_min": { + "default": "mdi:cash-multiple" + }, + "block_max": { + "default": "mdi:cash-multiple" + }, + "block_start_time": { + "default": "mdi:clock-time-twelve-outline" + }, + "block_end_time": { + "default": "mdi:clock-time-two-outline" + }, + "daily_average": { + "default": "mdi:cash-multiple" + } + } + } +} diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json new file mode 100644 index 0000000000000..ba435c38b5e6e --- /dev/null +++ b/homeassistant/components/nordpool/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "nordpool", + "name": "Nord Pool", + "codeowners": ["@gjohansson-ST"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/nordpool", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["pynordpool"], + "requirements": ["pynordpool==0.2.1"], + "single_config_entry": true +} diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py new file mode 100644 index 0000000000000..e7e655a66572f --- /dev/null +++ b/homeassistant/components/nordpool/sensor.py @@ -0,0 +1,328 @@ +"""Sensor platform for Nord Pool integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from pynordpool import DeliveryPeriodData + +from homeassistant.components.sensor import ( + EntityCategory, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util, slugify + +from . import NordPoolConfigEntry +from .const import LOGGER +from .coordinator import NordPoolDataUpdateCoordinator +from .entity import NordpoolBaseEntity + +PARALLEL_UPDATES = 0 + + +def get_prices(data: DeliveryPeriodData) -> dict[str, tuple[float, float, float]]: + """Return previous, current and next prices. + + Output: {"SE3": (10.0, 10.5, 12.1)} + """ + last_price_entries: dict[str, float] = {} + current_price_entries: dict[str, float] = {} + next_price_entries: dict[str, float] = {} + current_time = dt_util.utcnow() + previous_time = current_time - timedelta(hours=1) + next_time = current_time + timedelta(hours=1) + price_data = data.entries + for entry in price_data: + if entry.start <= current_time <= entry.end: + current_price_entries = entry.entry + if entry.start <= previous_time <= entry.end: + last_price_entries = entry.entry + if entry.start <= next_time <= entry.end: + next_price_entries = entry.entry + + result = {} + for area, price in current_price_entries.items(): + result[area] = (last_price_entries[area], price, next_price_entries[area]) + LOGGER.debug("Prices: %s", result) + return result + + +def get_blockprices( + data: DeliveryPeriodData, +) -> dict[str, dict[str, tuple[datetime, datetime, float, float, float]]]: + """Return average, min and max for block prices. + + Output: {"SE3": {"Off-peak 1": (_datetime_, _datetime_, 9.3, 10.5, 12.1)}} + """ + result: dict[str, dict[str, tuple[datetime, datetime, float, float, float]]] = {} + block_prices = data.block_prices + for entry in block_prices: + for _area in entry.average: + if _area not in result: + result[_area] = {} + result[_area][entry.name] = ( + entry.start, + entry.end, + entry.average[_area]["average"], + entry.average[_area]["min"], + entry.average[_area]["max"], + ) + + LOGGER.debug("Block prices: %s", result) + return result + + +@dataclass(frozen=True, kw_only=True) +class NordpoolDefaultSensorEntityDescription(SensorEntityDescription): + """Describes Nord Pool default sensor entity.""" + + value_fn: Callable[[DeliveryPeriodData], str | float | datetime | None] + + +@dataclass(frozen=True, kw_only=True) +class NordpoolPricesSensorEntityDescription(SensorEntityDescription): + """Describes Nord Pool prices sensor entity.""" + + value_fn: Callable[[tuple[float, float, float]], float | None] + + +@dataclass(frozen=True, kw_only=True) +class NordpoolBlockPricesSensorEntityDescription(SensorEntityDescription): + """Describes Nord Pool block prices sensor entity.""" + + value_fn: Callable[ + [tuple[datetime, datetime, float, float, float]], float | datetime | None + ] + + +DEFAULT_SENSOR_TYPES: tuple[NordpoolDefaultSensorEntityDescription, ...] = ( + NordpoolDefaultSensorEntityDescription( + key="updated_at", + translation_key="updated_at", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.updated_at, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NordpoolDefaultSensorEntityDescription( + key="currency", + translation_key="currency", + value_fn=lambda data: data.currency, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NordpoolDefaultSensorEntityDescription( + key="exchange_rate", + translation_key="exchange_rate", + value_fn=lambda data: data.exchange_rate, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) +PRICES_SENSOR_TYPES: tuple[NordpoolPricesSensorEntityDescription, ...] = ( + NordpoolPricesSensorEntityDescription( + key="current_price", + translation_key="current_price", + value_fn=lambda data: data[1] / 1000, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + NordpoolPricesSensorEntityDescription( + key="last_price", + translation_key="last_price", + value_fn=lambda data: data[0] / 1000, + suggested_display_precision=2, + ), + NordpoolPricesSensorEntityDescription( + key="next_price", + translation_key="next_price", + value_fn=lambda data: data[2] / 1000, + suggested_display_precision=2, + ), +) +BLOCK_PRICES_SENSOR_TYPES: tuple[NordpoolBlockPricesSensorEntityDescription, ...] = ( + NordpoolBlockPricesSensorEntityDescription( + key="block_average", + translation_key="block_average", + value_fn=lambda data: data[2] / 1000, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + NordpoolBlockPricesSensorEntityDescription( + key="block_min", + translation_key="block_min", + value_fn=lambda data: data[3] / 1000, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + NordpoolBlockPricesSensorEntityDescription( + key="block_max", + translation_key="block_max", + value_fn=lambda data: data[4] / 1000, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + NordpoolBlockPricesSensorEntityDescription( + key="block_start_time", + translation_key="block_start_time", + value_fn=lambda data: data[0], + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + ), + NordpoolBlockPricesSensorEntityDescription( + key="block_end_time", + translation_key="block_end_time", + value_fn=lambda data: data[1], + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + ), +) +DAILY_AVERAGE_PRICES_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="daily_average", + translation_key="daily_average", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NordPoolConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Nord Pool sensor platform.""" + + coordinator = entry.runtime_data + + entities: list[NordpoolBaseEntity] = [] + currency = entry.runtime_data.data.currency + + for area in get_prices(entry.runtime_data.data): + LOGGER.debug("Setting up base sensors for area %s", area) + entities.extend( + NordpoolSensor(coordinator, description, area) + for description in DEFAULT_SENSOR_TYPES + ) + LOGGER.debug( + "Setting up price sensors for area %s with currency %s", area, currency + ) + entities.extend( + NordpoolPriceSensor(coordinator, description, area, currency) + for description in PRICES_SENSOR_TYPES + ) + entities.extend( + NordpoolDailyAveragePriceSensor(coordinator, description, area, currency) + for description in DAILY_AVERAGE_PRICES_SENSOR_TYPES + ) + for block_name in get_blockprices(coordinator.data)[area]: + LOGGER.debug( + "Setting up block price sensors for area %s with currency %s in block %s", + area, + currency, + block_name, + ) + entities.extend( + NordpoolBlockPriceSensor( + coordinator, description, area, currency, block_name + ) + for description in BLOCK_PRICES_SENSOR_TYPES + ) + async_add_entities(entities) + + +class NordpoolSensor(NordpoolBaseEntity, SensorEntity): + """Representation of a Nord Pool sensor.""" + + entity_description: NordpoolDefaultSensorEntityDescription + + @property + def native_value(self) -> str | float | datetime | None: + """Return value of sensor.""" + return self.entity_description.value_fn(self.coordinator.data) + + +class NordpoolPriceSensor(NordpoolBaseEntity, SensorEntity): + """Representation of a Nord Pool price sensor.""" + + entity_description: NordpoolPricesSensorEntityDescription + + def __init__( + self, + coordinator: NordPoolDataUpdateCoordinator, + entity_description: NordpoolPricesSensorEntityDescription, + area: str, + currency: str, + ) -> None: + """Initiate Nord Pool sensor.""" + super().__init__(coordinator, entity_description, area) + self._attr_native_unit_of_measurement = f"{currency}/kWh" + + @property + def native_value(self) -> float | None: + """Return value of sensor.""" + return self.entity_description.value_fn( + get_prices(self.coordinator.data)[self.area] + ) + + +class NordpoolBlockPriceSensor(NordpoolBaseEntity, SensorEntity): + """Representation of a Nord Pool block price sensor.""" + + entity_description: NordpoolBlockPricesSensorEntityDescription + + def __init__( + self, + coordinator: NordPoolDataUpdateCoordinator, + entity_description: NordpoolBlockPricesSensorEntityDescription, + area: str, + currency: str, + block_name: str, + ) -> None: + """Initiate Nord Pool sensor.""" + super().__init__(coordinator, entity_description, area) + if entity_description.device_class is not SensorDeviceClass.TIMESTAMP: + self._attr_native_unit_of_measurement = f"{currency}/kWh" + self._attr_unique_id = f"{slugify(block_name)}-{area}-{entity_description.key}" + self.block_name = block_name + self._attr_translation_placeholders = {"block": block_name} + + @property + def native_value(self) -> float | datetime | None: + """Return value of sensor.""" + return self.entity_description.value_fn( + get_blockprices(self.coordinator.data)[self.area][self.block_name] + ) + + +class NordpoolDailyAveragePriceSensor(NordpoolBaseEntity, SensorEntity): + """Representation of a Nord Pool daily average price sensor.""" + + entity_description: SensorEntityDescription + + def __init__( + self, + coordinator: NordPoolDataUpdateCoordinator, + entity_description: SensorEntityDescription, + area: str, + currency: str, + ) -> None: + """Initiate Nord Pool sensor.""" + super().__init__(coordinator, entity_description, area) + self._attr_native_unit_of_measurement = f"{currency}/kWh" + + @property + def native_value(self) -> float | None: + """Return value of sensor.""" + return self.coordinator.data.area_average[self.area] / 1000 diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json new file mode 100644 index 0000000000000..e55950c7d678d --- /dev/null +++ b/homeassistant/components/nordpool/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_data": "API connected but the response was empty" + }, + "step": { + "user": { + "data": { + "currency": "Currency", + "areas": "Areas" + } + } + } + }, + "entity": { + "sensor": { + "updated_at": { + "name": "Last updated" + }, + "currency": { + "name": "Currency" + }, + "exchange_rate": { + "name": "Exchange rate" + }, + "current_price": { + "name": "Current price" + }, + "last_price": { + "name": "Previous price" + }, + "next_price": { + "name": "Next price" + }, + "block_average": { + "name": "{block} average" + }, + "block_min": { + "name": "{block} lowest price" + }, + "block_max": { + "name": "{block} highest price" + }, + "block_start_time": { + "name": "{block} time from" + }, + "block_end_time": { + "name": "{block} time until" + }, + "daily_average": { + "name": "Daily average" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 887fb99a0929c..cbd30b560ce75 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -408,6 +408,7 @@ "nina", "nmap_tracker", "nobo_hub", + "nordpool", "notion", "nuheat", "nuki", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 14b8550d296e8..a1fdb9478f3ab 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4187,6 +4187,13 @@ "config_flow": true, "iot_class": "local_push" }, + "nordpool": { + "name": "Nord Pool", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "single_config_entry": true + }, "norway_air": { "name": "Om Luftkvalitet i Norge (Norway Air)", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 15d1777f38163..4d33f16d968e1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3156,6 +3156,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.nordpool.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.notify.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 627d9937995ca..95d759b3211ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2095,6 +2095,9 @@ pynetio==0.1.9.1 # homeassistant.components.nobo_hub pynobo==1.8.1 +# homeassistant.components.nordpool +pynordpool==0.2.1 + # homeassistant.components.nuki pynuki==1.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b726627f1d6e3..0ac8e41900eb4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1688,6 +1688,9 @@ pynetgear==0.10.10 # homeassistant.components.nobo_hub pynobo==1.8.1 +# homeassistant.components.nordpool +pynordpool==0.2.1 + # homeassistant.components.nuki pynuki==1.6.3 diff --git a/tests/components/nordpool/__init__.py b/tests/components/nordpool/__init__.py new file mode 100644 index 0000000000000..20d74d3848637 --- /dev/null +++ b/tests/components/nordpool/__init__.py @@ -0,0 +1,9 @@ +"""Tests for the Nord Pool integration.""" + +from homeassistant.components.nordpool.const import CONF_AREAS +from homeassistant.const import CONF_CURRENCY + +ENTRY_CONFIG = { + CONF_AREAS: ["SE3", "SE4"], + CONF_CURRENCY: "SEK", +} diff --git a/tests/components/nordpool/conftest.py b/tests/components/nordpool/conftest.py new file mode 100644 index 0000000000000..305179c531ada --- /dev/null +++ b/tests/components/nordpool/conftest.py @@ -0,0 +1,76 @@ +"""Fixtures for the Nord Pool integration.""" + +from __future__ import annotations + +from datetime import datetime +import json +from typing import Any +from unittest.mock import patch + +from pynordpool import NordPoolClient +from pynordpool.const import Currency +from pynordpool.model import DeliveryPeriodData +import pytest + +from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.fixture +async def load_int( + hass: HomeAssistant, get_data: DeliveryPeriodData +) -> MockConfigEntry: + """Set up the Nord Pool integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + ) + + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="get_data") +async def get_data_from_library( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, load_json: dict[str, Any] +) -> DeliveryPeriodData: + """Retrieve data from Nord Pool library.""" + + client = NordPoolClient(aioclient_mock.create_session(hass.loop)) + with patch("pynordpool.NordPoolClient._get", return_value=load_json): + output = await client.async_get_delivery_period( + datetime(2024, 11, 5, 13, tzinfo=dt_util.UTC), Currency.SEK, ["SE3", "SE4"] + ) + await client._session.close() + return output + + +@pytest.fixture(name="load_json") +def load_json_from_fixture(load_data: str) -> dict[str, Any]: + """Load fixture with json data and return.""" + return json.loads(load_data) + + +@pytest.fixture(name="load_data", scope="package") +def load_data_from_fixture() -> str: + """Load fixture with fixture data and return.""" + return load_fixture("delivery_period.json", DOMAIN) diff --git a/tests/components/nordpool/fixtures/delivery_period.json b/tests/components/nordpool/fixtures/delivery_period.json new file mode 100644 index 0000000000000..77d51dc94339b --- /dev/null +++ b/tests/components/nordpool/fixtures/delivery_period.json @@ -0,0 +1,272 @@ +{ + "deliveryDateCET": "2024-11-05", + "version": 3, + "updatedAt": "2024-11-04T12:15:03.9456464Z", + "deliveryAreas": ["SE3", "SE4"], + "market": "DayAhead", + "multiAreaEntries": [ + { + "deliveryStart": "2024-11-04T23:00:00Z", + "deliveryEnd": "2024-11-05T00:00:00Z", + "entryPerArea": { + "SE3": 250.73, + "SE4": 283.79 + } + }, + { + "deliveryStart": "2024-11-05T00:00:00Z", + "deliveryEnd": "2024-11-05T01:00:00Z", + "entryPerArea": { + "SE3": 76.36, + "SE4": 81.36 + } + }, + { + "deliveryStart": "2024-11-05T01:00:00Z", + "deliveryEnd": "2024-11-05T02:00:00Z", + "entryPerArea": { + "SE3": 73.92, + "SE4": 79.15 + } + }, + { + "deliveryStart": "2024-11-05T02:00:00Z", + "deliveryEnd": "2024-11-05T03:00:00Z", + "entryPerArea": { + "SE3": 61.69, + "SE4": 65.19 + } + }, + { + "deliveryStart": "2024-11-05T03:00:00Z", + "deliveryEnd": "2024-11-05T04:00:00Z", + "entryPerArea": { + "SE3": 64.6, + "SE4": 68.44 + } + }, + { + "deliveryStart": "2024-11-05T04:00:00Z", + "deliveryEnd": "2024-11-05T05:00:00Z", + "entryPerArea": { + "SE3": 453.27, + "SE4": 516.71 + } + }, + { + "deliveryStart": "2024-11-05T05:00:00Z", + "deliveryEnd": "2024-11-05T06:00:00Z", + "entryPerArea": { + "SE3": 996.28, + "SE4": 1240.85 + } + }, + { + "deliveryStart": "2024-11-05T06:00:00Z", + "deliveryEnd": "2024-11-05T07:00:00Z", + "entryPerArea": { + "SE3": 1406.14, + "SE4": 1648.25 + } + }, + { + "deliveryStart": "2024-11-05T07:00:00Z", + "deliveryEnd": "2024-11-05T08:00:00Z", + "entryPerArea": { + "SE3": 1346.54, + "SE4": 1570.5 + } + }, + { + "deliveryStart": "2024-11-05T08:00:00Z", + "deliveryEnd": "2024-11-05T09:00:00Z", + "entryPerArea": { + "SE3": 1150.28, + "SE4": 1345.37 + } + }, + { + "deliveryStart": "2024-11-05T09:00:00Z", + "deliveryEnd": "2024-11-05T10:00:00Z", + "entryPerArea": { + "SE3": 1031.32, + "SE4": 1206.51 + } + }, + { + "deliveryStart": "2024-11-05T10:00:00Z", + "deliveryEnd": "2024-11-05T11:00:00Z", + "entryPerArea": { + "SE3": 927.37, + "SE4": 1085.8 + } + }, + { + "deliveryStart": "2024-11-05T11:00:00Z", + "deliveryEnd": "2024-11-05T12:00:00Z", + "entryPerArea": { + "SE3": 925.05, + "SE4": 1081.72 + } + }, + { + "deliveryStart": "2024-11-05T12:00:00Z", + "deliveryEnd": "2024-11-05T13:00:00Z", + "entryPerArea": { + "SE3": 949.49, + "SE4": 1130.38 + } + }, + { + "deliveryStart": "2024-11-05T13:00:00Z", + "deliveryEnd": "2024-11-05T14:00:00Z", + "entryPerArea": { + "SE3": 1042.03, + "SE4": 1256.91 + } + }, + { + "deliveryStart": "2024-11-05T14:00:00Z", + "deliveryEnd": "2024-11-05T15:00:00Z", + "entryPerArea": { + "SE3": 1258.89, + "SE4": 1765.82 + } + }, + { + "deliveryStart": "2024-11-05T15:00:00Z", + "deliveryEnd": "2024-11-05T16:00:00Z", + "entryPerArea": { + "SE3": 1816.45, + "SE4": 2522.55 + } + }, + { + "deliveryStart": "2024-11-05T16:00:00Z", + "deliveryEnd": "2024-11-05T17:00:00Z", + "entryPerArea": { + "SE3": 2512.65, + "SE4": 3533.03 + } + }, + { + "deliveryStart": "2024-11-05T17:00:00Z", + "deliveryEnd": "2024-11-05T18:00:00Z", + "entryPerArea": { + "SE3": 1819.83, + "SE4": 2524.06 + } + }, + { + "deliveryStart": "2024-11-05T18:00:00Z", + "deliveryEnd": "2024-11-05T19:00:00Z", + "entryPerArea": { + "SE3": 1011.77, + "SE4": 1804.46 + } + }, + { + "deliveryStart": "2024-11-05T19:00:00Z", + "deliveryEnd": "2024-11-05T20:00:00Z", + "entryPerArea": { + "SE3": 835.53, + "SE4": 1112.57 + } + }, + { + "deliveryStart": "2024-11-05T20:00:00Z", + "deliveryEnd": "2024-11-05T21:00:00Z", + "entryPerArea": { + "SE3": 796.19, + "SE4": 1051.69 + } + }, + { + "deliveryStart": "2024-11-05T21:00:00Z", + "deliveryEnd": "2024-11-05T22:00:00Z", + "entryPerArea": { + "SE3": 522.3, + "SE4": 662.44 + } + }, + { + "deliveryStart": "2024-11-05T22:00:00Z", + "deliveryEnd": "2024-11-05T23:00:00Z", + "entryPerArea": { + "SE3": 289.14, + "SE4": 349.21 + } + } + ], + "blockPriceAggregates": [ + { + "blockName": "Off-peak 1", + "deliveryStart": "2024-11-04T23:00:00Z", + "deliveryEnd": "2024-11-05T07:00:00Z", + "averagePricePerArea": { + "SE3": { + "average": 422.87, + "min": 61.69, + "max": 1406.14 + }, + "SE4": { + "average": 497.97, + "min": 65.19, + "max": 1648.25 + } + } + }, + { + "blockName": "Peak", + "deliveryStart": "2024-11-05T07:00:00Z", + "deliveryEnd": "2024-11-05T19:00:00Z", + "averagePricePerArea": { + "SE3": { + "average": 1315.97, + "min": 925.05, + "max": 2512.65 + }, + "SE4": { + "average": 1735.59, + "min": 1081.72, + "max": 3533.03 + } + } + }, + { + "blockName": "Off-peak 2", + "deliveryStart": "2024-11-05T19:00:00Z", + "deliveryEnd": "2024-11-05T23:00:00Z", + "averagePricePerArea": { + "SE3": { + "average": 610.79, + "min": 289.14, + "max": 835.53 + }, + "SE4": { + "average": 793.98, + "min": 349.21, + "max": 1112.57 + } + } + } + ], + "currency": "SEK", + "exchangeRate": 11.6402, + "areaStates": [ + { + "state": "Final", + "areas": ["SE3", "SE4"] + } + ], + "areaAverages": [ + { + "areaCode": "SE3", + "price": 900.74 + }, + { + "areaCode": "SE4", + "price": 1166.12 + } + ] +} diff --git a/tests/components/nordpool/snapshots/test_sensor.ambr b/tests/components/nordpool/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..0160035286141 --- /dev/null +++ b/tests/components/nordpool/snapshots/test_sensor.ambr @@ -0,0 +1,2215 @@ +# serializer version: 1 +# name: test_sensor[sensor.nord_pool_se3_currency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nord_pool_se3_currency', + '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': 'Currency', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'currency', + 'unique_id': 'SE3-currency', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_currency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Currency', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_currency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'SEK', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_current_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_current_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_price', + 'unique_id': 'SE3-current_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_current_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Current price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_current_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.01177', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_daily_average-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_daily_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Daily average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_average', + 'unique_id': 'SE3-daily_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_daily_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Daily average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_daily_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.90074', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_exchange_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nord_pool_se3_exchange_rate', + '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': 'Exchange rate', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'exchange_rate', + 'unique_id': 'SE3-exchange_rate', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_exchange_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Exchange rate', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_exchange_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.6402', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_last_updated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nord_pool_se3_last_updated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last updated', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'updated_at', + 'unique_id': 'SE3-updated_at', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_last_updated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Last updated', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_last_updated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-04T12:15:03+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_next_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_next_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_price', + 'unique_id': 'SE3-next_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_next_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Next price', + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_next_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.83553', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_average-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 1 average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_average', + 'unique_id': 'off_peak_1-SE3-block_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Off-peak 1 average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.42287', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_highest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 1 highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_max', + 'unique_id': 'off_peak_1-SE3-block_max', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Off-peak 1 highest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.40614', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_lowest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 1 lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_min', + 'unique_id': 'off_peak_1-SE3-block_min', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Off-peak 1 lowest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.06169', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_from-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_time_from', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 1 time from', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_start_time', + 'unique_id': 'off_peak_1-SE3-block_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_from-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Off-peak 1 time from', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_time_from', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-04T23:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_until-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_time_until', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 1 time until', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_end_time', + 'unique_id': 'off_peak_1-SE3-block_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_until-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Off-peak 1 time until', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_time_until', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T07:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_average-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 2 average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_average', + 'unique_id': 'off_peak_2-SE3-block_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Off-peak 2 average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.61079', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_highest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 2 highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_max', + 'unique_id': 'off_peak_2-SE3-block_max', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Off-peak 2 highest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.83553', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_lowest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 2 lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_min', + 'unique_id': 'off_peak_2-SE3-block_min', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Off-peak 2 lowest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.28914', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_from-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_time_from', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 2 time from', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_start_time', + 'unique_id': 'off_peak_2-SE3-block_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_from-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Off-peak 2 time from', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_time_from', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T19:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_until-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_time_until', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 2 time until', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_end_time', + 'unique_id': 'off_peak_2-SE3-block_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_until-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Off-peak 2 time until', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_time_until', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T23:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_average-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_peak_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Peak average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_average', + 'unique_id': 'peak-SE3-block_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Peak average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_peak_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.31597', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_highest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_peak_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Peak highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_max', + 'unique_id': 'peak-SE3-block_max', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Peak highest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_peak_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.51265', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_lowest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_peak_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Peak lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_min', + 'unique_id': 'peak-SE3-block_min', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Peak lowest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_peak_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.92505', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_time_from-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_peak_time_from', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak time from', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_start_time', + 'unique_id': 'peak-SE3-block_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_time_from-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Peak time from', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_peak_time_from', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T07:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_time_until-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_peak_time_until', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak time until', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_end_time', + 'unique_id': 'peak-SE3-block_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_time_until-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Peak time until', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_peak_time_until', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T19:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_previous_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_previous_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Previous price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_price', + 'unique_id': 'SE3-last_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_previous_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Previous price', + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_previous_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.81983', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_currency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nord_pool_se4_currency', + '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': 'Currency', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'currency', + 'unique_id': 'SE4-currency', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_currency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Currency', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_currency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'SEK', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_current_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_current_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_price', + 'unique_id': 'SE4-current_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_current_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Current price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_current_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.80446', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_daily_average-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_daily_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Daily average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_average', + 'unique_id': 'SE4-daily_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_daily_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Daily average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_daily_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.16612', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_exchange_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nord_pool_se4_exchange_rate', + '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': 'Exchange rate', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'exchange_rate', + 'unique_id': 'SE4-exchange_rate', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_exchange_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Exchange rate', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_exchange_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.6402', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_last_updated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nord_pool_se4_last_updated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last updated', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'updated_at', + 'unique_id': 'SE4-updated_at', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_last_updated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Last updated', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_last_updated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-04T12:15:03+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_next_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_next_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_price', + 'unique_id': 'SE4-next_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_next_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Next price', + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_next_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.11257', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_average-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 1 average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_average', + 'unique_id': 'off_peak_1-SE4-block_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Off-peak 1 average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.49797', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_highest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 1 highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_max', + 'unique_id': 'off_peak_1-SE4-block_max', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Off-peak 1 highest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.64825', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_lowest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 1 lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_min', + 'unique_id': 'off_peak_1-SE4-block_min', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Off-peak 1 lowest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.06519', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_from-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_time_from', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 1 time from', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_start_time', + 'unique_id': 'off_peak_1-SE4-block_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_from-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Off-peak 1 time from', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_time_from', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-04T23:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_until-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_time_until', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 1 time until', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_end_time', + 'unique_id': 'off_peak_1-SE4-block_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_until-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Off-peak 1 time until', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_time_until', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T07:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_average-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 2 average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_average', + 'unique_id': 'off_peak_2-SE4-block_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Off-peak 2 average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.79398', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_highest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 2 highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_max', + 'unique_id': 'off_peak_2-SE4-block_max', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Off-peak 2 highest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.11257', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_lowest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 2 lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_min', + 'unique_id': 'off_peak_2-SE4-block_min', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Off-peak 2 lowest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.34921', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_from-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_time_from', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 2 time from', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_start_time', + 'unique_id': 'off_peak_2-SE4-block_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_from-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Off-peak 2 time from', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_time_from', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T19:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_until-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_time_until', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 2 time until', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_end_time', + 'unique_id': 'off_peak_2-SE4-block_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_until-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Off-peak 2 time until', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_time_until', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T23:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_average-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_peak_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Peak average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_average', + 'unique_id': 'peak-SE4-block_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Peak average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_peak_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.73559', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_highest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_peak_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Peak highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_max', + 'unique_id': 'peak-SE4-block_max', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Peak highest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_peak_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.53303', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_lowest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_peak_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Peak lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_min', + 'unique_id': 'peak-SE4-block_min', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Peak lowest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_peak_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.08172', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_time_from-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_peak_time_from', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak time from', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_start_time', + 'unique_id': 'peak-SE4-block_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_time_from-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Peak time from', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_peak_time_from', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T07:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_time_until-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_peak_time_until', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak time until', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_end_time', + 'unique_id': 'peak-SE4-block_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_time_until-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Peak time until', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_peak_time_until', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T19:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_previous_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_previous_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Previous price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_price', + 'unique_id': 'SE4-last_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_previous_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Previous price', + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_previous_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.52406', + }) +# --- diff --git a/tests/components/nordpool/test_config_flow.py b/tests/components/nordpool/test_config_flow.py new file mode 100644 index 0000000000000..dbd85a07a1724 --- /dev/null +++ b/tests/components/nordpool/test_config_flow.py @@ -0,0 +1,151 @@ +"""Test the Nord Pool config flow.""" + +from __future__ import annotations + +from dataclasses import replace +from unittest.mock import patch + +from pynordpool import ( + DeliveryPeriodData, + NordPoolAuthenticationError, + NordPoolConnectionError, + NordPoolError, + NordPoolResponseError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ENTRY_CONFIG + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_form(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + ENTRY_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["version"] == 1 + assert result["title"] == "Nord Pool" + assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_single_config_entry( + hass: HomeAssistant, load_int: None, get_data: DeliveryPeriodData +) -> None: + """Test abort for single config entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.parametrize( + ("error_message", "p_error"), + [ + (NordPoolConnectionError, "cannot_connect"), + (NordPoolAuthenticationError, "cannot_connect"), + (NordPoolError, "cannot_connect"), + (NordPoolResponseError, "cannot_connect"), + ], +) +async def test_cannot_connect( + hass: HomeAssistant, + get_data: DeliveryPeriodData, + error_message: Exception, + p_error: str, +) -> None: + """Test cannot connect error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=error_message, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result["errors"] == {"base": p_error} + + with patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nord Pool" + assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_empty_data(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: + """Test empty data error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + + invalid_data = replace(get_data, raw={}) + + with patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=invalid_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result["errors"] == {"base": "no_data"} + + with patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nord Pool" + assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py new file mode 100644 index 0000000000000..9cff34adb1f28 --- /dev/null +++ b/tests/components/nordpool/test_coordinator.py @@ -0,0 +1,114 @@ +"""The test for the Nord Pool coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from pynordpool import ( + DeliveryPeriodData, + NordPoolAuthenticationError, + NordPoolError, + NordPoolResponseError, +) +import pytest + +from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.freeze_time("2024-11-05T12:00:00+00:00") +async def test_coordinator( + hass: HomeAssistant, + get_data: DeliveryPeriodData, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the Nord Pool coordinator with errors.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + ) + + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + ) as mock_data, + ): + mock_data.return_value = get_data + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + mock_data.assert_called_once() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == "0.94949" + mock_data.reset_mock() + + mock_data.side_effect = NordPoolError("error") + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + mock_data.assert_called_once() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + mock_data.reset_mock() + + assert "Authentication error" not in caplog.text + mock_data.side_effect = NordPoolAuthenticationError("Authentication error") + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + mock_data.assert_called_once() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "Authentication error" in caplog.text + mock_data.reset_mock() + + assert "Response error" not in caplog.text + mock_data.side_effect = NordPoolResponseError("Response error") + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + mock_data.assert_called_once() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "Response error" in caplog.text + mock_data.reset_mock() + + mock_data.return_value = DeliveryPeriodData( + raw={}, + requested_date="2024-11-05", + updated_at=dt_util.utcnow(), + entries=[], + block_prices=[], + currency="SEK", + exchange_rate=1, + area_average={}, + ) + mock_data.side_effect = None + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_data.assert_called_once() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + mock_data.reset_mock() + + mock_data.return_value = get_data + mock_data.side_effect = None + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_data.assert_called_once() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == "1.81983" diff --git a/tests/components/nordpool/test_init.py b/tests/components/nordpool/test_init.py new file mode 100644 index 0000000000000..5ec1c4b3a0bf9 --- /dev/null +++ b/tests/components/nordpool/test_init.py @@ -0,0 +1,39 @@ +"""Test for Nord Pool component Init.""" + +from __future__ import annotations + +from unittest.mock import patch + +from pynordpool import DeliveryPeriodData + +from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: + """Test load and unload an entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/nordpool/test_sensor.py b/tests/components/nordpool/test_sensor.py new file mode 100644 index 0000000000000..c7a305c8a40d6 --- /dev/null +++ b/tests/components/nordpool/test_sensor.py @@ -0,0 +1,25 @@ +"""The test for the Nord Pool sensor platform.""" + +from __future__ import annotations + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import snapshot_platform + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + load_int: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Nord Pool sensor.""" + + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) From 3eab0b704e551f4740251b65cdbf3c8814b84e74 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 8 Nov 2024 16:12:18 +0200 Subject: [PATCH 0263/1070] Get/Set custom config parameter for zwave_js node (#129332) * Get/Set custom config parameter for zwave_js node * add tests * handle errors on set * test FailedCommand --- homeassistant/components/zwave_js/api.py | 71 +++++++++ tests/components/zwave_js/test_api.py | 176 +++++++++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 7d3bd8273ecef..bd49e85b601a1 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -56,6 +56,7 @@ async_parse_qr_code_string, async_try_parse_dsk_from_qr_code_string, ) +from zwave_js_server.model.value import ConfigurationValueFormat from zwave_js_server.util.node import async_set_config_parameter from homeassistant.components import websocket_api @@ -106,6 +107,8 @@ PROPERTY_KEY = "property_key" ENDPOINT = "endpoint" VALUE = "value" +VALUE_SIZE = "value_size" +VALUE_FORMAT = "value_format" # constants for log config commands CONFIG = "config" @@ -416,6 +419,8 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_rebuild_node_routes) websocket_api.async_register_command(hass, websocket_set_config_parameter) websocket_api.async_register_command(hass, websocket_get_config_parameters) + websocket_api.async_register_command(hass, websocket_get_raw_config_parameter) + websocket_api.async_register_command(hass, websocket_set_raw_config_parameter) websocket_api.async_register_command(hass, websocket_subscribe_log_updates) websocket_api.async_register_command(hass, websocket_update_log_config) websocket_api.async_register_command(hass, websocket_get_log_config) @@ -1760,6 +1765,72 @@ async def websocket_get_config_parameters( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/set_raw_config_parameter", + vol.Required(DEVICE_ID): str, + vol.Required(PROPERTY): int, + vol.Required(VALUE): int, + vol.Required(VALUE_SIZE): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)), + vol.Required(VALUE_FORMAT): vol.Coerce(ConfigurationValueFormat), + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_node +async def websocket_set_raw_config_parameter( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + node: Node, +) -> None: + """Set a custom config parameter value for a Z-Wave node.""" + result = await node.async_set_raw_config_parameter_value( + msg[VALUE], + msg[PROPERTY], + value_size=msg[VALUE_SIZE], + value_format=msg[VALUE_FORMAT], + ) + + connection.send_result( + msg[ID], + { + STATUS: result.status, + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/get_raw_config_parameter", + vol.Required(DEVICE_ID): str, + vol.Required(PROPERTY): int, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_node +async def websocket_get_raw_config_parameter( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + node: Node, +) -> None: + """Get a custom config parameter value for a Z-Wave node.""" + value = await node.async_get_raw_config_parameter_value( + msg[PROPERTY], + ) + + connection.send_result( + msg[ID], + { + VALUE: value, + }, + ) + + def filename_is_present_if_logging_to_file(obj: dict) -> dict: """Validate that filename is provided if log_to_file is True.""" if obj.get(LOG_TO_FILE, False) and FILENAME not in obj: diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 8251d7d280fc7..df1adbc98e5c3 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -78,6 +78,8 @@ TYPE, UUID, VALUE, + VALUE_FORMAT, + VALUE_SIZE, VERSION, ) from homeassistant.components.zwave_js.const import ( @@ -3137,6 +3139,180 @@ async def test_get_config_parameters( assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_set_raw_config_parameter( + hass: HomeAssistant, + client, + multisensor_6, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that the set_raw_config_parameter WS API call works.""" + entry = integration + ws_client = await hass_ws_client(hass) + device = get_device(hass, multisensor_6) + + # Change from async_send_command to async_send_command_no_wait + client.async_send_command_no_wait.return_value = None + + # Test setting a raw config parameter value + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/set_raw_config_parameter", + DEVICE_ID: device.id, + PROPERTY: 102, + VALUE: 1, + VALUE_SIZE: 2, + VALUE_FORMAT: 1, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"]["status"] == "queued" + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.set_raw_config_parameter_value" + assert args["nodeId"] == multisensor_6.node_id + assert args["options"]["parameter"] == 102 + assert args["options"]["value"] == 1 + assert args["options"]["valueSize"] == 2 + assert args["options"]["valueFormat"] == 1 + + # Reset the mock for async_send_command_no_wait instead + client.async_send_command_no_wait.reset_mock() + + # Test getting non-existent node fails + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/set_raw_config_parameter", + DEVICE_ID: "fake_device", + PROPERTY: 102, + VALUE: 1, + VALUE_SIZE: 2, + VALUE_FORMAT: 1, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/set_raw_config_parameter", + DEVICE_ID: device.id, + PROPERTY: 102, + VALUE: 1, + VALUE_SIZE: 2, + VALUE_FORMAT: 1, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_get_raw_config_parameter( + hass: HomeAssistant, + multisensor_6, + integration, + client, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the get_raw_config_parameter websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + device = get_device(hass, multisensor_6) + + client.async_send_command.return_value = {"value": 1} + + # Test getting a raw config parameter value + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/get_raw_config_parameter", + DEVICE_ID: device.id, + PROPERTY: 102, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"]["value"] == 1 + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.get_raw_config_parameter_value" + assert args["nodeId"] == multisensor_6.node_id + assert args["options"]["parameter"] == 102 + + client.async_send_command.reset_mock() + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.node.Node.async_get_raw_config_parameter_value", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/get_raw_config_parameter", + DEVICE_ID: device.id, + PROPERTY: 102, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" + + # Test getting non-existent node fails + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/get_raw_config_parameter", + DEVICE_ID: "fake_device", + PROPERTY: 102, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + # Test FailedCommand exception + client.async_send_command.side_effect = FailedCommand("test", "test") + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/get_raw_config_parameter", + DEVICE_ID: device.id, + PROPERTY: 102, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "test" + assert msg["error"]["message"] == "Command failed: test" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/get_raw_config_parameter", + DEVICE_ID: device.id, + PROPERTY: 102, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + @pytest.mark.parametrize( ("firmware_data", "expected_data"), [({"target": "1"}, {"firmware_target": 1}), ({}, {})], From 52ed1bf44abb95928e67a6d65bedeef583d006ba Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 8 Nov 2024 15:13:05 +0100 Subject: [PATCH 0264/1070] Update frontend to 20241106.2 (#130128) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 1ac7e661abe5f..4dc5a2b0ae47c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241106.1"] + "requirements": ["home-assistant-frontend==20241106.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 05fabb340ff39..c73cb5edaa36b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241106.1 +home-assistant-frontend==20241106.2 home-assistant-intents==2024.11.6 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 95d759b3211ad..0309ab20c3563 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241106.1 +home-assistant-frontend==20241106.2 # homeassistant.components.conversation home-assistant-intents==2024.11.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ac8e41900eb4..644be49d95a36 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241106.1 +home-assistant-frontend==20241106.2 # homeassistant.components.conversation home-assistant-intents==2024.11.6 From 6c7ac7a6ef5bbe48b10576d3f0398be1af29b441 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Nov 2024 15:53:26 +0100 Subject: [PATCH 0265/1070] Bump spotifyaio to 0.8.7 (#130140) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 8cf8d73555382..afe352904cebd 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.5"], + "requirements": ["spotifyaio==0.8.7"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 0309ab20c3563..b1882cd620f9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2713,7 +2713,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.5 +spotifyaio==0.8.7 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 644be49d95a36..7a923dc8422a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2165,7 +2165,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.5 +spotifyaio==0.8.7 # homeassistant.components.sql sqlparse==0.5.0 From 51e691f8321e30cb25c0de24b92e52cfd699f5b3 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 8 Nov 2024 15:54:14 +0100 Subject: [PATCH 0266/1070] Add go2rtc workaround for HA managed one until upstream fixes it (#130139) --- homeassistant/components/go2rtc/__init__.py | 75 +++++-- homeassistant/components/go2rtc/const.py | 1 + homeassistant/components/go2rtc/server.py | 15 +- tests/components/go2rtc/test_init.py | 211 ++++++++++++++++++-- tests/components/go2rtc/test_server.py | 5 +- 5 files changed, 270 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index a07a62305f2ee..ca4aeeed9384d 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -1,5 +1,8 @@ """The go2rtc component.""" +from __future__ import annotations + +from dataclasses import dataclass import logging import shutil @@ -38,7 +41,13 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, HA_MANAGED_URL +from .const import ( + CONF_DEBUG_UI, + DEBUG_UI_URL_MESSAGE, + DOMAIN, + HA_MANAGED_RTSP_PORT, + HA_MANAGED_URL, +) from .server import Server _LOGGER = logging.getLogger(__name__) @@ -85,13 +94,22 @@ extra=vol.ALLOW_EXTRA, ) -_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN) +_DATA_GO2RTC: HassKey[Go2RtcData] = HassKey(DOMAIN) _RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError) +@dataclass(frozen=True) +class Go2RtcData: + """Data for go2rtc.""" + + url: str + managed: bool + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up WebRTC.""" url: str | None = None + managed = False if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config: await _remove_go2rtc_entries(hass) return True @@ -126,8 +144,9 @@ async def on_stop(event: Event) -> None: hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) url = HA_MANAGED_URL + managed = True - hass.data[_DATA_GO2RTC] = url + hass.data[_DATA_GO2RTC] = Go2RtcData(url, managed) discovery_flow.async_create_flow( hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} ) @@ -142,28 +161,32 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up go2rtc from a config entry.""" - url = hass.data[_DATA_GO2RTC] + data = hass.data[_DATA_GO2RTC] # Validate the server URL try: - client = Go2RtcRestClient(async_get_clientsession(hass), url) + client = Go2RtcRestClient(async_get_clientsession(hass), data.url) await client.validate_server_version() except Go2RtcClientError as err: if isinstance(err.__cause__, _RETRYABLE_ERRORS): raise ConfigEntryNotReady( - f"Could not connect to go2rtc instance on {url}" + f"Could not connect to go2rtc instance on {data.url}" ) from err - _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) + _LOGGER.warning( + "Could not connect to go2rtc instance on %s (%s)", data.url, err + ) return False except Go2RtcVersionError as err: raise ConfigEntryNotReady( f"The go2rtc server version is not supported, {err}" ) from err except Exception as err: # noqa: BLE001 - _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) + _LOGGER.warning( + "Could not connect to go2rtc instance on %s (%s)", data.url, err + ) return False - provider = WebRTCProvider(hass, url) + provider = WebRTCProvider(hass, data) async_register_webrtc_provider(hass, provider) return True @@ -181,12 +204,12 @@ async def _get_binary(hass: HomeAssistant) -> str | None: class WebRTCProvider(CameraWebRTCProvider): """WebRTC provider.""" - def __init__(self, hass: HomeAssistant, url: str) -> None: + def __init__(self, hass: HomeAssistant, data: Go2RtcData) -> None: """Initialize the WebRTC provider.""" self._hass = hass - self._url = url + self._data = data self._session = async_get_clientsession(hass) - self._rest_client = Go2RtcRestClient(self._session, url) + self._rest_client = Go2RtcRestClient(self._session, data.url) self._sessions: dict[str, Go2RtcWsClient] = {} @property @@ -208,7 +231,7 @@ async def async_handle_async_webrtc_offer( ) -> None: """Handle the WebRTC offer and return the answer via the provided callback.""" self._sessions[session_id] = ws_client = Go2RtcWsClient( - self._session, self._url, source=camera.entity_id + self._session, self._data.url, source=camera.entity_id ) if not (stream_source := await camera.stream_source()): @@ -219,8 +242,30 @@ async def async_handle_async_webrtc_offer( streams = await self._rest_client.streams.list() - if (stream := streams.get(camera.entity_id)) is None or not any( - stream_source == producer.url for producer in stream.producers + if self._data.managed: + # HA manages the go2rtc instance + stream_org_name = camera.entity_id + "_orginal" + stream_redirect_sources = [ + f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_org_name}", + f"ffmpeg:{stream_org_name}#audio=opus", + ] + + if ( + (stream_org := streams.get(stream_org_name)) is None + or not any( + stream_source == producer.url for producer in stream_org.producers + ) + or (stream_redirect := streams.get(camera.entity_id)) is None + or stream_redirect_sources != [p.url for p in stream_redirect.producers] + ): + await self._rest_client.streams.add(stream_org_name, stream_source) + await self._rest_client.streams.add( + camera.entity_id, stream_redirect_sources + ) + + # go2rtc instance is managed outside HA + elif (stream_org := streams.get(camera.entity_id)) is None or not any( + stream_source == producer.url for producer in stream_org.producers ): await self._rest_client.streams.add( camera.entity_id, diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index d33ae3e389759..3c4dc9a950096 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,3 +6,4 @@ DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" +HA_MANAGED_RTSP_PORT = 18554 diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index ed3b44aadf9a8..91f4433546caf 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL +from .const import HA_MANAGED_API_PORT, HA_MANAGED_RTSP_PORT, HA_MANAGED_URL _LOGGER = logging.getLogger(__name__) _TERMINATE_TIMEOUT = 5 @@ -24,15 +24,16 @@ # Default configuration for HA # - Api is listening only on localhost -# - Disable rtsp listener +# - Enable rtsp for localhost only as ffmpeg needs it # - Clear default ice servers -_GO2RTC_CONFIG_FORMAT = r""" +_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant +# Do not edit it manually + api: listen: "{api_ip}:{api_port}" rtsp: - # ffmpeg needs rtsp for opus audio transcoding - listen: "127.0.0.1:18554" + listen: "127.0.0.1:{rtsp_port}" webrtc: listen: ":18555/tcp" @@ -67,7 +68,9 @@ def _create_temp_file(api_ip: str) -> str: with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: file.write( _GO2RTC_CONFIG_FORMAT.format( - api_ip=api_ip, api_port=HA_MANAGED_API_PORT + api_ip=api_ip, + api_port=HA_MANAGED_API_PORT, + rtsp_port=HA_MANAGED_RTSP_PORT, ).encode() ) return file.name diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 18a46fdd4d1c2..ea1971a31d9e8 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator import logging from typing import NamedTuple -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, call, patch from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from go2rtc_client import Stream @@ -296,7 +296,7 @@ async def test() -> None: ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) -async def test_setup_go_binary( +async def test_setup_managed( hass: HomeAssistant, rest_client: AsyncMock, ws_client: Mock, @@ -308,15 +308,131 @@ async def test_setup_go_binary( config: ConfigType, ui_enabled: bool, ) -> None: - """Test the go2rtc config entry with binary.""" + """Test the go2rtc setup with managed go2rtc instance.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry + camera = init_test_integration + + entity_id = camera.entity_id + stream_name_orginal = camera.entity_id + "_orginal" + assert camera.frontend_stream_type == StreamType.HLS + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0].state == ConfigEntryState.LOADED + server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled) + server_start.assert_called_once() - def after_setup() -> None: - server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled) - server_start.assert_called_once() + receive_message_callback = Mock(spec_set=WebRTCSendMessage) - await _test_setup_and_signaling( - hass, rest_client, ws_client, config, after_setup, init_test_integration + async def test() -> None: + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + ws_client.send.assert_called_once_with( + WebRTCOffer( + OFFER_SDP, + camera.async_get_webrtc_client_configuration().configuration.ice_servers, + ) + ) + ws_client.subscribe.assert_called_once() + + # Simulate the answer from the go2rtc server + callback = ws_client.subscribe.call_args[0][0] + callback(WebRTCAnswer(ANSWER_SDP)) + receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP)) + + await test() + + stream_added_calls = [ + call(stream_name_orginal, "rtsp://stream"), + call( + entity_id, + [ + f"rtsp://127.0.0.1:18554/{stream_name_orginal}", + f"ffmpeg:{stream_name_orginal}#audio=opus", + ], + ), + ] + assert rest_client.streams.add.call_args_list == stream_added_calls + + # Stream original missing + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + entity_id: Stream( + [ + Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), + Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + ] + ) + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + assert rest_client.streams.add.call_args_list == stream_added_calls + + # Stream original source different + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + stream_name_orginal: Stream([Producer("rtsp://different")]), + entity_id: Stream( + [ + Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), + Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + ] + ), + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + assert rest_client.streams.add.call_args_list == stream_added_calls + + # Stream source different + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + stream_name_orginal: Stream([Producer("rtsp://stream")]), + entity_id: Stream([Producer("rtsp://different")]), + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + assert rest_client.streams.add.call_args_list == stream_added_calls + + # If the stream is already added, the stream should not be added again. + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + stream_name_orginal: Stream([Producer("rtsp://stream")]), + entity_id: Stream( + [ + Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), + Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + ] + ), + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + rest_client.streams.add.assert_not_called() + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + # Set stream source to None and provider should be skipped + rest_client.streams.list.return_value = {} + receive_message_callback.reset_mock() + camera.set_stream_source(None) + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + receive_message_callback.assert_called_once_with( + WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") ) await hass.async_stop() @@ -332,7 +448,7 @@ def after_setup() -> None: ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) -async def test_setup_go( +async def test_setup_self_hosted( hass: HomeAssistant, rest_client: AsyncMock, ws_client: Mock, @@ -342,16 +458,83 @@ async def test_setup_go( mock_is_docker_env: Mock, has_go2rtc_entry: bool, ) -> None: - """Test the go2rtc config entry without binary.""" + """Test the go2rtc with selfhosted go2rtc instance.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry config = {DOMAIN: {CONF_URL: "http://localhost:1984/"}} + camera = init_test_integration + + entity_id = camera.entity_id + assert camera.frontend_stream_type == StreamType.HLS - def after_setup() -> None: - server.assert_not_called() + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0].state == ConfigEntryState.LOADED + server.assert_not_called() + + receive_message_callback = Mock(spec_set=WebRTCSendMessage) - await _test_setup_and_signaling( - hass, rest_client, ws_client, config, after_setup, init_test_integration + async def test() -> None: + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + ws_client.send.assert_called_once_with( + WebRTCOffer( + OFFER_SDP, + camera.async_get_webrtc_client_configuration().configuration.ice_servers, + ) + ) + ws_client.subscribe.assert_called_once() + + # Simulate the answer from the go2rtc server + callback = ws_client.subscribe.call_args[0][0] + callback(WebRTCAnswer(ANSWER_SDP)) + receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP)) + + await test() + + rest_client.streams.add.assert_called_once_with( + entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + ) + + # Stream exists but the source is different + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + entity_id: Stream([Producer("rtsp://different")]) + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + rest_client.streams.add.assert_called_once_with( + entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + ) + + # If the stream is already added, the stream should not be added again. + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + entity_id: Stream([Producer("rtsp://stream")]) + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + rest_client.streams.add.assert_not_called() + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + # Set stream source to None and provider should be skipped + rest_client.streams.list.return_value = {} + receive_message_callback.reset_mock() + camera.set_stream_source(None) + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + receive_message_callback.assert_called_once_with( + WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") ) mock_get_binary.assert_not_called() diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index d810dbd88eb93..e4fe3993f3cdd 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -105,12 +105,13 @@ async def test_server_run_success( # Verify that the config file was written mock_tempfile.write.assert_called_once_with( - f""" + f"""# This file is managed by Home Assistant +# Do not edit it manually + api: listen: "{api_ip}:11984" rtsp: - # ffmpeg needs rtsp for opus audio transcoding listen: "127.0.0.1:18554" webrtc: From 6b90d8ff1ab78c00e04f08c683bfb1cbe5aabfce Mon Sep 17 00:00:00 2001 From: "Lektri.co" <137074859+Lektrico@users.noreply.github.com> Date: Fri, 8 Nov 2024 16:54:46 +0200 Subject: [PATCH 0267/1070] Add binary sensor platform to the Lektrico integration (#129872) --- homeassistant/components/lektrico/__init__.py | 1 + .../components/lektrico/binary_sensor.py | 139 ++++++ .../components/lektrico/strings.json | 32 ++ .../lektrico/fixtures/get_info.json | 12 +- .../snapshots/test_binary_sensor.ambr | 471 ++++++++++++++++++ .../components/lektrico/test_binary_sensor.py | 32 ++ 6 files changed, 686 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/lektrico/binary_sensor.py create mode 100644 tests/components/lektrico/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/lektrico/test_binary_sensor.py diff --git a/homeassistant/components/lektrico/__init__.py b/homeassistant/components/lektrico/__init__.py index c309bb42ece69..475b613254111 100644 --- a/homeassistant/components/lektrico/__init__.py +++ b/homeassistant/components/lektrico/__init__.py @@ -12,6 +12,7 @@ # List the platforms that charger supports. CHARGERS_PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, diff --git a/homeassistant/components/lektrico/binary_sensor.py b/homeassistant/components/lektrico/binary_sensor.py new file mode 100644 index 0000000000000..d0a3e39690c43 --- /dev/null +++ b/homeassistant/components/lektrico/binary_sensor.py @@ -0,0 +1,139 @@ +"""Support for Lektrico binary sensors entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator +from .entity import LektricoEntity + + +@dataclass(frozen=True, kw_only=True) +class LektricoBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Lektrico binary sensor entity.""" + + value_fn: Callable[[dict[str, Any]], bool] + + +BINARY_SENSORS: tuple[LektricoBinarySensorEntityDescription, ...] = ( + LektricoBinarySensorEntityDescription( + key="state_e_activated", + translation_key="state_e_activated", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["state_e_activated"]), + ), + LektricoBinarySensorEntityDescription( + key="overtemp", + translation_key="overtemp", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["overtemp"]), + ), + LektricoBinarySensorEntityDescription( + key="critical_temp", + translation_key="critical_temp", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["critical_temp"]), + ), + LektricoBinarySensorEntityDescription( + key="overcurrent", + translation_key="overcurrent", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["overcurrent"]), + ), + LektricoBinarySensorEntityDescription( + key="meter_fault", + translation_key="meter_fault", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["meter_fault"]), + ), + LektricoBinarySensorEntityDescription( + key="undervoltage", + translation_key="undervoltage", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["undervoltage_error"]), + ), + LektricoBinarySensorEntityDescription( + key="overvoltage", + translation_key="overvoltage", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["overvoltage_error"]), + ), + LektricoBinarySensorEntityDescription( + key="rcd_error", + translation_key="rcd_error", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["rcd_error"]), + ), + LektricoBinarySensorEntityDescription( + key="cp_diode_failure", + translation_key="cp_diode_failure", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["cp_diode_failure"]), + ), + LektricoBinarySensorEntityDescription( + key="contactor_failure", + translation_key="contactor_failure", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["contactor_failure"]), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LektricoConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Lektrico binary sensor entities based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + LektricoBinarySensor( + description, + coordinator, + f"{entry.data[CONF_TYPE]}_{entry.data[ATTR_SERIAL_NUMBER]}", + ) + for description in BINARY_SENSORS + ) + + +class LektricoBinarySensor(LektricoEntity, BinarySensorEntity): + """Defines a Lektrico binary sensor entity.""" + + entity_description: LektricoBinarySensorEntityDescription + + def __init__( + self, + description: LektricoBinarySensorEntityDescription, + coordinator: LektricoDeviceDataUpdateCoordinator, + device_name: str, + ) -> None: + """Initialize Lektrico binary sensor.""" + super().__init__(coordinator, device_name) + self.entity_description = description + self._coordinator = coordinator + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index e6dc7b9eb4615..e24700c9b091d 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -22,6 +22,38 @@ } }, "entity": { + "binary_sensor": { + "state_e_activated": { + "name": "Ev error" + }, + "overtemp": { + "name": "Thermal throttling" + }, + "critical_temp": { + "name": "Overheating" + }, + "overcurrent": { + "name": "Overcurrent" + }, + "meter_fault": { + "name": "Metering error" + }, + "undervoltage": { + "name": "Undervoltage" + }, + "overvoltage": { + "name": "Overvoltage" + }, + "rcd_error": { + "name": "Rcd error" + }, + "cp_diode_failure": { + "name": "Ev diode short" + }, + "contactor_failure": { + "name": "Relay contacts welded" + } + }, "button": { "charge_start": { "name": "Charge start" diff --git a/tests/components/lektrico/fixtures/get_info.json b/tests/components/lektrico/fixtures/get_info.json index bcd84a9a9df0e..2b099a666e56f 100644 --- a/tests/components/lektrico/fixtures/get_info.json +++ b/tests/components/lektrico/fixtures/get_info.json @@ -14,5 +14,15 @@ "dynamic_current": 32, "user_current": 32, "lb_mode": 0, - "require_auth": true + "require_auth": true, + "state_e_activated": false, + "undervoltage_error": true, + "rcd_error": false, + "meter_fault": false, + "overcurrent": false, + "overtemp": false, + "overvoltage_error": false, + "contactor_failure": false, + "cp_diode_failure": false, + "critical_temp": false } diff --git a/tests/components/lektrico/snapshots/test_binary_sensor.ambr b/tests/components/lektrico/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..6a28e7c60de5c --- /dev/null +++ b/tests/components/lektrico/snapshots/test_binary_sensor.ambr @@ -0,0 +1,471 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.1p7k_500006_ev_diode_short-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_ev_diode_short', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ev diode short', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cp_diode_failure', + 'unique_id': '500006_cp_diode_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_ev_diode_short-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Ev diode short', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_ev_diode_short', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_ev_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_ev_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ev error', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_e_activated', + 'unique_id': '500006_state_e_activated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_ev_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Ev error', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_ev_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_metering_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_metering_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Metering error', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_fault', + 'unique_id': '500006_meter_fault', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_metering_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Metering error', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_metering_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_overcurrent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_overcurrent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overcurrent', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overcurrent', + 'unique_id': '500006_overcurrent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_overcurrent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Overcurrent', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_overcurrent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_overheating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_overheating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheating', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'critical_temp', + 'unique_id': '500006_critical_temp', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_overheating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Overheating', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_overheating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_overvoltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_overvoltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overvoltage', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overvoltage', + 'unique_id': '500006_overvoltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_overvoltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Overvoltage', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_overvoltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_rcd_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_rcd_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rcd error', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rcd_error', + 'unique_id': '500006_rcd_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_rcd_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Rcd error', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_rcd_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_relay_contacts_welded-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_relay_contacts_welded', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay contacts welded', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'contactor_failure', + 'unique_id': '500006_contactor_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_relay_contacts_welded-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Relay contacts welded', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_relay_contacts_welded', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_thermal_throttling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_thermal_throttling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermal throttling', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overtemp', + 'unique_id': '500006_overtemp', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_thermal_throttling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Thermal throttling', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_thermal_throttling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_undervoltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_undervoltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Undervoltage', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'undervoltage', + 'unique_id': '500006_undervoltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_undervoltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Undervoltage', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_undervoltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/lektrico/test_binary_sensor.py b/tests/components/lektrico/test_binary_sensor.py new file mode 100644 index 0000000000000..d49eac6cc23ac --- /dev/null +++ b/tests/components/lektrico/test_binary_sensor.py @@ -0,0 +1,32 @@ +"""Tests for the Lektrico binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import 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_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + + with patch.multiple( + "homeassistant.components.lektrico", + CHARGERS_PLATFORMS=[Platform.BINARY_SENSOR], + LB_DEVICES_PLATFORMS=[Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 353ccf3ea7d67af121db1b77dac3278140ec585b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 8 Nov 2024 15:55:19 +0100 Subject: [PATCH 0268/1070] Only apply OptionsFlowWithConfigEntry deprecation to core (#130054) * Only apply OptionsFlowWithConfigEntry deprecation to core * Fix match string in pytest.raises * Improve coverage --- homeassistant/config_entries.py | 18 ++++++++++------- tests/test_config_entries.py | 34 ++++++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 0d4cc5fd102bd..64eadeb0d7ebd 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -63,7 +63,7 @@ RANDOM_MICROSECOND_MIN, async_call_later, ) -from .helpers.frame import report +from .helpers.frame import ReportBehavior, report, report_usage from .helpers.json import json_bytes, json_bytes_sorted, json_fragment from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType from .loader import async_suggest_report_issue @@ -3168,17 +3168,21 @@ def config_entry(self, value: ConfigEntry) -> None: class OptionsFlowWithConfigEntry(OptionsFlow): - """Base class for options flows with config entry and options.""" + """Base class for options flows with config entry and options. + + This class is being phased out, and should not be referenced in new code. + It is kept only for backward compatibility, and only for custom integrations. + """ def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self._config_entry = config_entry self._options = deepcopy(dict(config_entry.options)) - report( - "inherits from OptionsFlowWithConfigEntry, which is deprecated " - "and will stop working in 2025.12", - error_if_integration=False, - error_if_core=True, + report_usage( + "inherits from OptionsFlowWithConfigEntry", + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.IGNORE, ) @property diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index df464f6af1b3e..eb2a719eab898 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5040,6 +5040,24 @@ async def mock_setup(hass: HomeAssistant, _) -> bool: assert "test" in hass.config.components +@pytest.mark.parametrize( + "integration_frame_path", + ["homeassistant/components/my_integration", "homeassistant.core"], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_options_flow_with_config_entry_core() -> None: + """Test that OptionsFlowWithConfigEntry cannot be used in core.""" + entry = MockConfigEntry( + domain="hue", + data={"first": True}, + options={"sub_dict": {"1": "one"}, "sub_list": ["one"]}, + ) + + with pytest.raises(RuntimeError, match="inherits from OptionsFlowWithConfigEntry"): + _ = config_entries.OptionsFlowWithConfigEntry(entry) + + +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) -> None: @@ -5051,15 +5069,17 @@ async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) ) options_flow = config_entries.OptionsFlowWithConfigEntry(entry) - assert ( - "Detected that integration 'hue' inherits from OptionsFlowWithConfigEntry," - " which is deprecated and will stop working in 2025.12" in caplog.text - ) + assert caplog.text == "" # No deprecation warning for custom components + + # Ensure available at startup + assert options_flow.config_entry is entry + assert options_flow.options == entry.options - options_flow._options["sub_dict"]["2"] = "two" - options_flow._options["sub_list"].append("two") + options_flow.options["sub_dict"]["2"] = "two" + options_flow.options["sub_list"].append("two") - assert options_flow._options == { + # Ensure it does not mutate the entry options + assert options_flow.options == { "sub_dict": {"1": "one", "2": "two"}, "sub_list": ["one", "two"], } From 14285973b875da6ac8ea121359a98f190397b17f Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 8 Nov 2024 16:00:24 +0100 Subject: [PATCH 0269/1070] Bump ha-ffmpeg to 3.2.2 (#130142) --- homeassistant/components/ffmpeg/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ffmpeg/manifest.json b/homeassistant/components/ffmpeg/manifest.json index e5f4f8b93a825..085db6791b33e 100644 --- a/homeassistant/components/ffmpeg/manifest.json +++ b/homeassistant/components/ffmpeg/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/ffmpeg", "integration_type": "system", - "requirements": ["ha-ffmpeg==3.2.1"] + "requirements": ["ha-ffmpeg==3.2.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c73cb5edaa36b..3f7bb758e81c5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 go2rtc-client==0.1.0 -ha-ffmpeg==3.2.1 +ha-ffmpeg==3.2.2 habluetooth==3.6.0 hass-nabucasa==0.84.0 hassil==1.7.4 diff --git a/requirements_all.txt b/requirements_all.txt index b1882cd620f9d..45e2077abf8ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1069,7 +1069,7 @@ guppy3==3.1.4.post1 h2==4.1.0 # homeassistant.components.ffmpeg -ha-ffmpeg==3.2.1 +ha-ffmpeg==3.2.2 # homeassistant.components.iotawatt ha-iotawattpy==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a923dc8422a4..9e34403c87b06 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -907,7 +907,7 @@ guppy3==3.1.4.post1 h2==4.1.0 # homeassistant.components.ffmpeg -ha-ffmpeg==3.2.1 +ha-ffmpeg==3.2.2 # homeassistant.components.iotawatt ha-iotawattpy==0.1.2 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 97fc6c49d1246..745159d61d3da 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.2 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.11.6 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.2 hassil==1.7.4 home-assistant-intents==2024.11.6 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From c4762f3ff4ea611b012e497f4858440b7c69335c Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Fri, 8 Nov 2024 17:15:28 +0200 Subject: [PATCH 0270/1070] Fix issue when timestamp is None (#130133) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/seventeentrack/services.py | 33 +++++++++------- .../snapshots/test_services.ambr | 29 ++++++++++++++ .../seventeentrack/test_services.py | 38 +++++++++++++++++++ 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py index 0833bc0a97bc7..54c23e6d6191a 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -1,8 +1,8 @@ """Services for the seventeentrack integration.""" -from typing import Final +from typing import Any, Final -from pyseventeentrack.package import PACKAGE_STATUS_MAP +from pyseventeentrack.package import PACKAGE_STATUS_MAP, Package import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -81,18 +81,7 @@ async def get_packages(call: ServiceCall) -> ServiceResponse: return { "packages": [ - { - ATTR_DESTINATION_COUNTRY: package.destination_country, - ATTR_ORIGIN_COUNTRY: package.origin_country, - ATTR_PACKAGE_TYPE: package.package_type, - ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, - ATTR_TRACKING_NUMBER: package.tracking_number, - ATTR_LOCATION: package.location, - ATTR_STATUS: package.status, - ATTR_TIMESTAMP: package.timestamp.isoformat(), - ATTR_INFO_TEXT: package.info_text, - ATTR_FRIENDLY_NAME: package.friendly_name, - } + package_to_dict(package) for package in live_packages if slugify(package.status) in package_states or package_states == [] ] @@ -110,6 +99,22 @@ async def archive_package(call: ServiceCall) -> None: await seventeen_coordinator.client.profile.archive_package(tracking_number) + def package_to_dict(package: Package) -> dict[str, Any]: + result = { + ATTR_DESTINATION_COUNTRY: package.destination_country, + ATTR_ORIGIN_COUNTRY: package.origin_country, + ATTR_PACKAGE_TYPE: package.package_type, + ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, + ATTR_TRACKING_NUMBER: package.tracking_number, + ATTR_LOCATION: package.location, + ATTR_STATUS: package.status, + ATTR_INFO_TEXT: package.info_text, + ATTR_FRIENDLY_NAME: package.friendly_name, + } + if timestamp := package.timestamp: + result[ATTR_TIMESTAMP] = timestamp.isoformat() + return result + async def _validate_service(config_entry_id): entry: ConfigEntry | None = hass.config_entries.async_get_entry(config_entry_id) if not entry: diff --git a/tests/components/seventeentrack/snapshots/test_services.ambr b/tests/components/seventeentrack/snapshots/test_services.ambr index 568acea33a5fe..e172a2de5947d 100644 --- a/tests/components/seventeentrack/snapshots/test_services.ambr +++ b/tests/components/seventeentrack/snapshots/test_services.ambr @@ -71,3 +71,32 @@ ]), }) # --- +# name: test_packages_with_none_timestamp + dict({ + 'packages': list([ + dict({ + 'destination_country': 'Belgium', + 'friendly_name': 'friendly name 1', + 'info_text': 'info text 1', + 'location': 'location 1', + 'origin_country': 'Belgium', + 'package_type': 'Registered Parcel', + 'status': 'In Transit', + 'tracking_info_language': 'Unknown', + 'tracking_number': '456', + }), + dict({ + 'destination_country': 'Belgium', + 'friendly_name': 'friendly name 2', + 'info_text': 'info text 1', + 'location': 'location 1', + 'origin_country': 'Belgium', + 'package_type': 'Registered Parcel', + 'status': 'Delivered', + 'timestamp': '2020-08-10T10:32:00+00:00', + 'tracking_info_language': 'Unknown', + 'tracking_number': '789', + }), + ]), + }) +# --- diff --git a/tests/components/seventeentrack/test_services.py b/tests/components/seventeentrack/test_services.py index 54c9349c121fd..bbd5644ad63c7 100644 --- a/tests/components/seventeentrack/test_services.py +++ b/tests/components/seventeentrack/test_services.py @@ -150,6 +150,28 @@ async def test_archive_package( ) +async def test_packages_with_none_timestamp( + hass: HomeAssistant, + mock_seventeentrack: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Ensure service returns all packages when non provided.""" + await _mock_invalid_packages(mock_seventeentrack) + await init_integration(hass, mock_config_entry) + service_response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PACKAGES, + { + CONFIG_ENTRY_ID_KEY: mock_config_entry.entry_id, + }, + blocking=True, + return_response=True, + ) + + assert service_response == snapshot + + async def _mock_packages(mock_seventeentrack): package1 = get_package(status=10) package2 = get_package( @@ -167,3 +189,19 @@ async def _mock_packages(mock_seventeentrack): package2, package3, ] + + +async def _mock_invalid_packages(mock_seventeentrack): + package1 = get_package( + status=10, + timestamp=None, + ) + package2 = get_package( + tracking_number="789", + friendly_name="friendly name 2", + status=40, + ) + mock_seventeentrack.return_value.profile.packages.return_value = [ + package1, + package2, + ] From 2dc81ed866d2437dc2454cb73031a7eb2f00d762 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Fri, 8 Nov 2024 16:15:57 +0100 Subject: [PATCH 0271/1070] Force int value on port in P1Monitor (#130084) --- homeassistant/components/p1_monitor/config_flow.py | 11 +++++++---- tests/components/p1_monitor/test_config_flow.py | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/p1_monitor/config_flow.py b/homeassistant/components/p1_monitor/config_flow.py index 055973e8e37c0..a7ede186d727e 100644 --- a/homeassistant/components/p1_monitor/config_flow.py +++ b/homeassistant/components/p1_monitor/config_flow.py @@ -57,10 +57,13 @@ async def async_step_user( data_schema=vol.Schema( { vol.Required(CONF_HOST): TextSelector(), - vol.Required(CONF_PORT, default=80): NumberSelector( - NumberSelectorConfig( - mode=NumberSelectorMode.BOX, - ) + vol.Required(CONF_PORT, default=80): vol.All( + NumberSelector( + NumberSelectorConfig( + min=1, max=65535, mode=NumberSelectorMode.BOX + ), + ), + vol.Coerce(int), ), } ), diff --git a/tests/components/p1_monitor/test_config_flow.py b/tests/components/p1_monitor/test_config_flow.py index ea1d12055a0ee..cbd89320074a8 100644 --- a/tests/components/p1_monitor/test_config_flow.py +++ b/tests/components/p1_monitor/test_config_flow.py @@ -36,6 +36,7 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: assert result2.get("type") is FlowResultType.CREATE_ENTRY assert result2.get("title") == "P1 Monitor" assert result2.get("data") == {CONF_HOST: "example.com", CONF_PORT: 80} + assert isinstance(result2["data"][CONF_PORT], int) assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_p1monitor.mock_calls) == 1 From a8db25fbd8882463798caed449f9639b68c930f7 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 8 Nov 2024 18:05:05 +0100 Subject: [PATCH 0272/1070] Split test doesn't need to be executed per Python version (#130147) --- .github/workflows/ci.yaml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b4c1ad8a74d38..778ab8b064777 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -819,10 +819,6 @@ jobs: needs: - info - base - strategy: - fail-fast: false - matrix: - python-version: ${{ fromJson(needs.info.outputs.python_versions) }} name: Split tests for full run steps: - name: Install additional OS dependencies @@ -836,11 +832,11 @@ jobs: libgammu-dev - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.3.0 with: - python-version: ${{ matrix.python-version }} + python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv @@ -858,7 +854,7 @@ jobs: - name: Upload pytest_buckets uses: actions/upload-artifact@v4.4.3 with: - name: pytest_buckets-${{ matrix.python-version }} + name: pytest_buckets path: pytest_buckets.txt overwrite: true @@ -923,7 +919,7 @@ jobs: - name: Download pytest_buckets uses: actions/download-artifact@v4.1.8 with: - name: pytest_buckets-${{ matrix.python-version }} + name: pytest_buckets - name: Compile English translations run: | . venv/bin/activate From 4a8a674bd36cf0d5a1a325f9bfd6afe513564105 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Nov 2024 18:36:19 +0100 Subject: [PATCH 0273/1070] Refrase imap fetch service description string (#130152) --- homeassistant/components/imap/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 115d46f3d0e46..7c4a0d9a9736a 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -104,7 +104,7 @@ "services": { "fetch": { "name": "Fetch message", - "description": "Fetch the email message from the server.", + "description": "Fetch an email message from the server.", "fields": { "entry": { "name": "Entry", From f7cc91903ce890c05592c60ee02539e4d9907852 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 8 Nov 2024 09:37:00 -0800 Subject: [PATCH 0274/1070] Fix bugs in nest stream expiration handling (#130150) --- homeassistant/components/nest/camera.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 30f96f819c199..2bee54df3dd79 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -235,7 +235,9 @@ def _stream_expires_at(self) -> datetime.datetime | None: async def _async_refresh_stream(self) -> None: """Refresh stream to extend expiration time.""" now = utcnow() - for webrtc_stream in list(self._webrtc_sessions.values()): + for session_id, webrtc_stream in list(self._webrtc_sessions.items()): + if session_id not in self._webrtc_sessions: + continue if now < (webrtc_stream.expires_at - STREAM_EXPIRATION_BUFFER): _LOGGER.debug( "Stream does not yet expire: %s", webrtc_stream.expires_at @@ -247,7 +249,8 @@ async def _async_refresh_stream(self) -> None: except ApiException as err: _LOGGER.debug("Failed to extend stream: %s", err) else: - self._webrtc_sessions[webrtc_stream.media_session_id] = webrtc_stream + if session_id in self._webrtc_sessions: + self._webrtc_sessions[session_id] = webrtc_stream async def async_camera_image( self, width: int | None = None, height: int | None = None From a7be76ba0a8b4e92818055090cfbb94a1a85eb87 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Fri, 8 Nov 2024 18:40:43 +0100 Subject: [PATCH 0275/1070] Fix volume_up not working in some cases in bluesound integration (#130146) --- .../components/bluesound/media_player.py | 2 +- .../components/bluesound/test_media_player.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 1d46af2cc4b50..97985a74300c5 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -770,7 +770,7 @@ async def async_volume_down(self) -> None: async def async_set_volume_level(self, volume: float) -> None: """Send volume_up command to media player.""" - volume = int(volume * 100) + volume = int(round(volume * 100)) volume = min(100, volume) volume = max(0, volume) diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index 894528265e1b2..0bf615de3da87 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -345,3 +345,31 @@ async def test_attr_bluesound_group( ).attributes.get("bluesound_group") assert attr_bluesound_group == ["player-name1111", "player-name2222"] + + +async def test_volume_up_from_6_to_7( + hass: HomeAssistant, + setup_config_entry: None, + player_mocks: PlayerMocks, +) -> None: + """Test the media player volume up from 6 to 7. + + This fails if if rounding is not done correctly. See https://github.com/home-assistant/core/issues/129956 for more details. + """ + player_mocks.player_data.status_long_polling_mock.set( + dataclasses.replace( + player_mocks.player_data.status_long_polling_mock.get(), volume=6 + ) + ) + + # give the long polling loop a chance to update the state; this could be any async call + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_UP, + {ATTR_ENTITY_ID: "media_player.player_name1111"}, + blocking=True, + ) + + player_mocks.player_data.player.volume.assert_called_once_with(level=7) From e4aaaf10c32e271aeddf5f4f2c68538a3b8ed10b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 8 Nov 2024 17:44:15 +0000 Subject: [PATCH 0276/1070] Fix utility_meter on DST changes (#129862) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/utility_meter/manifest.json | 2 +- .../components/utility_meter/sensor.py | 21 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/utility_meter/test_sensor.py | 20 ++++++++++++++++++ 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/utility_meter/manifest.json b/homeassistant/components/utility_meter/manifest.json index 25e803e6a2d37..31a2d4e958406 100644 --- a/homeassistant/components/utility_meter/manifest.json +++ b/homeassistant/components/utility_meter/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["croniter"], "quality_scale": "internal", - "requirements": ["croniter==2.0.2"] + "requirements": ["cronsim==2.6"] } diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 6b8c07c7ef7b3..9cd4523afa646 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -9,7 +9,7 @@ import logging from typing import Any, Self -from croniter import croniter +from cronsim import CronSim import voluptuous as vol from homeassistant.components.sensor import ( @@ -405,6 +405,16 @@ def __init__( self._tariff = tariff self._tariff_entity = tariff_entity self._next_reset = None + self.scheduler = ( + CronSim( + self._cron_pattern, + dt_util.now( + dt_util.get_default_time_zone() + ), # we need timezone for DST purposes (see issue #102984) + ) + if self._cron_pattern + else None + ) def start(self, attributes: Mapping[str, Any]) -> None: """Initialize unit and state upon source initial update.""" @@ -543,11 +553,10 @@ def _change_status(self, tariff: str) -> None: async def _program_reset(self): """Program the reset of the utility meter.""" - if self._cron_pattern is not None: - tz = dt_util.get_default_time_zone() - self._next_reset = croniter(self._cron_pattern, dt_util.now(tz)).get_next( - datetime - ) # we need timezone for DST purposes (see issue #102984) + if self.scheduler: + self._next_reset = next(self.scheduler) + + _LOGGER.debug("Next reset of %s is %s", self.entity_id, self._next_reset) self.async_on_remove( async_track_point_in_time( self.hass, diff --git a/requirements_all.txt b/requirements_all.txt index 45e2077abf8ad..c61a39f30b8fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -702,7 +702,7 @@ connect-box==0.3.1 construct==2.10.68 # homeassistant.components.utility_meter -croniter==2.0.2 +cronsim==2.6 # homeassistant.components.crownstone crownstone-cloud==1.4.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e34403c87b06..e15d9f437c69b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -598,7 +598,7 @@ colorthief==0.2.1 construct==2.10.68 # homeassistant.components.utility_meter -croniter==2.0.2 +cronsim==2.6 # homeassistant.components.crownstone crownstone-cloud==1.4.11 diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 745bf0ce012fa..a4540a4714dfd 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1804,6 +1804,26 @@ async def test_self_reset_hourly_dst(hass: HomeAssistant) -> None: ) +async def test_self_reset_hourly_dst2(hass: HomeAssistant) -> None: + """Test weekly reset of meter in DST change conditions.""" + + hass.config.time_zone = "Europe/Berlin" + dt_util.set_default_time_zone(dt_util.get_time_zone(hass.config.time_zone)) + await _test_self_reset( + hass, gen_config("daily"), "2024-10-26T23:59:00.000000+02:00" + ) + + state = hass.states.get("sensor.energy_bill") + last_reset = dt_util.parse_datetime("2024-10-27T00:00:00.000000+02:00") + assert ( + dt_util.as_local(dt_util.parse_datetime(state.attributes.get("last_reset"))) + == last_reset + ) + + next_reset = dt_util.parse_datetime("2024-10-28T00:00:00.000000+01:00").isoformat() + assert state.attributes.get("next_reset") == next_reset + + async def test_self_reset_daily(hass: HomeAssistant) -> None: """Test daily reset of meter.""" await _test_self_reset( From da9c73a76769ab103ac0f89c1bc550024d8f7429 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 8 Nov 2024 19:53:52 +0100 Subject: [PATCH 0277/1070] Add reconfigure flow to Nord Pool (#130151) --- .../components/nordpool/config_flow.py | 19 ++++ .../components/nordpool/strings.json | 9 ++ tests/components/nordpool/test_config_flow.py | 96 ++++++++++++++++++- 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nordpool/config_flow.py b/homeassistant/components/nordpool/config_flow.py index d184c04f3cec9..a9a834d8225ed 100644 --- a/homeassistant/components/nordpool/config_flow.py +++ b/homeassistant/components/nordpool/config_flow.py @@ -90,3 +90,22 @@ async def async_step_user( data_schema=DATA_SCHEMA, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reconfiguration step.""" + errors: dict[str, str] = {} + if user_input: + errors = await test_api(self.hass, user_input) + reconfigure_entry = self._get_reconfigure_entry() + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index e55950c7d678d..59ba009eb900f 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_data": "API connected but the response was empty" @@ -10,6 +13,12 @@ "currency": "Currency", "areas": "Areas" } + }, + "reconfigure": { + "data": { + "currency": "[%key:component::nordpool::config::step::user::data::currency%]", + "areas": "[%key:component::nordpool::config::step::user::data::areas%]" + } } } }, diff --git a/tests/components/nordpool/test_config_flow.py b/tests/components/nordpool/test_config_flow.py index dbd85a07a1724..d17db619b02a5 100644 --- a/tests/components/nordpool/test_config_flow.py +++ b/tests/components/nordpool/test_config_flow.py @@ -15,12 +15,15 @@ import pytest from homeassistant import config_entries -from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.components.nordpool.const import CONF_AREAS, DOMAIN +from homeassistant.const import CONF_CURRENCY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ENTRY_CONFIG +from tests.common import MockConfigEntry + @pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") async def test_form(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: @@ -149,3 +152,94 @@ async def test_empty_data(hass: HomeAssistant, get_data: DeliveryPeriodData) -> assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Nord Pool" assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_reconfigure( + hass: HomeAssistant, + load_int: MockConfigEntry, + get_data: DeliveryPeriodData, +) -> None: + """Test reconfiguration.""" + + result = await load_int.start_reconfigure_flow(hass) + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_AREAS: ["SE3"], + CONF_CURRENCY: "EUR", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert load_int.data == { + "areas": [ + "SE3", + ], + "currency": "EUR", + } + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.parametrize( + ("error_message", "p_error"), + [ + (NordPoolConnectionError, "cannot_connect"), + (NordPoolAuthenticationError, "cannot_connect"), + (NordPoolError, "cannot_connect"), + (NordPoolResponseError, "cannot_connect"), + ], +) +async def test_reconfigure_cannot_connect( + hass: HomeAssistant, + load_int: MockConfigEntry, + get_data: DeliveryPeriodData, + error_message: Exception, + p_error: str, +) -> None: + """Test cannot connect error in a reeconfigure flow.""" + + result = await load_int.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=error_message, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_AREAS: ["SE3"], + CONF_CURRENCY: "EUR", + }, + ) + + assert result["errors"] == {"base": p_error} + + with patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_AREAS: ["SE3"], + CONF_CURRENCY: "EUR", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert load_int.data == { + "areas": [ + "SE3", + ], + "currency": "EUR", + } From e4036a2f14834f059dab0dab59462883a20671fe Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Fri, 8 Nov 2024 20:14:33 +0100 Subject: [PATCH 0278/1070] Bump python-linkplay to v0.0.18 (#130159) --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index f2b2e2da00c19..9ddb6abf09398 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.0.17"], + "requirements": ["python-linkplay==0.0.18"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index c61a39f30b8fd..0d900f672f765 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2362,7 +2362,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay -python-linkplay==0.0.17 +python-linkplay==0.0.18 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e15d9f437c69b..41f683dacc4de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1889,7 +1889,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay -python-linkplay==0.0.17 +python-linkplay==0.0.18 # homeassistant.components.matter python-matter-server==6.6.0 From 1ac9217630059ece15f4a744a3423cac132bf5d5 Mon Sep 17 00:00:00 2001 From: Sheldon Ip <4224778+sheldonip@users.noreply.github.com> Date: Fri, 8 Nov 2024 11:15:17 -0800 Subject: [PATCH 0279/1070] Fix translations in ollama (#130164) --- homeassistant/components/ollama/strings.json | 4 +++- tests/components/ollama/test_config_flow.py | 4 ---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index c307f160228d4..248cac34f115b 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -11,9 +11,11 @@ "title": "Downloading model" } }, + "abort": { + "download_failed": "Model downloading failed" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "download_failed": "Model downloading failed", "unknown": "[%key:common::config_flow::error::unknown%]" }, "progress": { diff --git a/tests/components/ollama/test_config_flow.py b/tests/components/ollama/test_config_flow.py index 82c954a1737c5..7755f2208b43d 100644 --- a/tests/components/ollama/test_config_flow.py +++ b/tests/components/ollama/test_config_flow.py @@ -204,10 +204,6 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: assert result2["errors"] == {"base": error} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.ollama.config.abort.download_failed"], -) async def test_download_error(hass: HomeAssistant) -> None: """Test we handle errors while downloading a model.""" result = await hass.config_entries.flow.async_init( From c97cc3487932cb3df128e9a11c32cdecd7c13d4d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Nov 2024 20:16:46 +0100 Subject: [PATCH 0280/1070] Use f-strings in go2rtc code and test and do not use abbreviation (#130158) --- homeassistant/components/go2rtc/__init__.py | 10 +++++----- tests/components/go2rtc/test_init.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index ca4aeeed9384d..e44361f69a4cd 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -244,21 +244,21 @@ async def async_handle_async_webrtc_offer( if self._data.managed: # HA manages the go2rtc instance - stream_org_name = camera.entity_id + "_orginal" + stream_original_name = f"{camera.entity_id}_orginal" stream_redirect_sources = [ - f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_org_name}", - f"ffmpeg:{stream_org_name}#audio=opus", + f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_original_name}", + f"ffmpeg:{stream_original_name}#audio=opus", ] if ( - (stream_org := streams.get(stream_org_name)) is None + (stream_org := streams.get(stream_original_name)) is None or not any( stream_source == producer.url for producer in stream_org.producers ) or (stream_redirect := streams.get(camera.entity_id)) is None or stream_redirect_sources != [p.url for p in stream_redirect.producers] ): - await self._rest_client.streams.add(stream_org_name, stream_source) + await self._rest_client.streams.add(stream_original_name, stream_source) await self._rest_client.streams.add( camera.entity_id, stream_redirect_sources ) diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index ea1971a31d9e8..e085bab31b32d 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -313,7 +313,7 @@ async def test_setup_managed( camera = init_test_integration entity_id = camera.entity_id - stream_name_orginal = camera.entity_id + "_orginal" + stream_name_orginal = f"{camera.entity_id}_orginal" assert camera.frontend_stream_type == StreamType.HLS assert await async_setup_component(hass, DOMAIN, config) From 9037cb8a7d00b40bd269b6a964a2a7d755c424ab Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Nov 2024 20:38:38 +0100 Subject: [PATCH 0281/1070] Fix typo in go2rtc (#130165) Fix typo in original --- homeassistant/components/go2rtc/__init__.py | 2 +- tests/components/go2rtc/test_init.py | 26 ++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index e44361f69a4cd..04b5b9f931732 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -244,7 +244,7 @@ async def async_handle_async_webrtc_offer( if self._data.managed: # HA manages the go2rtc instance - stream_original_name = f"{camera.entity_id}_orginal" + stream_original_name = f"{camera.entity_id}_original" stream_redirect_sources = [ f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_original_name}", f"ffmpeg:{stream_original_name}#audio=opus", diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index e085bab31b32d..ec5867761428a 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -313,7 +313,7 @@ async def test_setup_managed( camera = init_test_integration entity_id = camera.entity_id - stream_name_orginal = f"{camera.entity_id}_orginal" + stream_name_original = f"{camera.entity_id}_original" assert camera.frontend_stream_type == StreamType.HLS assert await async_setup_component(hass, DOMAIN, config) @@ -346,12 +346,12 @@ async def test() -> None: await test() stream_added_calls = [ - call(stream_name_orginal, "rtsp://stream"), + call(stream_name_original, "rtsp://stream"), call( entity_id, [ - f"rtsp://127.0.0.1:18554/{stream_name_orginal}", - f"ffmpeg:{stream_name_orginal}#audio=opus", + f"rtsp://127.0.0.1:18554/{stream_name_original}", + f"ffmpeg:{stream_name_original}#audio=opus", ], ), ] @@ -362,8 +362,8 @@ async def test() -> None: rest_client.streams.list.return_value = { entity_id: Stream( [ - Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), - Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + Producer(f"rtsp://127.0.0.1:18554/{stream_name_original}"), + Producer(f"ffmpeg:{stream_name_original}#audio=opus"), ] ) } @@ -377,11 +377,11 @@ async def test() -> None: # Stream original source different rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - stream_name_orginal: Stream([Producer("rtsp://different")]), + stream_name_original: Stream([Producer("rtsp://different")]), entity_id: Stream( [ - Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), - Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + Producer(f"rtsp://127.0.0.1:18554/{stream_name_original}"), + Producer(f"ffmpeg:{stream_name_original}#audio=opus"), ] ), } @@ -395,7 +395,7 @@ async def test() -> None: # Stream source different rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - stream_name_orginal: Stream([Producer("rtsp://stream")]), + stream_name_original: Stream([Producer("rtsp://stream")]), entity_id: Stream([Producer("rtsp://different")]), } @@ -408,11 +408,11 @@ async def test() -> None: # If the stream is already added, the stream should not be added again. rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - stream_name_orginal: Stream([Producer("rtsp://stream")]), + stream_name_original: Stream([Producer("rtsp://stream")]), entity_id: Stream( [ - Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), - Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + Producer(f"rtsp://127.0.0.1:18554/{stream_name_original}"), + Producer(f"ffmpeg:{stream_name_original}#audio=opus"), ] ), } From 0a4c0fe7ccd72a9ff78ee2ee5d166ca9c4f194d0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 8 Nov 2024 21:09:53 +0100 Subject: [PATCH 0282/1070] Add option to specify additional markers for wheel build requirements (#129949) --- script/gen_requirements_all.py | 35 +++++++++++++++++++---- tests/script/test_gen_requirements_all.py | 26 +++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 4a3408632407a..02dad3aef3f1e 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -58,8 +58,16 @@ # will be included in requirements_all_{action}.txt OVERRIDDEN_REQUIREMENTS_ACTIONS = { - "pytest": {"exclude": set(), "include": {"python-gammu"}}, - "wheels_aarch64": {"exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS}, + "pytest": { + "exclude": set(), + "include": {"python-gammu"}, + "markers": {}, + }, + "wheels_aarch64": { + "exclude": set(), + "include": INCLUDED_REQUIREMENTS_WHEELS, + "markers": {}, + }, # Pandas has issues building on armhf, it is expected they # will drop the platform in the near future (they consider it # "flimsy" on 386). The following packages depend on pandas, @@ -67,10 +75,23 @@ "wheels_armhf": { "exclude": {"env-canada", "noaa-coops", "pyezviz", "pykrakenapi"}, "include": INCLUDED_REQUIREMENTS_WHEELS, + "markers": {}, + }, + "wheels_armv7": { + "exclude": set(), + "include": INCLUDED_REQUIREMENTS_WHEELS, + "markers": {}, + }, + "wheels_amd64": { + "exclude": set(), + "include": INCLUDED_REQUIREMENTS_WHEELS, + "markers": {}, + }, + "wheels_i386": { + "exclude": set(), + "include": INCLUDED_REQUIREMENTS_WHEELS, + "markers": {}, }, - "wheels_armv7": {"exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS}, - "wheels_amd64": {"exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS}, - "wheels_i386": {"exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS}, } IGNORE_PIN = ("colorlog>2.1,<3", "urllib3") @@ -311,6 +332,10 @@ def process_action_requirement(req: str, action: str) -> str: return req if normalized_package_name in EXCLUDED_REQUIREMENTS_ALL: return f"# {req}" + if markers := OVERRIDDEN_REQUIREMENTS_ACTIONS[action]["markers"].get( + normalized_package_name, None + ): + return f"{req};{markers}" return req diff --git a/tests/script/test_gen_requirements_all.py b/tests/script/test_gen_requirements_all.py index 793b3de63c564..519a5c2185556 100644 --- a/tests/script/test_gen_requirements_all.py +++ b/tests/script/test_gen_requirements_all.py @@ -1,5 +1,7 @@ """Tests for the gen_requirements_all script.""" +from unittest.mock import patch + from script import gen_requirements_all @@ -23,3 +25,27 @@ def test_include_overrides_subsets() -> None: for overrides in gen_requirements_all.OVERRIDDEN_REQUIREMENTS_ACTIONS.values(): for req in overrides["include"]: assert req in gen_requirements_all.EXCLUDED_REQUIREMENTS_ALL + + +def test_requirement_override_markers() -> None: + """Test override markers are applied to the correct requirements.""" + data = { + "pytest": { + "exclude": set(), + "include": set(), + "markers": {"env-canada": "python_version<'3.13'"}, + } + } + with patch.dict( + gen_requirements_all.OVERRIDDEN_REQUIREMENTS_ACTIONS, data, clear=True + ): + assert ( + gen_requirements_all.process_action_requirement( + "env-canada==0.7.2", "pytest" + ) + == "env-canada==0.7.2;python_version<'3.13'" + ) + assert ( + gen_requirements_all.process_action_requirement("other==1.0", "pytest") + == "other==1.0" + ) From 48e7fed901717580ac69bd3b7c7929208d8a460f Mon Sep 17 00:00:00 2001 From: murfy76 Date: Fri, 8 Nov 2024 22:03:01 +0100 Subject: [PATCH 0283/1070] Add voc and formaldehyde to Tuya CO2 Detector (#130119) --- homeassistant/components/tuya/sensor.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index fd8efcac95df9..b9677037b7edd 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -203,6 +203,17 @@ class TuyaSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.CH2O_VALUE, + translation_key="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VOC_VALUE, + translation_key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), *BATTERY_SENSORS, ), # Two-way temperature and humidity switch From 742eca5927cac735d63ecf66498d830e2190eda8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 8 Nov 2024 22:09:43 +0100 Subject: [PATCH 0284/1070] Use TemplateStateFromEntityId in Template trigger entity (#130136) --- homeassistant/components/template/trigger_entity.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index df84ce057c3e1..5130f332d5bb5 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.template import TemplateStateFromEntityId from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -41,11 +42,11 @@ def _set_unique_id(self, unique_id: str | None) -> None: def _process_data(self) -> None: """Process new data.""" - this = None - if state := self.hass.states.get(self.entity_id): - this = state.as_dict() run_variables = self.coordinator.data["run_variables"] - variables = {"this": this, **(run_variables or {})} + variables = { + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **(run_variables or {}), + } self._render_templates(variables) From cd11f01ace64a6f6c661367a09ab6f06d5d09ac2 Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Fri, 8 Nov 2024 22:12:16 +0100 Subject: [PATCH 0285/1070] Add support for MW/GW/TW and GWh/TWh (#130089) --- homeassistant/components/number/const.py | 6 +++--- homeassistant/components/sensor/const.py | 6 +++--- homeassistant/const.py | 5 +++++ homeassistant/util/unit_conversion.py | 8 ++++++++ tests/components/sensor/test_recorder.py | 8 ++++---- tests/components/template/test_config_flow.py | 2 +- tests/test_const.py | 9 ++++++++- tests/util/test_unit_conversion.py | 9 +++++++++ 8 files changed, 41 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index ad95c9b5358b7..5eea525fb6a57 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -162,7 +162,7 @@ class NumberDeviceClass(StrEnum): ENERGY = "energy" """Energy. - Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ` """ ENERGY_STORAGE = "energy_storage" @@ -171,7 +171,7 @@ class NumberDeviceClass(StrEnum): Use this device class for sensors measuring stored energy, for example the amount of electric energy currently stored in a battery or the capacity of a battery. - Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ` """ FREQUENCY = "frequency" @@ -279,7 +279,7 @@ class NumberDeviceClass(StrEnum): POWER = "power" """Power. - Unit of measurement: `W`, `kW` + Unit of measurement: `W`, `kW`, `MW`, `GW`, `TW` """ PRECIPITATION = "precipitation" diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index da0b48a23a040..aa3d1906b21f7 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -182,7 +182,7 @@ class SensorDeviceClass(StrEnum): Use this device class for sensors measuring energy consumption, for example electric energy consumption. - Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `Wh`, `kWh`, `MWh`, `cal`, `kcal`, `Mcal`, `Gcal` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ ENERGY_STORAGE = "energy_storage" @@ -191,7 +191,7 @@ class SensorDeviceClass(StrEnum): Use this device class for sensors measuring stored energy, for example the amount of electric energy currently stored in a battery or the capacity of a battery. - Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ` """ FREQUENCY = "frequency" @@ -299,7 +299,7 @@ class SensorDeviceClass(StrEnum): POWER = "power" """Power. - Unit of measurement: `W`, `kW` + Unit of measurement: `W`, `kW`, `MW`, `GW`, `TW` """ PRECIPITATION = "precipitation" diff --git a/homeassistant/const.py b/homeassistant/const.py index 1da3b819f9f82..0bdd625e417e8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -725,6 +725,9 @@ class UnitOfPower(StrEnum): WATT = "W" KILO_WATT = "kW" + MEGA_WATT = "MW" + GIGA_WATT = "GW" + TERA_WATT = "TW" BTU_PER_HOUR = "BTU/h" @@ -770,6 +773,8 @@ class UnitOfEnergy(StrEnum): WATT_HOUR = "Wh" KILO_WATT_HOUR = "kWh" MEGA_WATT_HOUR = "MWh" + GIGA_WATT_HOUR = "GWh" + TERA_WATT_HOUR = "TWh" CALORIE = "cal" KILO_CALORIE = "kcal" MEGA_CALORIE = "Mcal" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 6bc595bd48775..289df28738ad0 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -222,6 +222,8 @@ class EnergyConverter(BaseUnitConverter): UnitOfEnergy.WATT_HOUR: 1e3, UnitOfEnergy.KILO_WATT_HOUR: 1, UnitOfEnergy.MEGA_WATT_HOUR: 1 / 1e3, + UnitOfEnergy.GIGA_WATT_HOUR: 1 / 1e6, + UnitOfEnergy.TERA_WATT_HOUR: 1 / 1e9, UnitOfEnergy.CALORIE: _WH_TO_CAL * 1e3, UnitOfEnergy.KILO_CALORIE: _WH_TO_CAL, UnitOfEnergy.MEGA_CALORIE: _WH_TO_CAL / 1e3, @@ -292,10 +294,16 @@ class PowerConverter(BaseUnitConverter): _UNIT_CONVERSION: dict[str | None, float] = { UnitOfPower.WATT: 1, UnitOfPower.KILO_WATT: 1 / 1000, + UnitOfPower.MEGA_WATT: 1 / 1e6, + UnitOfPower.GIGA_WATT: 1 / 1e9, + UnitOfPower.TERA_WATT: 1 / 1e12, } VALID_UNITS = { UnitOfPower.WATT, UnitOfPower.KILO_WATT, + UnitOfPower.MEGA_WATT, + UnitOfPower.GIGA_WATT, + UnitOfPower.TERA_WATT, } diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 37f080d2de21d..0e8c2a5e188e3 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -4233,8 +4233,8 @@ def set_state(entity_id, state, **kwargs): @pytest.mark.parametrize( ("units", "attributes", "unit", "unit2", "supported_unit"), [ - (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW"), + (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW"), ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, @@ -4445,8 +4445,8 @@ async def test_validate_statistics_unit_ignore_device_class( @pytest.mark.parametrize( ("units", "attributes", "unit", "unit2", "supported_unit"), [ - (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW"), + (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW"), ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 72c453d48dcb6..a3e53aab9e113 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -794,7 +794,7 @@ async def test_config_flow_preview( ), "unit_of_measurement": ( "'None' is not a valid unit for device class 'energy'; " - "expected one of 'cal', 'Gcal', 'GJ', 'J', 'kcal', 'kJ', 'kWh', 'Mcal', 'MJ', 'MWh', 'Wh'" + "expected one of 'cal', 'Gcal', 'GJ', 'GWh', 'J', 'kcal', 'kJ', 'kWh', 'Mcal', 'MJ', 'MWh', 'TWh', 'Wh'" ), }, ), diff --git a/tests/test_const.py b/tests/test_const.py index c572c4a08d785..87a14ecfe9c31 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -66,7 +66,14 @@ def test_all() -> None: "DEVICE_CLASS_", ) + _create_tuples(const.UnitOfApparentPower, "POWER_") - + _create_tuples(const.UnitOfPower, "POWER_") + + _create_tuples( + [ + const.UnitOfPower.WATT, + const.UnitOfPower.KILO_WATT, + const.UnitOfPower.BTU_PER_HOUR, + ], + "POWER_", + ) + _create_tuples( [ const.UnitOfEnergy.KILO_WATT_HOUR, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 3b8fd3bc46659..b07b96e0de7db 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -357,10 +357,16 @@ EnergyConverter: [ (10, UnitOfEnergy.WATT_HOUR, 0.01, UnitOfEnergy.KILO_WATT_HOUR), (10, UnitOfEnergy.WATT_HOUR, 0.00001, UnitOfEnergy.MEGA_WATT_HOUR), + (10, UnitOfEnergy.WATT_HOUR, 0.00000001, UnitOfEnergy.GIGA_WATT_HOUR), + (10, UnitOfEnergy.WATT_HOUR, 0.00000000001, UnitOfEnergy.TERA_WATT_HOUR), (10, UnitOfEnergy.KILO_WATT_HOUR, 10000, UnitOfEnergy.WATT_HOUR), (10, UnitOfEnergy.KILO_WATT_HOUR, 0.01, UnitOfEnergy.MEGA_WATT_HOUR), (10, UnitOfEnergy.MEGA_WATT_HOUR, 10000000, UnitOfEnergy.WATT_HOUR), (10, UnitOfEnergy.MEGA_WATT_HOUR, 10000, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.GIGA_WATT_HOUR, 10e6, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.GIGA_WATT_HOUR, 10e9, UnitOfEnergy.WATT_HOUR), + (10, UnitOfEnergy.TERA_WATT_HOUR, 10e9, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.TERA_WATT_HOUR, 10e12, UnitOfEnergy.WATT_HOUR), (10, UnitOfEnergy.GIGA_JOULE, 2777.78, UnitOfEnergy.KILO_WATT_HOUR), (10, UnitOfEnergy.GIGA_JOULE, 2.77778, UnitOfEnergy.MEGA_WATT_HOUR), (10, UnitOfEnergy.MEGA_JOULE, 2.77778, UnitOfEnergy.KILO_WATT_HOUR), @@ -439,6 +445,9 @@ ], PowerConverter: [ (10, UnitOfPower.KILO_WATT, 10000, UnitOfPower.WATT), + (10, UnitOfPower.MEGA_WATT, 10e6, UnitOfPower.WATT), + (10, UnitOfPower.GIGA_WATT, 10e9, UnitOfPower.WATT), + (10, UnitOfPower.TERA_WATT, 10e12, UnitOfPower.WATT), (10, UnitOfPower.WATT, 0.01, UnitOfPower.KILO_WATT), ], PressureConverter: [ From 182be6e0ea461bd65654223386d4e1373b9ac640 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 8 Nov 2024 23:10:29 +0100 Subject: [PATCH 0286/1070] Fix failing UniFi Protect tests on some systems (#129516) --- .../unifiprotect/test_media_source.py | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 60cd3150884a6..18944460ca594 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -669,7 +669,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.RING, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=[], @@ -683,7 +683,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.MOTION, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=[], @@ -697,7 +697,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["person"], @@ -706,7 +706,7 @@ async def test_browse_media_recent_truncated( metadata={ "detected_thumbnails": [ { - "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "clock_best_wall": datetime(2000, 1, 1, 0, 0, 0), "type": "person", "cropped_id": "event_id", } @@ -720,7 +720,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["vehicle", "person"], @@ -734,7 +734,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["vehicle", "licensePlate"], @@ -748,7 +748,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["vehicle", "licensePlate"], @@ -758,7 +758,7 @@ async def test_browse_media_recent_truncated( "license_plate": {"name": "ABC1234", "confidence_level": 95}, "detected_thumbnails": [ { - "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "clock_best_wall": datetime(2000, 1, 1, 0, 0, 0), "type": "vehicle", "cropped_id": "event_id", } @@ -772,7 +772,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["vehicle", "licensePlate"], @@ -782,7 +782,7 @@ async def test_browse_media_recent_truncated( "license_plate": {"name": "ABC1234", "confidence_level": 95}, "detected_thumbnails": [ { - "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "clock_best_wall": datetime(2000, 1, 1, 0, 0, 0), "type": "vehicle", "cropped_id": "event_id", "attributes": { @@ -802,7 +802,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["vehicle", "licensePlate"], @@ -812,7 +812,7 @@ async def test_browse_media_recent_truncated( "license_plate": {"name": "ABC1234", "confidence_level": 95}, "detected_thumbnails": [ { - "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "clock_best_wall": datetime(2000, 1, 1, 0, 0, 0), "type": "vehicle", "cropped_id": "event_id", "attributes": { @@ -823,7 +823,7 @@ async def test_browse_media_recent_truncated( }, }, { - "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "clock_best_wall": datetime(2000, 1, 1, 0, 0, 0), "type": "person", "cropped_id": "event_id", }, @@ -837,7 +837,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["vehicle"], @@ -846,7 +846,7 @@ async def test_browse_media_recent_truncated( metadata={ "detected_thumbnails": [ { - "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "clock_best_wall": datetime(2000, 1, 1, 0, 0, 0), "type": "vehicle", "cropped_id": "event_id", "attributes": { @@ -870,7 +870,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_AUDIO_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["alrmSpeak"], From 964ad43a27556be2b56a685c5b0aa9f0ab11f541 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Nov 2024 23:07:05 +0000 Subject: [PATCH 0287/1070] Bump orjson to 3.10.11 (#130182) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3f7bb758e81c5..99811a11babb8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ ifaddr==0.2.0 Jinja2==3.1.4 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.10 +orjson==3.10.11 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.4.0 diff --git a/pyproject.toml b/pyproject.toml index df3e2703d5ca3..7855a6671cc9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ "Pillow==10.4.0", "propcache==0.2.0", "pyOpenSSL==24.2.1", - "orjson==3.10.10", + "orjson==3.10.11", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index f9ac034136d77..c7436cab5b8d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ cryptography==43.0.1 Pillow==10.4.0 propcache==0.2.0 pyOpenSSL==24.2.1 -orjson==3.10.10 +orjson==3.10.11 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From 2802b77f21d50d8c002a4dba370c7f8a38296a92 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Fri, 8 Nov 2024 18:12:14 -0500 Subject: [PATCH 0288/1070] Bump nice-go to 0.3.10 (#130173) Bump Nice G.O. to 0.3.10 --- homeassistant/components/nice_go/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json index d3f54e5e66844..817d7ef9bc9d4 100644 --- a/homeassistant/components/nice_go/manifest.json +++ b/homeassistant/components/nice_go/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["nice_go"], - "requirements": ["nice-go==0.3.9"] + "requirements": ["nice-go==0.3.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0d900f672f765..f883405070c24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1457,7 +1457,7 @@ nextdns==3.3.0 nibe==2.11.0 # homeassistant.components.nice_go -nice-go==0.3.9 +nice-go==0.3.10 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 41f683dacc4de..a4d7dd7f85b9d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1217,7 +1217,7 @@ nextdns==3.3.0 nibe==2.11.0 # homeassistant.components.nice_go -nice-go==0.3.9 +nice-go==0.3.10 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From 9f7e6048f832c9ae0f5258a37aaf93d2023f619b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 8 Nov 2024 23:17:43 +0000 Subject: [PATCH 0289/1070] Code quality improvements on utility_meter (#129918) * clean * update snapshot * move name, native_value and native_unit_of_measurement to _attr's * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/utility_meter/sensor.py | 93 ++++--------- .../snapshots/test_diagnostics.ambr | 24 +++- .../utility_meter/test_diagnostics.py | 24 +++- tests/components/utility_meter/test_sensor.py | 126 +++++------------- 4 files changed, 103 insertions(+), 164 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 9cd4523afa646..19ef3c1f3a88d 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -379,14 +379,13 @@ def __init__( self.entity_id = suggested_entity_id self._parent_meter = parent_meter self._sensor_source_id = source_entity - self._state = None self._last_period = Decimal(0) self._last_reset = dt_util.utcnow() self._last_valid_state = None self._collecting = None - self._name = name + self._attr_name = name self._input_device_class = None - self._unit_of_measurement = None + self._attr_native_unit_of_measurement = None self._period = meter_type if meter_type is not None: # For backwards compatibility reasons we convert the period and offset into a cron pattern @@ -419,8 +418,8 @@ def __init__( def start(self, attributes: Mapping[str, Any]) -> None: """Initialize unit and state upon source initial update.""" self._input_device_class = attributes.get(ATTR_DEVICE_CLASS) - self._unit_of_measurement = attributes.get(ATTR_UNIT_OF_MEASUREMENT) - self._state = 0 + self._attr_native_unit_of_measurement = attributes.get(ATTR_UNIT_OF_MEASUREMENT) + self._attr_native_value = 0 self.async_write_ha_state() @staticmethod @@ -495,13 +494,13 @@ def async_reading(self, event: Event[EventStateChangedData]) -> None: ) return - if self._state is None: + if self.native_value is None: # First state update initializes the utility_meter sensors for sensor in self.hass.data[DATA_UTILITY][self._parent_meter][ DATA_TARIFF_SENSORS ]: sensor.start(new_state_attributes) - if self._unit_of_measurement is None: + if self.native_unit_of_measurement is None: _LOGGER.warning( "Source sensor %s has no unit of measurement. Please %s", self._sensor_source_id, @@ -512,10 +511,12 @@ def async_reading(self, event: Event[EventStateChangedData]) -> None: adjustment := self.calculate_adjustment(old_state, new_state) ) is not None and (self._sensor_net_consumption or adjustment >= 0): # If net_consumption is off, the adjustment must be non-negative - self._state += adjustment # type: ignore[operator] # self._state will be set to by the start function if it is None, therefore it always has a valid Decimal value at this line + self._attr_native_value += adjustment # type: ignore[operator] # self._attr_native_value will be set to by the start function if it is None, therefore it always has a valid Decimal value at this line self._input_device_class = new_state_attributes.get(ATTR_DEVICE_CLASS) - self._unit_of_measurement = new_state_attributes.get(ATTR_UNIT_OF_MEASUREMENT) + self._attr_native_unit_of_measurement = new_state_attributes.get( + ATTR_UNIT_OF_MEASUREMENT + ) self._last_valid_state = new_state_val self.async_write_ha_state() @@ -544,7 +545,7 @@ def _change_status(self, tariff: str) -> None: _LOGGER.debug( "%s - %s - source <%s>", - self._name, + self.name, COLLECTING if self._collecting is not None else PAUSED, self._sensor_source_id, ) @@ -584,14 +585,16 @@ async def async_reset_meter(self, entity_id): return _LOGGER.debug("Reset utility meter <%s>", self.entity_id) self._last_reset = dt_util.utcnow() - self._last_period = Decimal(self._state) if self._state else Decimal(0) - self._state = 0 + self._last_period = ( + Decimal(self.native_value) if self.native_value else Decimal(0) + ) + self._attr_native_value = 0 self.async_write_ha_state() async def async_calibrate(self, value): """Calibrate the Utility Meter with a given value.""" - _LOGGER.debug("Calibrate %s = %s type(%s)", self._name, value, type(value)) - self._state = Decimal(str(value)) + _LOGGER.debug("Calibrate %s = %s type(%s)", self.name, value, type(value)) + self._attr_native_value = Decimal(str(value)) self.async_write_ha_state() async def async_added_to_hass(self): @@ -607,10 +610,11 @@ async def async_added_to_hass(self): ) if (last_sensor_data := await self.async_get_last_sensor_data()) is not None: - # new introduced in 2022.04 - self._state = last_sensor_data.native_value + self._attr_native_value = last_sensor_data.native_value self._input_device_class = last_sensor_data.input_device_class - self._unit_of_measurement = last_sensor_data.native_unit_of_measurement + self._attr_native_unit_of_measurement = ( + last_sensor_data.native_unit_of_measurement + ) self._last_period = last_sensor_data.last_period self._last_reset = last_sensor_data.last_reset self._last_valid_state = last_sensor_data.last_valid_state @@ -618,39 +622,6 @@ async def async_added_to_hass(self): # Null lambda to allow cancelling the collection on tariff change self._collecting = lambda: None - elif state := await self.async_get_last_state(): - # legacy to be removed on 2022.10 (we are keeping this to avoid utility_meter counter losses) - try: - self._state = Decimal(state.state) - except InvalidOperation: - _LOGGER.error( - "Could not restore state <%s>. Resetting utility_meter.%s", - state.state, - self.name, - ) - else: - self._unit_of_measurement = state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT - ) - self._last_period = ( - Decimal(state.attributes[ATTR_LAST_PERIOD]) - if state.attributes.get(ATTR_LAST_PERIOD) - and is_number(state.attributes[ATTR_LAST_PERIOD]) - else Decimal(0) - ) - self._last_valid_state = ( - Decimal(state.attributes[ATTR_LAST_VALID_STATE]) - if state.attributes.get(ATTR_LAST_VALID_STATE) - and is_number(state.attributes[ATTR_LAST_VALID_STATE]) - else None - ) - self._last_reset = dt_util.as_utc( - dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET)) - ) - if state.attributes.get(ATTR_STATUS) == COLLECTING: - # Null lambda to allow cancelling the collection on tariff change - self._collecting = lambda: None - @callback def async_source_tracking(event): """Wait for source to be ready, then start meter.""" @@ -675,7 +646,7 @@ def async_source_tracking(event): _LOGGER.debug( "<%s> collecting %s from %s", self.name, - self._unit_of_measurement, + self.native_unit_of_measurement, self._sensor_source_id, ) self._collecting = async_track_state_change_event( @@ -690,22 +661,15 @@ async def async_will_remove_from_hass(self) -> None: self._collecting() self._collecting = None - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - @property def device_class(self): """Return the device class of the sensor.""" if self._input_device_class is not None: return self._input_device_class - if self._unit_of_measurement in DEVICE_CLASS_UNITS[SensorDeviceClass.ENERGY]: + if ( + self.native_unit_of_measurement + in DEVICE_CLASS_UNITS[SensorDeviceClass.ENERGY] + ): return SensorDeviceClass.ENERGY return None @@ -718,11 +682,6 @@ def state_class(self): else SensorStateClass.TOTAL_INCREASING ) - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr index c69164264daa6..6cdf121d7e388 100644 --- a/tests/components/utility_meter/snapshots/test_diagnostics.ambr +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -41,7 +41,17 @@ 'status': 'collecting', 'tariff': 'tariff0', }), - 'last_sensor_data': None, + 'last_sensor_data': dict({ + 'last_period': '0', + 'last_reset': '2024-04-05T00:00:00+00:00', + 'last_valid_state': 3, + 'native_unit_of_measurement': 'kWh', + 'native_value': dict({ + '__type': "", + 'decimal_str': '3', + }), + 'status': 'collecting', + }), 'name': 'Energy Bill tariff0', 'period': 'monthly', 'source': 'sensor.input1', @@ -57,7 +67,17 @@ 'status': 'paused', 'tariff': 'tariff1', }), - 'last_sensor_data': None, + 'last_sensor_data': dict({ + 'last_period': '0', + 'last_reset': '2024-04-05T00:00:00+00:00', + 'last_valid_state': 7, + 'native_unit_of_measurement': 'kWh', + 'native_value': dict({ + '__type': "", + 'decimal_str': '7', + }), + 'status': 'paused', + }), 'name': 'Energy Bill tariff1', 'period': 'monthly', 'source': 'sensor.input1', diff --git a/tests/components/utility_meter/test_diagnostics.py b/tests/components/utility_meter/test_diagnostics.py index 9ecabe813b1ca..8be5f949940cd 100644 --- a/tests/components/utility_meter/test_diagnostics.py +++ b/tests/components/utility_meter/test_diagnostics.py @@ -91,7 +91,17 @@ async def test_diagnostics( ATTR_LAST_RESET: last_reset, }, ), - {}, + { + "native_value": { + "__type": "", + "decimal_str": "3", + }, + "native_unit_of_measurement": "kWh", + "last_reset": last_reset, + "last_period": "0", + "last_valid_state": 3, + "status": "collecting", + }, ), ( State( @@ -101,7 +111,17 @@ async def test_diagnostics( ATTR_LAST_RESET: last_reset, }, ), - {}, + { + "native_value": { + "__type": "", + "decimal_str": "7", + }, + "native_unit_of_measurement": "kWh", + "last_reset": last_reset, + "last_period": "0", + "last_valid_state": 7, + "status": "paused", + }, ), ], ) diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index a4540a4714dfd..0ab78739f7f8a 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -26,7 +26,6 @@ ) from homeassistant.components.utility_meter.sensor import ( ATTR_LAST_RESET, - ATTR_LAST_VALID_STATE, ATTR_STATUS, COLLECTING, PAUSED, @@ -760,64 +759,6 @@ async def test_restore_state( "status": "paused", }, ), - # sensor.energy_bill_tariff2 has missing keys and falls back to - # saved state - ( - State( - "sensor.energy_bill_tariff2", - "2.1", - attributes={ - ATTR_STATUS: PAUSED, - ATTR_LAST_RESET: last_reset_1, - ATTR_LAST_VALID_STATE: None, - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.MEGA_WATT_HOUR, - }, - ), - { - "native_value": { - "__type": "", - "decimal_str": "2.2", - }, - "native_unit_of_measurement": "kWh", - "last_valid_state": "None", - }, - ), - # sensor.energy_bill_tariff3 has invalid data and falls back to - # saved state - ( - State( - "sensor.energy_bill_tariff3", - "3.1", - attributes={ - ATTR_STATUS: COLLECTING, - ATTR_LAST_RESET: last_reset_1, - ATTR_LAST_VALID_STATE: None, - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.MEGA_WATT_HOUR, - }, - ), - { - "native_value": { - "__type": "", - "decimal_str": "3f", # Invalid - }, - "native_unit_of_measurement": "kWh", - "last_valid_state": "None", - }, - ), - # No extra saved data, fall back to saved state - ( - State( - "sensor.energy_bill_tariff4", - "error", - attributes={ - ATTR_STATUS: COLLECTING, - ATTR_LAST_RESET: last_reset_1, - ATTR_LAST_VALID_STATE: None, - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.MEGA_WATT_HOUR, - }, - ), - {}, - ), ], ) @@ -852,25 +793,6 @@ async def test_restore_state( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - state = hass.states.get("sensor.energy_bill_tariff2") - assert state.state == "2.1" - assert state.attributes.get("status") == PAUSED - assert state.attributes.get("last_reset") == last_reset_1 - assert state.attributes.get("last_valid_state") == "None" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.MEGA_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - - state = hass.states.get("sensor.energy_bill_tariff3") - assert state.state == "3.1" - assert state.attributes.get("status") == COLLECTING - assert state.attributes.get("last_reset") == last_reset_1 - assert state.attributes.get("last_valid_state") == "None" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.MEGA_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - - state = hass.states.get("sensor.energy_bill_tariff4") - assert state.state == STATE_UNKNOWN - # utility_meter is loaded, now set sensors according to utility_meter: hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -882,12 +804,7 @@ async def test_restore_state( state = hass.states.get("sensor.energy_bill_tariff0") assert state.attributes.get("status") == COLLECTING - for entity_id in ( - "sensor.energy_bill_tariff1", - "sensor.energy_bill_tariff2", - "sensor.energy_bill_tariff3", - "sensor.energy_bill_tariff4", - ): + for entity_id in ("sensor.energy_bill_tariff1",): state = hass.states.get(entity_id) assert state.attributes.get("status") == PAUSED @@ -939,7 +856,18 @@ async def test_service_reset_no_tariffs( ATTR_LAST_RESET: last_reset, }, ), - {}, + { + "native_value": { + "__type": "", + "decimal_str": "3", + }, + "native_unit_of_measurement": "kWh", + "last_reset": last_reset, + "last_period": "0", + "last_valid_state": None, + "status": "collecting", + "input_device_class": "energy", + }, ), ], ) @@ -1045,21 +973,33 @@ async def test_service_reset_no_tariffs_correct_with_multi( State( "sensor.energy_bill", "3", - attributes={ - ATTR_LAST_RESET: last_reset, - }, ), - {}, + { + "native_value": { + "__type": "", + "decimal_str": "3", + }, + "native_unit_of_measurement": "kWh", + "last_reset": last_reset, + "last_period": "0", + "status": "collecting", + }, ), ( State( "sensor.water_bill", "6", - attributes={ - ATTR_LAST_RESET: last_reset, - }, ), - {}, + { + "native_value": { + "__type": "", + "decimal_str": "6", + }, + "native_unit_of_measurement": "kWh", + "last_reset": last_reset, + "last_period": "0", + "status": "collecting", + }, ), ], ) From b413e481cbc1e288713c4cff01d09c6789a7f7d1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Nov 2024 10:12:52 +0100 Subject: [PATCH 0290/1070] Update numpy to 2.1.3 (#130191) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 90fa6289b8d92..775bde3c8591e 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@Petro31"], "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", - "requirements": ["numpy==2.1.2"] + "requirements": ["numpy==2.1.3"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index d589c117edd7a..11c99a7428f2d 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==2.1.2", "pyiqvia==2022.04.0"] + "requirements": ["numpy==2.1.3", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 304ef5bbf62e4..fdf81d99e656c 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.1.2"] + "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.1.3"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 906ce02f5b1ee..91ce27badd3a7 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -9,7 +9,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==2.1.2", + "numpy==2.1.3", "Pillow==10.4.0" ] } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index b2f47738d4a9a..d7981105fd2da 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -7,5 +7,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==2.1.2"] + "requirements": ["numpy==2.1.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 99811a11babb8..a8a7e009c4a43 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -112,7 +112,7 @@ httpcore==1.0.5 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.1.2 +numpy==2.1.3 pandas~=2.2.3 # Constrain multidict to avoid typing issues diff --git a/requirements_all.txt b/requirements_all.txt index f883405070c24..cf6795cf93ec1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1491,7 +1491,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.1.2 +numpy==2.1.3 # homeassistant.components.nyt_games nyt_games==0.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4d7dd7f85b9d..b4c9dc86c1e6d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1239,7 +1239,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.1.2 +numpy==2.1.3 # homeassistant.components.nyt_games nyt_games==0.4.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 02dad3aef3f1e..edcbc69c15de3 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -148,7 +148,7 @@ hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.1.2 +numpy==2.1.3 pandas~=2.2.3 # Constrain multidict to avoid typing issues From cd0349ee4ddd88daf62624f81560439cf947d4cf Mon Sep 17 00:00:00 2001 From: Tristan Bastian Date: Sat, 9 Nov 2024 10:41:08 +0100 Subject: [PATCH 0291/1070] Bump tplink-omada-client to 1.4.3 (#130184) --- homeassistant/components/tplink_omada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 6bde656dc302e..af20b54675b2d 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.4.2"] + "requirements": ["tplink-omada-client==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index cf6795cf93ec1..e7b39f5d6c222 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2858,7 +2858,7 @@ total-connect-client==2024.5 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.4.2 +tplink-omada-client==1.4.3 # homeassistant.components.transmission transmission-rpc==7.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4c9dc86c1e6d..44ca05a1c474f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2271,7 +2271,7 @@ toonapi==0.3.0 total-connect-client==2024.5 # homeassistant.components.tplink_omada -tplink-omada-client==1.4.2 +tplink-omada-client==1.4.3 # homeassistant.components.transmission transmission-rpc==7.0.3 From 8384100e1b66ca871d61b57b932764d35612b4d4 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sat, 9 Nov 2024 10:46:38 +0100 Subject: [PATCH 0292/1070] Rename tedee library (#130203) --- homeassistant/components/tedee/__init__.py | 2 +- homeassistant/components/tedee/binary_sensor.py | 4 ++-- homeassistant/components/tedee/config_flow.py | 2 +- homeassistant/components/tedee/coordinator.py | 4 ++-- homeassistant/components/tedee/entity.py | 2 +- homeassistant/components/tedee/lock.py | 2 +- homeassistant/components/tedee/manifest.json | 4 ++-- homeassistant/components/tedee/sensor.py | 2 +- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- tests/components/tedee/conftest.py | 4 ++-- tests/components/tedee/test_binary_sensor.py | 2 +- tests/components/tedee/test_config_flow.py | 4 ++-- tests/components/tedee/test_init.py | 2 +- tests/components/tedee/test_lock.py | 6 +++--- tests/components/tedee/test_sensor.py | 2 +- 16 files changed, 27 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py index cd593f68e3a66..528a5052678c4 100644 --- a/homeassistant/components/tedee/__init__.py +++ b/homeassistant/components/tedee/__init__.py @@ -7,7 +7,7 @@ from aiohttp.hdrs import METH_POST from aiohttp.web import Request, Response -from pytedee_async.exception import TedeeDataUpdateException, TedeeWebhookException +from aiotedee.exception import TedeeDataUpdateException, TedeeWebhookException from homeassistant.components.http import HomeAssistantView from homeassistant.components.webhook import ( diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index 5eab7bfa2546b..b586db7c2a77c 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -3,8 +3,8 @@ from collections.abc import Callable from dataclasses import dataclass -from pytedee_async import TedeeLock -from pytedee_async.lock import TedeeLockState +from aiotedee import TedeeLock +from aiotedee.lock import TedeeLockState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py index 65d4ec12e8098..422d818d1b522 100644 --- a/homeassistant/components/tedee/config_flow.py +++ b/homeassistant/components/tedee/config_flow.py @@ -4,7 +4,7 @@ import logging from typing import Any -from pytedee_async import ( +from aiotedee import ( TedeeAuthException, TedeeClient, TedeeClientException, diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index de3090a3f7864..445585a1a2c6e 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -8,7 +8,7 @@ import time from typing import Any -from pytedee_async import ( +from aiotedee import ( TedeeClient, TedeeClientException, TedeeDataUpdateException, @@ -16,7 +16,7 @@ TedeeLock, TedeeWebhookException, ) -from pytedee_async.bridge import TedeeBridge +from aiotedee.bridge import TedeeBridge from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py index c72e293a292d0..96cc6f2b3f5d6 100644 --- a/homeassistant/components/tedee/entity.py +++ b/homeassistant/components/tedee/entity.py @@ -1,6 +1,6 @@ """Bases for Tedee entities.""" -from pytedee_async.lock import TedeeLock +from aiotedee.lock import TedeeLock from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index 34d313f3e4886..6e89a48f2a066 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -2,7 +2,7 @@ from typing import Any -from pytedee_async import TedeeClientException, TedeeLock, TedeeLockState +from aiotedee import TedeeClientException, TedeeLock, TedeeLockState from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 4f071267a253a..bca51f08f935a 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -6,7 +6,7 @@ "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", - "loggers": ["pytedee_async"], + "loggers": ["aiotedee"], "quality_scale": "platinum", - "requirements": ["pytedee-async==0.2.20"] + "requirements": ["aiotedee==0.2.20"] } diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py index 33894a5eb52a1..90f76317fffd3 100644 --- a/homeassistant/components/tedee/sensor.py +++ b/homeassistant/components/tedee/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass -from pytedee_async import TedeeLock +from aiotedee import TedeeLock from homeassistant.components.sensor import ( SensorDeviceClass, diff --git a/requirements_all.txt b/requirements_all.txt index e7b39f5d6c222..972c94f3c73b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -392,6 +392,9 @@ aiosyncthing==0.5.1 # homeassistant.components.tankerkoenig aiotankerkoenig==0.4.2 +# homeassistant.components.tedee +aiotedee==0.2.20 + # homeassistant.components.tractive aiotractive==0.6.0 @@ -2295,9 +2298,6 @@ pyswitchbee==1.8.3 # homeassistant.components.tautulli pytautulli==23.1.1 -# homeassistant.components.tedee -pytedee-async==0.2.20 - # homeassistant.components.thinkingcleaner pythinkingcleaner==0.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 44ca05a1c474f..c38ac10c53aca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -374,6 +374,9 @@ aiosyncthing==0.5.1 # homeassistant.components.tankerkoenig aiotankerkoenig==0.4.2 +# homeassistant.components.tedee +aiotedee==0.2.20 + # homeassistant.components.tractive aiotractive==0.6.0 @@ -1852,9 +1855,6 @@ pyswitchbee==1.8.3 # homeassistant.components.tautulli pytautulli==23.1.1 -# homeassistant.components.tedee -pytedee-async==0.2.20 - # homeassistant.components.motionmount python-MotionMount==2.2.0 diff --git a/tests/components/tedee/conftest.py b/tests/components/tedee/conftest.py index 68444de640c08..8e028cb53003a 100644 --- a/tests/components/tedee/conftest.py +++ b/tests/components/tedee/conftest.py @@ -6,8 +6,8 @@ import json from unittest.mock import AsyncMock, MagicMock, patch -from pytedee_async.bridge import TedeeBridge -from pytedee_async.lock import TedeeLock +from aiotedee.bridge import TedeeBridge +from aiotedee.lock import TedeeLock import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN diff --git a/tests/components/tedee/test_binary_sensor.py b/tests/components/tedee/test_binary_sensor.py index 788d31c84d21a..dfe70e7a2ea55 100644 --- a/tests/components/tedee/test_binary_sensor.py +++ b/tests/components/tedee/test_binary_sensor.py @@ -3,8 +3,8 @@ from datetime import timedelta from unittest.mock import MagicMock +from aiotedee import TedeeLock from freezegun.api import FrozenDateTimeFactory -from pytedee_async import TedeeLock import pytest from syrupy import SnapshotAssertion diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py index 2e86286c8da21..825e01aca70a5 100644 --- a/tests/components/tedee/test_config_flow.py +++ b/tests/components/tedee/test_config_flow.py @@ -2,12 +2,12 @@ from unittest.mock import MagicMock, patch -from pytedee_async import ( +from aiotedee import ( TedeeClientException, TedeeDataUpdateException, TedeeLocalAuthException, ) -from pytedee_async.bridge import TedeeBridge +from aiotedee.bridge import TedeeBridge import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py index d4ac1c9d2901d..63701bb17889f 100644 --- a/tests/components/tedee/test_init.py +++ b/tests/components/tedee/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch from urllib.parse import urlparse -from pytedee_async.exception import ( +from aiotedee.exception import ( TedeeAuthException, TedeeClientException, TedeeWebhookException, diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py index 3f6b97e2c7040..45eae6e22d99c 100644 --- a/tests/components/tedee/test_lock.py +++ b/tests/components/tedee/test_lock.py @@ -4,13 +4,13 @@ from unittest.mock import MagicMock from urllib.parse import urlparse -from freezegun.api import FrozenDateTimeFactory -from pytedee_async import TedeeLock, TedeeLockState -from pytedee_async.exception import ( +from aiotedee import TedeeLock, TedeeLockState +from aiotedee.exception import ( TedeeClientException, TedeeDataUpdateException, TedeeLocalAuthException, ) +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion diff --git a/tests/components/tedee/test_sensor.py b/tests/components/tedee/test_sensor.py index 72fbd9cbe8d57..ddbcd5086afd1 100644 --- a/tests/components/tedee/test_sensor.py +++ b/tests/components/tedee/test_sensor.py @@ -3,8 +3,8 @@ from datetime import timedelta from unittest.mock import MagicMock +from aiotedee import TedeeLock from freezegun.api import FrozenDateTimeFactory -from pytedee_async import TedeeLock import pytest from syrupy import SnapshotAssertion From d11012b2b7395a259004672f9ada28ae96feb944 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 9 Nov 2024 10:50:11 +0100 Subject: [PATCH 0293/1070] Move check thresholds valid to platform schema in threshold (#129540) --- .../components/threshold/binary_sensor.py | 35 ++++++++++++------- .../threshold/test_binary_sensor.py | 2 +- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index da7d92f7051e8..3d52d2225be17 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -61,15 +61,29 @@ DEFAULT_NAME: Final = "Threshold" -PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce(float), - vol.Optional(CONF_LOWER): vol.Coerce(float), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UPPER): vol.Coerce(float), - } + +def no_missing_threshold(value: dict) -> dict: + """Validate data point list is greater than polynomial degrees.""" + if value.get(CONF_LOWER) is None and value.get(CONF_UPPER) is None: + raise vol.Invalid("Lower or Upper thresholds are not provided") + + return value + + +PLATFORM_SCHEMA = vol.All( + BINARY_SENSOR_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce( + float + ), + vol.Optional(CONF_LOWER): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UPPER): vol.Coerce(float), + } + ), + no_missing_threshold, ) @@ -126,9 +140,6 @@ async def async_setup_platform( hysteresis: float = config[CONF_HYSTERESIS] device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) - if lower is None and upper is None: - raise ValueError("Lower or Upper thresholds not provided") - async_add_entities( [ ThresholdSensor( diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index e0973c7a5803e..259009c6319cf 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -538,7 +538,7 @@ async def test_sensor_no_lower_upper( await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - assert "Lower or Upper thresholds not provided" in caplog.text + assert "Lower or Upper thresholds are not provided" in caplog.text async def test_device_id( From 701f35488c2bf2032da2b9e71968955b364d3325 Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Sat, 9 Nov 2024 10:57:22 +0100 Subject: [PATCH 0294/1070] Add water price sensor to suez water (#130141) * Suez water: add water price sensor * sensor description * clean up --- .../components/suez_water/coordinator.py | 46 ++++++++- homeassistant/components/suez_water/sensor.py | 94 ++++++++++++------- .../components/suez_water/strings.json | 3 + tests/components/suez_water/conftest.py | 8 +- .../suez_water/snapshots/test_sensor.ambr | 51 +++++++++- tests/components/suez_water/test_sensor.py | 21 +++-- 6 files changed, 175 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py index 55f3ba348d4e7..224929c606e59 100644 --- a/homeassistant/components/suez_water/coordinator.py +++ b/homeassistant/components/suez_water/coordinator.py @@ -1,6 +1,11 @@ """Suez water update coordinator.""" -from pysuez import AggregatedData, PySuezError, SuezClient +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import date +from typing import Any + +from pysuez import PySuezError, SuezClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -11,7 +16,28 @@ from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN -class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedData]): +@dataclass +class SuezWaterAggregatedAttributes: + """Class containing aggregated sensor extra attributes.""" + + this_month_consumption: dict[date, float] + previous_month_consumption: dict[date, float] + last_year_overall: dict[str, float] + this_year_overall: dict[str, float] + history: dict[date, float] + highest_monthly_consumption: float + + +@dataclass +class SuezWaterData: + """Class used to hold all fetch data from suez api.""" + + aggregated_value: float + aggregated_attr: Mapping[str, Any] + price: float + + +class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]): """Suez water coordinator.""" _suez_client: SuezClient @@ -37,10 +63,22 @@ async def _async_setup(self) -> None: if not await self._suez_client.check_credentials(): raise ConfigEntryError("Invalid credentials for suez water") - async def _async_update_data(self) -> AggregatedData: + async def _async_update_data(self) -> SuezWaterData: """Fetch data from API endpoint.""" try: - data = await self._suez_client.fetch_aggregated_data() + aggregated = await self._suez_client.fetch_aggregated_data() + data = SuezWaterData( + aggregated_value=aggregated.value, + aggregated_attr={ + "this_month_consumption": aggregated.current_month, + "previous_month_consumption": aggregated.previous_month, + "highest_monthly_consumption": aggregated.highest_monthly_consumption, + "last_year_overall": aggregated.previous_year, + "this_year_overall": aggregated.current_year, + "history": aggregated.history, + }, + price=(await self._suez_client.get_price()).price, + ) except PySuezError as err: _LOGGER.exception(err) raise UpdateFailed( diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 22a61c835e190..2ba699a9af14d 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -2,19 +2,53 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping +from dataclasses import dataclass from typing import Any -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from pysuez.const import ATTRIBUTION + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfVolume +from homeassistant.const import CURRENCY_EURO, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_COUNTER_ID, DOMAIN -from .coordinator import SuezWaterCoordinator +from .coordinator import SuezWaterCoordinator, SuezWaterData + + +@dataclass(frozen=True, kw_only=True) +class SuezWaterSensorEntityDescription(SensorEntityDescription): + """Describes Suez water sensor entity.""" + + value_fn: Callable[[SuezWaterData], float | str | None] + attr_fn: Callable[[SuezWaterData], Mapping[str, Any] | None] = lambda _: None + + +SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = ( + SuezWaterSensorEntityDescription( + key="water_usage_yesterday", + translation_key="water_usage_yesterday", + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.WATER, + value_fn=lambda suez_data: suez_data.aggregated_value, + attr_fn=lambda suez_data: suez_data.aggregated_attr, + ), + SuezWaterSensorEntityDescription( + key="water_price", + translation_key="water_price", + native_unit_of_measurement=CURRENCY_EURO, + device_class=SensorDeviceClass.MONETARY, + value_fn=lambda suez_data: suez_data.price, + ), +) async def async_setup_entry( @@ -24,46 +58,42 @@ async def async_setup_entry( ) -> None: """Set up Suez Water sensor from a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([SuezAggregatedSensor(coordinator, entry.data[CONF_COUNTER_ID])]) + counter_id = entry.data[CONF_COUNTER_ID] + + async_add_entities( + SuezWaterSensor(coordinator, counter_id, description) for description in SENSORS + ) -class SuezAggregatedSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity): - """Representation of a Sensor.""" +class SuezWaterSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity): + """Representation of a Suez water sensor.""" _attr_has_entity_name = True - _attr_translation_key = "water_usage_yesterday" - _attr_native_unit_of_measurement = UnitOfVolume.LITERS - _attr_device_class = SensorDeviceClass.WATER + _attr_attribution = ATTRIBUTION + entity_description: SuezWaterSensorEntityDescription - def __init__(self, coordinator: SuezWaterCoordinator, counter_id: int) -> None: - """Initialize the data object.""" + def __init__( + self, + coordinator: SuezWaterCoordinator, + counter_id: int, + entity_description: SuezWaterSensorEntityDescription, + ) -> None: + """Initialize the suez water sensor entity.""" super().__init__(coordinator) - self._attr_extra_state_attributes = {} - self._attr_unique_id = f"{counter_id}_water_usage_yesterday" + self._attr_unique_id = f"{counter_id}_{entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(counter_id))}, entry_type=DeviceEntryType.SERVICE, manufacturer="Suez", ) + self.entity_description = entity_description @property - def native_value(self) -> float: - """Return the current daily usage.""" - return self.coordinator.data.value - - @property - def attribution(self) -> str: - """Return data attribution message.""" - return self.coordinator.data.attribution + def native_value(self) -> float | str | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Return aggregated data.""" - return { - "this_month_consumption": self.coordinator.data.current_month, - "previous_month_consumption": self.coordinator.data.previous_month, - "highest_monthly_consumption": self.coordinator.data.highest_monthly_consumption, - "last_year_overall": self.coordinator.data.previous_year, - "this_year_overall": self.coordinator.data.current_year, - "history": self.coordinator.data.history, - } + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return extra state of the sensor.""" + return self.entity_description.attr_fn(self.coordinator.data) diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json index a1af12abd5599..6be2affab9779 100644 --- a/homeassistant/components/suez_water/strings.json +++ b/homeassistant/components/suez_water/strings.json @@ -23,6 +23,9 @@ "sensor": { "water_usage_yesterday": { "name": "Water usage yesterday" + }, + "water_price": { + "name": "Water price" } } } diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index 0cbf16095bf72..f634a053c65a6 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -3,10 +3,11 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from pysuez import AggregatedData, PriceResult +from pysuez.const import ATTRIBUTION import pytest from homeassistant.components.suez_water.const import DOMAIN -from homeassistant.components.suez_water.coordinator import AggregatedData from tests.common import MockConfigEntry @@ -38,7 +39,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="suez_client") -def mock_suez_data() -> Generator[AsyncMock]: +def mock_suez_client() -> Generator[AsyncMock]: """Create mock for suez_water external api.""" with ( patch( @@ -64,7 +65,7 @@ def mock_suez_data() -> Generator[AsyncMock]: }, current_year=1500, previous_year=1000, - attribution="suez water mock test", + attribution=ATTRIBUTION, highest_monthly_consumption=2558, history={ "2024-01-01": 130, @@ -75,4 +76,5 @@ def mock_suez_data() -> Generator[AsyncMock]: ) suez_client.fetch_aggregated_data.return_value = result + suez_client.get_price.return_value = PriceResult("4.74") yield suez_client diff --git a/tests/components/suez_water/snapshots/test_sensor.ambr b/tests/components/suez_water/snapshots/test_sensor.ambr index acc3042f93b71..da0ed3df7dd4e 100644 --- a/tests/components/suez_water/snapshots/test_sensor.ambr +++ b/tests/components/suez_water/snapshots/test_sensor.ambr @@ -1,4 +1,53 @@ # serializer version: 1 +# name: test_sensors_valid_state[sensor.suez_mock_device_water_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.suez_mock_device_water_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water price', + 'platform': 'suez_water', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_price', + 'unique_id': 'test-counter_water_price', + 'unit_of_measurement': '€', + }) +# --- +# name: test_sensors_valid_state[sensor.suez_mock_device_water_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by toutsurmoneau.fr', + 'device_class': 'monetary', + 'friendly_name': 'Suez mock device Water price', + 'unit_of_measurement': '€', + }), + 'context': , + 'entity_id': 'sensor.suez_mock_device_water_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.74', + }) +# --- # name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -35,7 +84,7 @@ # name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'suez water mock test', + 'attribution': 'Data provided by toutsurmoneau.fr', 'device_class': 'water', 'friendly_name': 'Suez mock device Water usage yesterday', 'highest_monthly_consumption': 2558, diff --git a/tests/components/suez_water/test_sensor.py b/tests/components/suez_water/test_sensor.py index 1cd40dff75bb5..cb578432f6232 100644 --- a/tests/components/suez_water/test_sensor.py +++ b/tests/components/suez_water/test_sensor.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion from homeassistant.components.suez_water.const import DATA_REFRESH_INTERVAL @@ -32,11 +33,13 @@ async def test_sensors_valid_state( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.parametrize("method", [("fetch_aggregated_data"), ("get_price")]) async def test_sensors_failed_update( hass: HomeAssistant, suez_client: AsyncMock, mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, + method: str, ) -> None: """Test that suez_water sensor reflect failure when api fails.""" @@ -45,18 +48,20 @@ async def test_sensors_failed_update( assert mock_config_entry.state is ConfigEntryState.LOADED entity_ids = await hass.async_add_executor_job(hass.states.entity_ids) - assert len(entity_ids) == 1 + assert len(entity_ids) == 2 - state = hass.states.get(entity_ids[0]) - assert entity_ids[0] - assert state.state != STATE_UNAVAILABLE + for entity in entity_ids: + state = hass.states.get(entity) + assert entity + assert state.state != STATE_UNAVAILABLE - suez_client.fetch_aggregated_data.side_effect = PySuezError("Should fail to update") + getattr(suez_client, method).side_effect = PySuezError("Should fail to update") freezer.tick(DATA_REFRESH_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(True) - state = hass.states.get(entity_ids[0]) - assert state - assert state.state == STATE_UNAVAILABLE + for entity in entity_ids: + state = hass.states.get(entity) + assert entity + assert state.state == STATE_UNAVAILABLE From 08f5081197c9f7d86bade818858d3599d4ec287e Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sat, 9 Nov 2024 11:03:48 +0100 Subject: [PATCH 0295/1070] Rename lamarzocco library (#130204) --- homeassistant/components/lamarzocco/__init__.py | 10 +++++----- homeassistant/components/lamarzocco/binary_sensor.py | 2 +- homeassistant/components/lamarzocco/button.py | 4 ++-- homeassistant/components/lamarzocco/calendar.py | 2 +- homeassistant/components/lamarzocco/config_flow.py | 8 ++++---- homeassistant/components/lamarzocco/coordinator.py | 10 +++++----- homeassistant/components/lamarzocco/diagnostics.py | 2 +- homeassistant/components/lamarzocco/entity.py | 4 ++-- homeassistant/components/lamarzocco/manifest.json | 4 ++-- homeassistant/components/lamarzocco/number.py | 8 ++++---- homeassistant/components/lamarzocco/select.py | 8 ++++---- homeassistant/components/lamarzocco/sensor.py | 4 ++-- homeassistant/components/lamarzocco/switch.py | 8 ++++---- homeassistant/components/lamarzocco/update.py | 4 ++-- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- tests/components/lamarzocco/__init__.py | 2 +- tests/components/lamarzocco/conftest.py | 6 +++--- tests/components/lamarzocco/test_binary_sensor.py | 2 +- tests/components/lamarzocco/test_button.py | 2 +- tests/components/lamarzocco/test_config_flow.py | 6 +++--- tests/components/lamarzocco/test_init.py | 4 ++-- tests/components/lamarzocco/test_number.py | 4 ++-- tests/components/lamarzocco/test_select.py | 4 ++-- tests/components/lamarzocco/test_sensor.py | 2 +- tests/components/lamarzocco/test_switch.py | 2 +- tests/components/lamarzocco/test_update.py | 4 ++-- 27 files changed, 64 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 82a91c0003fb7..da513bc8cffa6 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -2,12 +2,12 @@ import logging -from lmcloud.client_bluetooth import LaMarzoccoBluetoothClient -from lmcloud.client_cloud import LaMarzoccoCloudClient -from lmcloud.client_local import LaMarzoccoLocalClient -from lmcloud.const import BT_MODEL_PREFIXES, FirmwareType -from lmcloud.exceptions import AuthFail, RequestNotSuccessful from packaging import version +from pylamarzocco.client_bluetooth import LaMarzoccoBluetoothClient +from pylamarzocco.client_cloud import LaMarzoccoCloudClient +from pylamarzocco.client_local import LaMarzoccoLocalClient +from pylamarzocco.const import BT_MODEL_PREFIXES, FirmwareType +from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from homeassistant.components.bluetooth import async_discovered_service_info from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index c48453214bdfe..444e4d0723b57 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass -from lmcloud.models import LaMarzoccoMachineConfig +from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py index 60374a85e1e73..b9bc7fc8844b9 100644 --- a/homeassistant/components/lamarzocco/button.py +++ b/homeassistant/components/lamarzocco/button.py @@ -4,8 +4,8 @@ from dataclasses import dataclass from typing import Any -from lmcloud.exceptions import RequestNotSuccessful -from lmcloud.lm_machine import LaMarzoccoMachine +from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.lm_machine import LaMarzoccoMachine from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/lamarzocco/calendar.py b/homeassistant/components/lamarzocco/calendar.py index 3d8b2474c940f..0ec9b55a9a1de 100644 --- a/homeassistant/components/lamarzocco/calendar.py +++ b/homeassistant/components/lamarzocco/calendar.py @@ -3,7 +3,7 @@ from collections.abc import Iterator from datetime import datetime, timedelta -from lmcloud.models import LaMarzoccoWakeUpSleepEntry +from pylamarzocco.models import LaMarzoccoWakeUpSleepEntry from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 4fadd3a9a32a1..04e705edbdcac 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -6,10 +6,10 @@ import logging from typing import Any -from lmcloud.client_cloud import LaMarzoccoCloudClient -from lmcloud.client_local import LaMarzoccoLocalClient -from lmcloud.exceptions import AuthFail, RequestNotSuccessful -from lmcloud.models import LaMarzoccoDeviceInfo +from pylamarzocco.client_cloud import LaMarzoccoCloudClient +from pylamarzocco.client_local import LaMarzoccoLocalClient +from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.models import LaMarzoccoDeviceInfo import voluptuous as vol from homeassistant.components.bluetooth import ( diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index e2ff8791a0554..05fee98c5997e 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -8,11 +8,11 @@ from time import time from typing import Any -from lmcloud.client_bluetooth import LaMarzoccoBluetoothClient -from lmcloud.client_cloud import LaMarzoccoCloudClient -from lmcloud.client_local import LaMarzoccoLocalClient -from lmcloud.exceptions import AuthFail, RequestNotSuccessful -from lmcloud.lm_machine import LaMarzoccoMachine +from pylamarzocco.client_bluetooth import LaMarzoccoBluetoothClient +from pylamarzocco.client_cloud import LaMarzoccoCloudClient +from pylamarzocco.client_local import LaMarzoccoLocalClient +from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.lm_machine import LaMarzoccoMachine from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, CONF_NAME, EVENT_HOMEASSISTANT_STOP diff --git a/homeassistant/components/lamarzocco/diagnostics.py b/homeassistant/components/lamarzocco/diagnostics.py index edce6a349aaa6..43ae51ee19286 100644 --- a/homeassistant/components/lamarzocco/diagnostics.py +++ b/homeassistant/components/lamarzocco/diagnostics.py @@ -5,7 +5,7 @@ from dataclasses import asdict from typing import Any, TypedDict -from lmcloud.const import FirmwareType +from pylamarzocco.const import FirmwareType from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index f7e6ff9e2b814..1ea84302a17ac 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -3,8 +3,8 @@ from collections.abc import Callable from dataclasses import dataclass -from lmcloud.const import FirmwareType -from lmcloud.lm_machine import LaMarzoccoMachine +from pylamarzocco.const import FirmwareType +from pylamarzocco.lm_machine import LaMarzoccoMachine from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index bfe0d34a9e412..6b2260511183e 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -32,6 +32,6 @@ "documentation": "https://www.home-assistant.io/integrations/lamarzocco", "integration_type": "device", "iot_class": "cloud_polling", - "loggers": ["lmcloud"], - "requirements": ["lmcloud==1.2.3"] + "loggers": ["pylamarzocco"], + "requirements": ["pylamarzocco==1.2.3"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index df75147e7e1c0..825c5d6deb074 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -4,16 +4,16 @@ from dataclasses import dataclass from typing import Any -from lmcloud.const import ( +from pylamarzocco.const import ( KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey, PrebrewMode, ) -from lmcloud.exceptions import RequestNotSuccessful -from lmcloud.lm_machine import LaMarzoccoMachine -from lmcloud.models import LaMarzoccoMachineConfig +from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.number import ( NumberDeviceClass, diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index 1958fa6f21060..1889ba38d6bc7 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -4,10 +4,10 @@ from dataclasses import dataclass from typing import Any -from lmcloud.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel -from lmcloud.exceptions import RequestNotSuccessful -from lmcloud.lm_machine import LaMarzoccoMachine -from lmcloud.models import LaMarzoccoMachineConfig +from pylamarzocco.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel +from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index ca8a118c1ee6a..04b095e798ccc 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -3,8 +3,8 @@ from collections.abc import Callable from dataclasses import dataclass -from lmcloud.const import BoilerType, MachineModel, PhysicalKey -from lmcloud.lm_machine import LaMarzoccoMachine +from pylamarzocco.const import BoilerType, MachineModel, PhysicalKey +from pylamarzocco.lm_machine import LaMarzoccoMachine from homeassistant.components.sensor import ( SensorDeviceClass, diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index a611424418fc5..f7690885f0591 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -4,10 +4,10 @@ from dataclasses import dataclass from typing import Any -from lmcloud.const import BoilerType -from lmcloud.exceptions import RequestNotSuccessful -from lmcloud.lm_machine import LaMarzoccoMachine -from lmcloud.models import LaMarzoccoMachineConfig +from pylamarzocco.const import BoilerType +from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py index 61f436a7d7f88..371ff679bae0d 100644 --- a/homeassistant/components/lamarzocco/update.py +++ b/homeassistant/components/lamarzocco/update.py @@ -3,8 +3,8 @@ from dataclasses import dataclass from typing import Any -from lmcloud.const import FirmwareType -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.const import FirmwareType +from pylamarzocco.exceptions import RequestNotSuccessful from homeassistant.components.update import ( UpdateDeviceClass, diff --git a/requirements_all.txt b/requirements_all.txt index 972c94f3c73b1..acc44aecb430c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1309,9 +1309,6 @@ linear-garage-door==0.2.9 # homeassistant.components.linode linode-api==4.1.9b1 -# homeassistant.components.lamarzocco -lmcloud==1.2.3 - # homeassistant.components.google_maps locationsharinglib==5.0.1 @@ -2026,6 +2023,9 @@ pykwb==0.0.8 # homeassistant.components.lacrosse pylacrosse==0.4 +# homeassistant.components.lamarzocco +pylamarzocco==1.2.3 + # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c38ac10c53aca..6299b26c2cbc0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1090,9 +1090,6 @@ libsoundtouch==0.8 # homeassistant.components.linear_garage_door linear-garage-door==0.2.9 -# homeassistant.components.lamarzocco -lmcloud==1.2.3 - # homeassistant.components.london_underground london-tube-status==0.5 @@ -1631,6 +1628,9 @@ pykrakenapi==0.1.8 # homeassistant.components.kulersky pykulersky==0.5.2 +# homeassistant.components.lamarzocco +pylamarzocco==1.2.3 + # homeassistant.components.lastfm pylast==5.1.0 diff --git a/tests/components/lamarzocco/__init__.py b/tests/components/lamarzocco/__init__.py index f88fa474f8b76..f6ca0fe40df64 100644 --- a/tests/components/lamarzocco/__init__.py +++ b/tests/components/lamarzocco/__init__.py @@ -1,6 +1,6 @@ """Mock inputs for tests.""" -from lmcloud.const import MachineModel +from pylamarzocco.const import MachineModel from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index d8047dfbabfb5..210dd9406cc9e 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -5,9 +5,9 @@ from unittest.mock import MagicMock, patch from bleak.backends.device import BLEDevice -from lmcloud.const import FirmwareType, MachineModel, SteamLevel -from lmcloud.lm_machine import LaMarzoccoMachine -from lmcloud.models import LaMarzoccoDeviceInfo +from pylamarzocco.const import FirmwareType, MachineModel, SteamLevel +from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.models import LaMarzoccoDeviceInfo import pytest from homeassistant.components.lamarzocco.const import DOMAIN diff --git a/tests/components/lamarzocco/test_binary_sensor.py b/tests/components/lamarzocco/test_binary_sensor.py index 120d825c80463..956bfe90dd4e3 100644 --- a/tests/components/lamarzocco/test_binary_sensor.py +++ b/tests/components/lamarzocco/test_binary_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.exceptions import RequestNotSuccessful from syrupy import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE diff --git a/tests/components/lamarzocco/test_button.py b/tests/components/lamarzocco/test_button.py index b754688f36996..fdea26c9f6f20 100644 --- a/tests/components/lamarzocco/test_button.py +++ b/tests/components/lamarzocco/test_button.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy import SnapshotAssertion diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 13cf6a72b81d6..be93779848fb9 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -2,9 +2,9 @@ from unittest.mock import MagicMock, patch -from lmcloud.const import MachineModel -from lmcloud.exceptions import AuthFail, RequestNotSuccessful -from lmcloud.models import LaMarzoccoDeviceInfo +from pylamarzocco.const import MachineModel +from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.models import LaMarzoccoDeviceInfo import pytest from homeassistant.components.dhcp import DhcpServiceInfo diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 2c812f7943890..b99077a905928 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -2,8 +2,8 @@ from unittest.mock import AsyncMock, MagicMock, patch -from lmcloud.const import FirmwareType -from lmcloud.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.const import FirmwareType +from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful import pytest from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE diff --git a/tests/components/lamarzocco/test_number.py b/tests/components/lamarzocco/test_number.py index 352271f26cf70..710a0220e06e5 100644 --- a/tests/components/lamarzocco/test_number.py +++ b/tests/components/lamarzocco/test_number.py @@ -3,14 +3,14 @@ from typing import Any from unittest.mock import MagicMock -from lmcloud.const import ( +from pylamarzocco.const import ( KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey, PrebrewMode, ) -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy import SnapshotAssertion diff --git a/tests/components/lamarzocco/test_select.py b/tests/components/lamarzocco/test_select.py index 415954d30be37..24b96f84f3749 100644 --- a/tests/components/lamarzocco/test_select.py +++ b/tests/components/lamarzocco/test_select.py @@ -2,8 +2,8 @@ from unittest.mock import MagicMock -from lmcloud.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel +from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy import SnapshotAssertion diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 760dcffd28fff..6f14d52d1fcc9 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from lmcloud.const import MachineModel +from pylamarzocco.const import MachineModel import pytest from syrupy import SnapshotAssertion diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py index 802ab59148ec4..5c6d1cb1e421d 100644 --- a/tests/components/lamarzocco/test_switch.py +++ b/tests/components/lamarzocco/test_switch.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import MagicMock -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy import SnapshotAssertion diff --git a/tests/components/lamarzocco/test_update.py b/tests/components/lamarzocco/test_update.py index 3dc2a86b57468..aef37d7c9218a 100644 --- a/tests/components/lamarzocco/test_update.py +++ b/tests/components/lamarzocco/test_update.py @@ -2,8 +2,8 @@ from unittest.mock import MagicMock -from lmcloud.const import FirmwareType -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.const import FirmwareType +from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy import SnapshotAssertion From 0304588bb8ad3751a8a478a75d101b0dd075f7a8 Mon Sep 17 00:00:00 2001 From: Tom Gamull Date: Sat, 9 Nov 2024 05:19:36 -0500 Subject: [PATCH 0296/1070] Fix missing unit of measurement for blink wifi strength (#128409) --- homeassistant/components/blink/sensor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index f20f8188b4215..e0b5989cc8050 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -10,7 +10,11 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -32,6 +36,8 @@ SensorEntityDescription( key=TYPE_WIFI_STRENGTH, translation_key="wifi_strength", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), From 25fb70f281408f087e642ed1e9e71a1b003fb178 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 9 Nov 2024 11:29:24 +0100 Subject: [PATCH 0297/1070] Add blood glucose concentration device class (#129340) --- homeassistant/components/nightscout/sensor.py | 9 +++++--- homeassistant/components/number/const.py | 8 +++++++ homeassistant/components/number/icons.json | 3 +++ homeassistant/components/number/strings.json | 3 +++ .../components/recorder/statistics.py | 6 +++++ .../components/recorder/websocket_api.py | 4 ++++ homeassistant/components/sensor/const.py | 11 ++++++++++ .../components/sensor/device_condition.py | 5 +++++ .../components/sensor/device_trigger.py | 5 +++++ homeassistant/components/sensor/icons.json | 3 +++ homeassistant/components/sensor/strings.json | 5 +++++ homeassistant/const.py | 7 ++++++ homeassistant/util/unit_conversion.py | 12 ++++++++++ tests/util/test_unit_conversion.py | 22 +++++++++++++++++++ 14 files changed, 100 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index 92291bdc4f995..620349ec3c3ff 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -9,9 +9,9 @@ from aiohttp import ClientError from py_nightscout import Api as NightscoutAPI -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_DATE +from homeassistant.const import ATTR_DATE, UnitOfBloodGlucoseConcentration from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -37,7 +37,10 @@ async def async_setup_entry( class NightscoutSensor(SensorEntity): """Implementation of a Nightscout sensor.""" - _attr_native_unit_of_measurement = "mg/dL" + _attr_device_class = SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION + _attr_native_unit_of_measurement = ( + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER + ) _attr_icon = "mdi:cloud-question" def __init__(self, api: NightscoutAPI, name: str, unique_id: str | None) -> None: diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 5eea525fb6a57..23e3ce0910bc3 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -17,6 +17,7 @@ SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfApparentPower, + UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, UnitOfElectricCurrent, @@ -109,6 +110,12 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `%` """ + BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration" + """Blood glucose concentration. + + Unit of measurement: `mg/dL`, `mmol/L` + """ + CO = "carbon_monoxide" """Carbon Monoxide gas concentration. @@ -429,6 +436,7 @@ class NumberDeviceClass(StrEnum): NumberDeviceClass.AQI: {None}, NumberDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure), NumberDeviceClass.BATTERY: {PERCENTAGE}, + NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration), NumberDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION}, NumberDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION}, NumberDeviceClass.CONDUCTIVITY: set(UnitOfConductivity), diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index a122aaecb0946..5e0fc6e44d261 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -15,6 +15,9 @@ "battery": { "default": "mdi:battery" }, + "blood_glucose_concentration": { + "default": "mdi:spoon-sugar" + }, "carbon_dioxide": { "default": "mdi:molecule-co2" }, diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 580385172e38a..b9aec880ecc22 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -43,6 +43,9 @@ "battery": { "name": "[%key:component::sensor::entity_component::battery::name%]" }, + "blood_glucose_concentration": { + "name": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]" + }, "carbon_dioxide": { "name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]" }, diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 4ffe7c72971d8..9a66c4542b5e0 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -28,6 +28,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, + BloodGlugoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -128,6 +129,11 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { + **{ + unit: BloodGlugoseConcentrationConverter + for unit in BloodGlugoseConcentrationConverter.VALID_UNITS + }, + **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, **{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS}, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index ac917e903df25..8b8d1cfb0c657 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -16,6 +16,7 @@ from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( + BloodGlugoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -54,6 +55,9 @@ UNIT_SCHEMA = vol.Schema( { + vol.Optional("blood_glucose_concentration"): vol.In( + BloodGlugoseConcentrationConverter.VALID_UNITS + ), vol.Optional("conductivity"): vol.In(ConductivityConverter.VALID_UNITS), vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS), vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index aa3d1906b21f7..ee6167a564336 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -17,6 +17,7 @@ SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfApparentPower, + UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, UnitOfElectricCurrent, @@ -47,6 +48,7 @@ ) from homeassistant.util.unit_conversion import ( BaseUnitConverter, + BloodGlugoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -127,6 +129,12 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `%` """ + BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration" + """Blood glucose concentration. + + Unit of measurement: `mg/dL`, `mmol/L` + """ + CO = "carbon_monoxide" """Carbon Monoxide gas concentration. @@ -493,6 +501,7 @@ class SensorStateClass(StrEnum): UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlugoseConcentrationConverter, SensorDeviceClass.CONDUCTIVITY: ConductivityConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, SensorDeviceClass.DATA_RATE: DataRateConverter, @@ -524,6 +533,7 @@ class SensorStateClass(StrEnum): SensorDeviceClass.AQI: {None}, SensorDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure), SensorDeviceClass.BATTERY: {PERCENTAGE}, + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration), SensorDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION}, SensorDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION}, SensorDeviceClass.CONDUCTIVITY: set(UnitOfConductivity), @@ -599,6 +609,7 @@ class SensorStateClass(StrEnum): SensorDeviceClass.AQI: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ATMOSPHERIC_PRESSURE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.BATTERY: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.CO: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.CO2: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.CONDUCTIVITY: {SensorStateClass.MEASUREMENT}, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index f2b51899312cd..56ecb36adb3b1 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -37,6 +37,7 @@ CONF_IS_AQI = "is_aqi" CONF_IS_ATMOSPHERIC_PRESSURE = "is_atmospheric_pressure" CONF_IS_BATTERY_LEVEL = "is_battery_level" +CONF_IS_BLOOD_GLUCOSE_CONCENTRATION = "is_blood_glucose_concentration" CONF_IS_CO = "is_carbon_monoxide" CONF_IS_CO2 = "is_carbon_dioxide" CONF_IS_CONDUCTIVITY = "is_conductivity" @@ -87,6 +88,9 @@ SensorDeviceClass.AQI: [{CONF_TYPE: CONF_IS_AQI}], SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_IS_ATMOSPHERIC_PRESSURE}], SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_IS_BATTERY_LEVEL}], + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: [ + {CONF_TYPE: CONF_IS_BLOOD_GLUCOSE_CONCENTRATION} + ], SensorDeviceClass.CO: [{CONF_TYPE: CONF_IS_CO}], SensorDeviceClass.CO2: [{CONF_TYPE: CONF_IS_CO2}], SensorDeviceClass.CONDUCTIVITY: [{CONF_TYPE: CONF_IS_CONDUCTIVITY}], @@ -151,6 +155,7 @@ CONF_IS_AQI, CONF_IS_ATMOSPHERIC_PRESSURE, CONF_IS_BATTERY_LEVEL, + CONF_IS_BLOOD_GLUCOSE_CONCENTRATION, CONF_IS_CO, CONF_IS_CO2, CONF_IS_CONDUCTIVITY, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index b07b3fac11e45..ffee10d9f401b 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -36,6 +36,7 @@ CONF_AQI = "aqi" CONF_ATMOSPHERIC_PRESSURE = "atmospheric_pressure" CONF_BATTERY_LEVEL = "battery_level" +CONF_BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration" CONF_CO = "carbon_monoxide" CONF_CO2 = "carbon_dioxide" CONF_CONDUCTIVITY = "conductivity" @@ -86,6 +87,9 @@ SensorDeviceClass.AQI: [{CONF_TYPE: CONF_AQI}], SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_ATMOSPHERIC_PRESSURE}], SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_BATTERY_LEVEL}], + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: [ + {CONF_TYPE: CONF_BLOOD_GLUCOSE_CONCENTRATION} + ], SensorDeviceClass.CO: [{CONF_TYPE: CONF_CO}], SensorDeviceClass.CO2: [{CONF_TYPE: CONF_CO2}], SensorDeviceClass.CONDUCTIVITY: [{CONF_TYPE: CONF_CONDUCTIVITY}], @@ -151,6 +155,7 @@ CONF_AQI, CONF_ATMOSPHERIC_PRESSURE, CONF_BATTERY_LEVEL, + CONF_BLOOD_GLUCOSE_CONCENTRATION, CONF_CO, CONF_CO2, CONF_CONDUCTIVITY, diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 6132fcbc1e92e..ea4c902e66573 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -12,6 +12,9 @@ "atmospheric_pressure": { "default": "mdi:thermometer-lines" }, + "blood_glucose_concentration": { + "default": "mdi:spoon-sugar" + }, "carbon_dioxide": { "default": "mdi:molecule-co2" }, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 71bead342c40b..6d529e72c3b2b 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -6,6 +6,7 @@ "is_aqi": "Current {entity_name} air quality index", "is_atmospheric_pressure": "Current {entity_name} atmospheric pressure", "is_battery_level": "Current {entity_name} battery level", + "is_blood_glucose_concentration": "Current {entity_name} blood glucose concentration", "is_carbon_monoxide": "Current {entity_name} carbon monoxide concentration level", "is_carbon_dioxide": "Current {entity_name} carbon dioxide concentration level", "is_conductivity": "Current {entity_name} conductivity", @@ -56,6 +57,7 @@ "aqi": "{entity_name} air quality index changes", "atmospheric_pressure": "{entity_name} atmospheric pressure changes", "battery_level": "{entity_name} battery level changes", + "blood_glucose_concentration": "{entity_name} blood glucose concentration changes", "carbon_monoxide": "{entity_name} carbon monoxide concentration changes", "carbon_dioxide": "{entity_name} carbon dioxide concentration changes", "conductivity": "{entity_name} conductivity changes", @@ -149,6 +151,9 @@ "battery": { "name": "Battery" }, + "blood_glucose_concentration": { + "name": "Blood glucose concentration" + }, "carbon_monoxide": { "name": "Carbon monoxide" }, diff --git a/homeassistant/const.py b/homeassistant/const.py index 0bdd625e417e8..558e7ec2b0b84 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1358,6 +1358,13 @@ class UnitOfPrecipitationDepth(StrEnum): CONCENTRATION_PARTS_PER_BILLION: Final = "ppb" +class UnitOfBloodGlucoseConcentration(StrEnum): + """Blood glucose concentration units.""" + + MILLIGRAMS_PER_DECILITER = "mg/dL" + MILLIMOLE_PER_LITER = "mmol/L" + + # Speed units class UnitOfSpeed(StrEnum): """Speed units.""" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 289df28738ad0..95d8fbc9df120 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -10,6 +10,7 @@ CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, UNIT_NOT_RECOGNIZED_TEMPLATE, + UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, UnitOfElectricCurrent, @@ -173,6 +174,17 @@ class DistanceConverter(BaseUnitConverter): } +class BloodGlugoseConcentrationConverter(BaseUnitConverter): + """Utility to convert blood glucose concentration values.""" + + UNIT_CLASS = "blood_glucose_concentration" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER: 18, + UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER: 1, + } + VALID_UNITS = set(UnitOfBloodGlucoseConcentration) + + class ConductivityConverter(BaseUnitConverter): """Utility to convert electric current values.""" diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index b07b96e0de7db..a57cdde821fc8 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -11,6 +11,7 @@ CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, UnitOfElectricCurrent, @@ -32,6 +33,7 @@ from homeassistant.util import unit_conversion from homeassistant.util.unit_conversion import ( BaseUnitConverter, + BloodGlugoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -59,6 +61,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { converter: sorted(converter.VALID_UNITS, key=lambda x: (x is None, x)) for converter in ( + BloodGlugoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -80,6 +83,11 @@ # Dict containing all converters with a corresponding unit ratio. _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, float]] = { + BloodGlugoseConcentrationConverter: ( + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, + UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, + 18, + ), ConductivityConverter: ( UnitOfConductivity.MICROSIEMENS_PER_CM, UnitOfConductivity.MILLISIEMENS_PER_CM, @@ -130,6 +138,20 @@ _CONVERTED_VALUE: dict[ type[BaseUnitConverter], list[tuple[float, str | None, float, str | None]] ] = { + BloodGlugoseConcentrationConverter: [ + ( + 90, + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, + 5, + UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, + ), + ( + 1, + UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, + 18, + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, + ), + ], ConductivityConverter: [ # Deprecated to deprecated (5, UnitOfConductivity.SIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS), From 69ba0d3a50aa09810d1fbeee0797af63ef9b8709 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 9 Nov 2024 11:35:18 +0100 Subject: [PATCH 0298/1070] Report update_percentage in ezviz update entity (#129377) --- homeassistant/components/ezviz/update.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py index 05735d152cf30..25a506a005228 100644 --- a/homeassistant/components/ezviz/update.py +++ b/homeassistant/components/ezviz/update.py @@ -73,11 +73,9 @@ def installed_version(self) -> str | None: return self.data["version"] @property - def in_progress(self) -> bool | int | None: + def in_progress(self) -> bool: """Update installation progress.""" - if self.data["upgrade_in_progress"]: - return self.data["upgrade_percent"] - return False + return bool(self.data["upgrade_in_progress"]) @property def latest_version(self) -> str | None: @@ -93,6 +91,13 @@ def release_notes(self) -> str | None: return self.data["latest_firmware_info"].get("desc") return None + @property + def update_percentage(self) -> int | None: + """Update installation progress.""" + if self.data["upgrade_in_progress"]: + return self.data["upgrade_percent"] + return None + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: From 8b8e949bdfa2592c7b3a833c0dda502c3741bd8f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Nov 2024 12:07:20 +0100 Subject: [PATCH 0299/1070] Update wheel builder to 2024.11.0 (#130209) --- .github/workflows/wheels.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 0c8df57d5a287..835969f368f9a 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -135,7 +135,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2024.07.1 + uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -208,7 +208,7 @@ jobs: cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt - name: Build wheels (old cython) - uses: home-assistant/wheels@2024.07.1 + uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -223,7 +223,7 @@ jobs: pip: "'cython<3'" - name: Build wheels (part 1) - uses: home-assistant/wheels@2024.07.1 + uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -237,7 +237,7 @@ jobs: requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2024.07.1 + uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -251,7 +251,7 @@ jobs: requirements: "requirements_all.txtab" - name: Build wheels (part 3) - uses: home-assistant/wheels@2024.07.1 + uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From 03bc711c51e904bebba441c593a93f0724986e4d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 9 Nov 2024 12:25:06 +0100 Subject: [PATCH 0300/1070] Add Reolink chime vehicle tone (#129835) --- homeassistant/components/reolink/icons.json | 6 ++++++ homeassistant/components/reolink/select.py | 10 ++++++++++ homeassistant/components/reolink/strings.json | 16 ++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 7f4a15ffe213e..d333a8a020108 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -246,6 +246,12 @@ "off": "mdi:music-note-off" } }, + "vehicle_tone": { + "default": "mdi:music-note", + "state": { + "off": "mdi:music-note-off" + } + }, "visitor_tone": { "default": "mdi:music-note", "state": { diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 1306c881059c5..a444997a907de 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -197,6 +197,16 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: value=lambda chime: ChimeToneEnum(chime.tone("people")).name, method=lambda chime, name: chime.set_tone("people", ChimeToneEnum[name].value), ), + ReolinkChimeSelectEntityDescription( + key="vehicle_tone", + cmd_key="GetDingDongCfg", + translation_key="vehicle_tone", + entity_category=EntityCategory.CONFIG, + get_options=[method.name for method in ChimeToneEnum], + supported=lambda chime: "vehicle" in chime.chime_event_types, + value=lambda chime: ChimeToneEnum(chime.tone("vehicle")).name, + method=lambda chime, name: chime.set_tone("vehicle", ChimeToneEnum[name].value), + ), ReolinkChimeSelectEntityDescription( key="visitor_tone", cmd_key="GetDingDongCfg", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index fbc88ed1b506d..1d699b7b65844 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -606,6 +606,22 @@ "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]" } }, + "vehicle_tone": { + "name": "Vehicle ringtone", + "state": { + "off": "[%key:common::state::off%]", + "citybird": "[%key:component::reolink::entity::select::motion_tone::state::citybird%]", + "originaltune": "[%key:component::reolink::entity::select::motion_tone::state::originaltune%]", + "pianokey": "[%key:component::reolink::entity::select::motion_tone::state::pianokey%]", + "loop": "[%key:component::reolink::entity::select::motion_tone::state::loop%]", + "attraction": "[%key:component::reolink::entity::select::motion_tone::state::attraction%]", + "hophop": "[%key:component::reolink::entity::select::motion_tone::state::hophop%]", + "goodday": "[%key:component::reolink::entity::select::motion_tone::state::goodday%]", + "operetta": "[%key:component::reolink::entity::select::motion_tone::state::operetta%]", + "moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]", + "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]" + } + }, "visitor_tone": { "name": "Visitor ringtone", "state": { From 4e2f5bdb7d140f5001cd564b3dbe5ac996ba8575 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 9 Nov 2024 12:45:50 +0100 Subject: [PATCH 0301/1070] Add tests for cast skill action in Habitica (#129596) --- tests/components/habitica/fixtures/tasks.json | 3 +- tests/components/habitica/test_services.py | 273 ++++++++++++++++++ 2 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 tests/components/habitica/test_services.py diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 0d6ffba0732db..768768b44788d 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -454,7 +454,8 @@ "createdAt": "2024-09-21T22:17:19.513Z", "updatedAt": "2024-09-21T22:19:35.576Z", "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", - "id": "2f6fcabc-f670-4ec3-ba65-817e8deea490" + "id": "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "alias": "pay_bills" }, { "_id": "1aa3137e-ef72-4d1f-91ee-41933602f438", diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py new file mode 100644 index 0000000000000..072fc2b7721f0 --- /dev/null +++ b/tests/components/habitica/test_services.py @@ -0,0 +1,273 @@ +"""Test Habitica actions.""" + +from collections.abc import Generator +from http import HTTPStatus +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.habitica.const import ( + ATTR_CONFIG_ENTRY, + ATTR_SKILL, + ATTR_TASK, + DEFAULT_URL, + DOMAIN, + SERVICE_CAST_SKILL, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from .conftest import mock_called_with + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture(autouse=True) +def services_only() -> Generator[None]: + """Enable only services.""" + with patch( + "homeassistant.components.habitica.PLATFORMS", + [], + ): + yield + + +@pytest.fixture(autouse=True) +async def load_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + services_only: Generator, +) -> None: + """Load config entry.""" + 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 + + +@pytest.mark.parametrize( + ("service_data", "item", "target_id"), + [ + ( + { + ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ATTR_SKILL: "pickpocket", + }, + "pickPocket", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ), + ( + { + ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ATTR_SKILL: "backstab", + }, + "backStab", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ), + ( + { + ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ATTR_SKILL: "fireball", + }, + "fireball", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ), + ( + { + ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ATTR_SKILL: "smash", + }, + "smash", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + "smash", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ), + ( + { + ATTR_TASK: "pay_bills", + ATTR_SKILL: "smash", + }, + "smash", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ), + ], + ids=[ + "cast pickpocket", + "cast backstab", + "cast fireball", + "cast smash", + "select task by name", + "select task_by_alias", + ], +) +async def test_cast_skill( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service_data: dict[str, Any], + item: str, + target_id: str, +) -> None: + """Test Habitica cast skill action.""" + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", + json={"success": True, "data": {}}, + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_CAST_SKILL, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + + assert mock_called_with( + mock_habitica, + "post", + f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", + ) + + +@pytest.mark.parametrize( + ( + "service_data", + "http_status", + "expected_exception", + "expected_exception_msg", + ), + [ + ( + { + ATTR_TASK: "task-not-found", + ATTR_SKILL: "smash", + }, + HTTPStatus.OK, + ServiceValidationError, + "Unable to cast skill, could not find the task 'task-not-found", + ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + HTTPStatus.TOO_MANY_REQUESTS, + ServiceValidationError, + "Currently rate limited, try again later", + ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + HTTPStatus.NOT_FOUND, + ServiceValidationError, + "Unable to cast skill, your character does not have the skill or spell smash", + ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + HTTPStatus.UNAUTHORIZED, + ServiceValidationError, + "Unable to cast skill, not enough mana. Your character has 50 MP, but the skill costs 10 MP", + ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + HTTPStatus.BAD_REQUEST, + HomeAssistantError, + "Unable to connect to Habitica, try again later", + ), + ], +) +@pytest.mark.usefixtures("mock_habitica") +async def test_cast_skill_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service_data: dict[str, Any], + http_status: HTTPStatus, + expected_exception: Exception, + expected_exception_msg: str, +) -> None: + """Test Habitica cast skill action exceptions.""" + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/user/class/cast/smash?targetId=2f6fcabc-f670-4ec3-ba65-817e8deea490", + json={"success": True, "data": {}}, + status=http_status, + ) + + with pytest.raises(expected_exception, match=expected_exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_CAST_SKILL, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_habitica") +async def test_get_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, +) -> None: + """Test Habitica config entry exceptions.""" + + with pytest.raises( + ServiceValidationError, + match="The selected character is not configured in Home Assistant", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_CAST_SKILL, + service_data={ + ATTR_CONFIG_ENTRY: "0000000000000000", + ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ATTR_SKILL: "smash", + }, + return_response=True, + blocking=True, + ) + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + with pytest.raises( + ServiceValidationError, + match="The selected character is currently not loaded or disabled in Home Assistant", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_CAST_SKILL, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ATTR_SKILL: "smash", + }, + return_response=True, + blocking=True, + ) From 4adffdd1a607c386ab02ce64f610a7aa7a5212c7 Mon Sep 17 00:00:00 2001 From: Max Shcherbina <17325179+maxshcherbina@users.noreply.github.com> Date: Sat, 9 Nov 2024 07:01:59 -0500 Subject: [PATCH 0302/1070] Fix wording in Google Calendar create_event strings for consistency (#130183) --- homeassistant/components/google/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index c029b46051e72..2ea45239a5307 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -87,8 +87,8 @@ } }, "create_event": { - "name": "Creates event", - "description": "Add a new calendar event.", + "name": "Create event", + "description": "Adds a new calendar event.", "fields": { "summary": { "name": "Summary", From 4d7405de2c723d562e843c6753a93314428657d4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Nov 2024 13:03:26 +0100 Subject: [PATCH 0303/1070] Install zlib-dev for pillow wheel build (#130211) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 835969f368f9a..ef01bb122d39b 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -142,7 +142,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libffi-dev;openssl-dev;yaml-dev;nasm" + apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-dev" skip-binary: aiohttp;multidict;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -230,7 +230,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -244,7 +244,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -258,7 +258,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From 1f43dc667600bf48eff9972833612a1c963ac598 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 9 Nov 2024 13:12:04 +0100 Subject: [PATCH 0304/1070] Fix cast skill test in Habitica (#130213) --- tests/components/habitica/test_services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 072fc2b7721f0..1dd7b74893672 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -168,7 +168,7 @@ async def test_cast_skill( }, HTTPStatus.TOO_MANY_REQUESTS, ServiceValidationError, - "Currently rate limited, try again later", + "Rate limit exceeded, try again later", ), ( { From 5f0f29704b5cffef35ea396606885d8b9e3ed1a0 Mon Sep 17 00:00:00 2001 From: Marco <46717884+marcodutto@users.noreply.github.com> Date: Sat, 9 Nov 2024 13:32:00 +0100 Subject: [PATCH 0305/1070] Add smarty reset filters timer button (#129637) --- homeassistant/components/smarty/__init__.py | 8 +- homeassistant/components/smarty/button.py | 74 +++++++++++++++++++ homeassistant/components/smarty/strings.json | 5 ++ tests/components/smarty/conftest.py | 1 + .../smarty/snapshots/test_button.ambr | 47 ++++++++++++ tests/components/smarty/test_button.py | 45 +++++++++++ 6 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/smarty/button.py create mode 100644 tests/components/smarty/snapshots/test_button.ambr create mode 100644 tests/components/smarty/test_button.py diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index 0e5ca2166213e..0d043804c3d17 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -30,7 +30,13 @@ extra=vol.ALLOW_EXTRA, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.FAN, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.FAN, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: diff --git a/homeassistant/components/smarty/button.py b/homeassistant/components/smarty/button.py new file mode 100644 index 0000000000000..b8e31cf6fc8ca --- /dev/null +++ b/homeassistant/components/smarty/button.py @@ -0,0 +1,74 @@ +"""Platform to control a Salda Smarty XP/XV ventilation unit.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any + +from pysmarty2 import Smarty + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import SmartyConfigEntry, SmartyCoordinator +from .entity import SmartyEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class SmartyButtonDescription(ButtonEntityDescription): + """Class describing Smarty button.""" + + press_fn: Callable[[Smarty], bool | None] + + +ENTITIES: tuple[SmartyButtonDescription, ...] = ( + SmartyButtonDescription( + key="reset_filters_timer", + translation_key="reset_filters_timer", + press_fn=lambda smarty: smarty.reset_filters_timer(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartyConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Smarty Button Platform.""" + + coordinator = entry.runtime_data + + async_add_entities( + SmartyButton(coordinator, description) for description in ENTITIES + ) + + +class SmartyButton(SmartyEntity, ButtonEntity): + """Representation of a Smarty Button.""" + + entity_description: SmartyButtonDescription + + def __init__( + self, + coordinator: SmartyCoordinator, + entity_description: SmartyButtonDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{entity_description.key}" + ) + + async def async_press(self, **kwargs: Any) -> None: + """Press the button.""" + await self.hass.async_add_executor_job( + self.entity_description.press_fn, self.coordinator.client + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/smarty/strings.json b/homeassistant/components/smarty/strings.json index 5553a1c0135dc..188459b4f1630 100644 --- a/homeassistant/components/smarty/strings.json +++ b/homeassistant/components/smarty/strings.json @@ -42,6 +42,11 @@ "name": "Boost state" } }, + "button": { + "reset_filters_timer": { + "name": "Reset filters timer" + } + }, "sensor": { "supply_air_temperature": { "name": "Supply air temperature" diff --git a/tests/components/smarty/conftest.py b/tests/components/smarty/conftest.py index c61ec4b1022db..a9b518d88f42b 100644 --- a/tests/components/smarty/conftest.py +++ b/tests/components/smarty/conftest.py @@ -50,6 +50,7 @@ def mock_smarty() -> Generator[AsyncMock]: client.filter_timer = 31 client.get_configuration_version.return_value = 111 client.get_software_version.return_value = 127 + client.reset_filters_timer.return_value = True yield client diff --git a/tests/components/smarty/snapshots/test_button.ambr b/tests/components/smarty/snapshots/test_button.ambr new file mode 100644 index 0000000000000..38849bd2b2eed --- /dev/null +++ b/tests/components/smarty/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_all_entities[button.mock_title_reset_filters_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.mock_title_reset_filters_timer', + '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': 'Reset filters timer', + 'platform': 'smarty', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_filters_timer', + 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_reset_filters_timer', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.mock_title_reset_filters_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Reset filters timer', + }), + 'context': , + 'entity_id': 'button.mock_title_reset_filters_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smarty/test_button.py b/tests/components/smarty/test_button.py new file mode 100644 index 0000000000000..0a7b67f2be6a6 --- /dev/null +++ b/tests/components/smarty/test_button.py @@ -0,0 +1,45 @@ +"""Tests for the Smarty button platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, 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_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_smarty: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.smarty.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_setting_value( + hass: HomeAssistant, + mock_smarty: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + target={ATTR_ENTITY_ID: "button.mock_title_reset_filters_timer"}, + blocking=True, + ) + mock_smarty.reset_filters_timer.assert_called_once_with() From 6837ea947cb9e642c359bf8ccf546fbacb1e112a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 9 Nov 2024 15:54:18 +0100 Subject: [PATCH 0306/1070] Cleanup yaml import and legacy file notify service (#130219) --- homeassistant/components/file/__init__.py | 91 +-------- homeassistant/components/file/config_flow.py | 23 --- homeassistant/components/file/notify.py | 83 +------- homeassistant/components/file/sensor.py | 31 +-- tests/components/file/test_notify.py | 201 ++----------------- tests/components/file/test_sensor.py | 23 --- 6 files changed, 18 insertions(+), 434 deletions(-) diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index 0c9cfee5f4d48..4139b021422cf 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -3,88 +3,19 @@ from copy import deepcopy from typing import Any -from homeassistant.components.notify import migrate_notify_issue -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_FILE_PATH, - CONF_NAME, - CONF_PLATFORM, - CONF_SCAN_INTERVAL, - Platform, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import ( - config_validation as cv, - discovery, - issue_registry as ir, -) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers import config_validation as cv from .const import DOMAIN -from .notify import PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA -from .sensor import PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA - -IMPORT_SCHEMA = { - Platform.SENSOR: SENSOR_PLATFORM_SCHEMA, - Platform.NOTIFY: NOTIFY_PLATFORM_SCHEMA, -} CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the file integration.""" - - hass.data[DOMAIN] = config - if hass.config_entries.async_entries(DOMAIN): - # We skip import in case we already have config entries - return True - # The use of the legacy notify service was deprecated with HA Core 2024.6.0 - # and will be removed with HA Core 2024.12 - migrate_notify_issue(hass, DOMAIN, "File", "2024.12.0") - # The YAML config was imported with HA Core 2024.6.0 and will be removed with - # HA Core 2024.12 - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.12.0", - is_fixable=False, - issue_domain=DOMAIN, - learn_more_url="https://www.home-assistant.io/integrations/file/", - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "File", - }, - ) - - # Import the YAML config into separate config entries - platforms_config: dict[Platform, list[ConfigType]] = { - domain: config[domain] for domain in PLATFORMS if domain in config - } - for domain, items in platforms_config.items(): - for item in items: - if item[CONF_PLATFORM] == DOMAIN: - file_config_item = IMPORT_SCHEMA[domain](item) - file_config_item[CONF_PLATFORM] = domain - if CONF_SCAN_INTERVAL in file_config_item: - del file_config_item[CONF_SCAN_INTERVAL] - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=file_config_item, - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a file component entry.""" config = {**entry.data, **entry.options} @@ -102,20 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, [Platform(entry.data[CONF_PLATFORM])] ) entry.async_on_unload(entry.add_update_listener(update_listener)) - if entry.data[CONF_PLATFORM] == Platform.NOTIFY and CONF_NAME in entry.data: - # New notify entities are being setup through the config entry, - # but during the deprecation period we want to keep the legacy notify platform, - # so we forward the setup config through discovery. - # Only the entities from yaml will still be available as legacy service. - hass.async_create_task( - discovery.async_load_platform( - hass, - Platform.NOTIFY, - DOMAIN, - config, - hass.data[DOMAIN], - ) - ) return True diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py index 2b8a9bde749e1..992635d05fd93 100644 --- a/homeassistant/components/file/config_flow.py +++ b/homeassistant/components/file/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations from copy import deepcopy -import os from typing import Any import voluptuous as vol @@ -16,7 +15,6 @@ ) from homeassistant.const import ( CONF_FILE_PATH, - CONF_FILENAME, CONF_NAME, CONF_PLATFORM, CONF_UNIT_OF_MEASUREMENT, @@ -132,27 +130,6 @@ async def async_step_sensor( """Handle file sensor config flow.""" return await self._async_handle_step(Platform.SENSOR.value, user_input) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import `file`` config from configuration.yaml.""" - self._async_abort_entries_match(import_data) - platform = import_data[CONF_PLATFORM] - name: str = import_data.get(CONF_NAME, DEFAULT_NAME) - file_name: str - if platform == Platform.NOTIFY: - file_name = import_data.pop(CONF_FILENAME) - file_path: str = os.path.join(self.hass.config.config_dir, file_name) - import_data[CONF_FILE_PATH] = file_path - else: - file_path = import_data[CONF_FILE_PATH] - title = f"{name} [{file_path}]" - data = deepcopy(import_data) - options = {} - for key, value in import_data.items(): - if key not in (CONF_FILE_PATH, CONF_PLATFORM, CONF_NAME): - data.pop(key) - options[key] = value - return self.async_create_entry(title=title, data=data, options=options) - class FileOptionsFlowHandler(OptionsFlow): """Handle File options.""" diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 9411b7cf1a852..10e3d4a4ac66a 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -2,104 +2,23 @@ from __future__ import annotations -from functools import partial -import logging import os from typing import Any, TextIO -import voluptuous as vol - from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, - BaseNotificationService, NotifyEntity, NotifyEntityFeature, - migrate_notify_issue, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME, CONF_NAME +from homeassistant.const import CONF_FILE_PATH, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON -_LOGGER = logging.getLogger(__name__) - -# The legacy platform schema uses a filename, after import -# The full file path is stored in the config entry -PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_FILENAME): cv.string, - vol.Optional(CONF_TIMESTAMP, default=False): cv.boolean, - } -) - - -async def async_get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> FileNotificationService | None: - """Get the file notification service.""" - if discovery_info is None: - # We only set up through discovery - return None - file_path: str = discovery_info[CONF_FILE_PATH] - timestamp: bool = discovery_info[CONF_TIMESTAMP] - - return FileNotificationService(file_path, timestamp) - - -class FileNotificationService(BaseNotificationService): - """Implement the notification service for the File service.""" - - def __init__(self, file_path: str, add_timestamp: bool) -> None: - """Initialize the service.""" - self._file_path = file_path - self.add_timestamp = add_timestamp - - async def async_send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to a file.""" - # The use of the legacy notify service was deprecated with HA Core 2024.6.0 - # and will be removed with HA Core 2024.12 - migrate_notify_issue( - self.hass, DOMAIN, "File", "2024.12.0", service_name=self._service_name - ) - await self.hass.async_add_executor_job( - partial(self.send_message, message, **kwargs) - ) - - def send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to a file.""" - file: TextIO - filepath = self._file_path - try: - with open(filepath, "a", encoding="utf8") as file: - if os.stat(filepath).st_size == 0: - title = ( - f"{kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)} notifications (Log" - f" started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" - ) - file.write(title) - - if self.add_timestamp: - text = f"{dt_util.utcnow().isoformat()} {message}\n" - else: - text = f"{message}\n" - file.write(text) - except OSError as exc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="write_access_failed", - translation_placeholders={"filename": filepath, "exc": f"{exc!r}"}, - ) from exc - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py index e37a3df86a687..879c06e29f3b5 100644 --- a/homeassistant/components/file/sensor.py +++ b/homeassistant/components/file/sensor.py @@ -6,12 +6,8 @@ import os from file_read_backwards import FileReadBackwards -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorEntity, -) +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_FILE_PATH, @@ -20,38 +16,13 @@ CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DEFAULT_NAME, FILE_ICON _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_FILE_PATH): cv.isfile, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the file sensor from YAML. - - The YAML platform config is automatically - imported to a config entry, this method can be removed - when YAML support is removed. - """ - async def async_setup_entry( hass: HomeAssistant, diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index 33e4739a488a7..e7cb85a9cfcf2 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -12,222 +12,46 @@ from homeassistant.components.notify import ATTR_TITLE_DEFAULT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry, assert_setup_component - - -async def test_bad_config(hass: HomeAssistant) -> None: - """Test set up the platform with bad/missing config.""" - config = {notify.DOMAIN: {"name": "test", "platform": "file"}} - with assert_setup_component(0, domain="notify") as handle_config: - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert not handle_config[notify.DOMAIN] +from tests.common import MockConfigEntry @pytest.mark.parametrize( ("domain", "service", "params"), [ - (notify.DOMAIN, "test", {"message": "one, two, testing, testing"}), ( notify.DOMAIN, "send_message", {"entity_id": "notify.test", "message": "one, two, testing, testing"}, ), ], - ids=["legacy", "entity"], -) -@pytest.mark.parametrize( - ("timestamp", "config"), - [ - ( - False, - { - "notify": [ - { - "name": "test", - "platform": "file", - "filename": "mock_file", - "timestamp": False, - } - ] - }, - ), - ( - True, - { - "notify": [ - { - "name": "test", - "platform": "file", - "filename": "mock_file", - "timestamp": True, - } - ] - }, - ), - ], - ids=["no_timestamp", "timestamp"], ) +@pytest.mark.parametrize("timestamp", [False, True], ids=["no_timestamp", "timestamp"]) async def test_notify_file( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - timestamp: bool, mock_is_allowed_path: MagicMock, - config: ConfigType, + timestamp: bool, domain: str, service: str, params: dict[str, str], ) -> None: """Test the notify file output.""" filename = "mock_file" - message = params["message"] - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done(wait_background_tasks=True) - - freezer.move_to(dt_util.utcnow()) - - m_open = mock_open() - with ( - patch("homeassistant.components.file.notify.open", m_open, create=True), - patch("homeassistant.components.file.notify.os.stat") as mock_st, - ): - mock_st.return_value.st_size = 0 - title = ( - f"{ATTR_TITLE_DEFAULT} notifications " - f"(Log started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" - ) - - await hass.services.async_call(domain, service, params, blocking=True) + full_filename = os.path.join(hass.config.path(), filename) - full_filename = os.path.join(hass.config.path(), filename) - assert m_open.call_count == 1 - assert m_open.call_args == call(full_filename, "a", encoding="utf8") - - assert m_open.return_value.write.call_count == 2 - if not timestamp: - assert m_open.return_value.write.call_args_list == [ - call(title), - call(f"{message}\n"), - ] - else: - assert m_open.return_value.write.call_args_list == [ - call(title), - call(f"{dt_util.utcnow().isoformat()} {message}\n"), - ] - - -@pytest.mark.parametrize( - ("domain", "service", "params"), - [(notify.DOMAIN, "test", {"message": "one, two, testing, testing"})], - ids=["legacy"], -) -@pytest.mark.parametrize( - ("is_allowed", "config"), - [ - ( - True, - { - "notify": [ - { - "name": "test", - "platform": "file", - "filename": "mock_file", - } - ] - }, - ), - ], - ids=["allowed_but_access_failed"], -) -async def test_legacy_notify_file_exception( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_is_allowed_path: MagicMock, - config: ConfigType, - domain: str, - service: str, - params: dict[str, str], -) -> None: - """Test legacy notify file output has exception.""" - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done(wait_background_tasks=True) - - freezer.move_to(dt_util.utcnow()) - - m_open = mock_open() - with ( - patch("homeassistant.components.file.notify.open", m_open, create=True), - patch("homeassistant.components.file.notify.os.stat") as mock_st, - ): - mock_st.side_effect = OSError("Access Failed") - with pytest.raises(ServiceValidationError) as exc: - await hass.services.async_call(domain, service, params, blocking=True) - assert f"{exc.value!r}" == "ServiceValidationError('write_access_failed')" - - -@pytest.mark.parametrize( - ("timestamp", "data", "options"), - [ - ( - False, - { - "name": "test", - "platform": "notify", - "file_path": "mock_file", - }, - { - "timestamp": False, - }, - ), - ( - True, - { - "name": "test", - "platform": "notify", - "file_path": "mock_file", - }, - { - "timestamp": True, - }, - ), - ], - ids=["no_timestamp", "timestamp"], -) -async def test_legacy_notify_file_entry_only_setup( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - timestamp: bool, - mock_is_allowed_path: MagicMock, - data: dict[str, Any], - options: dict[str, Any], -) -> None: - """Test the legacy notify file output in entry only setup.""" - filename = "mock_file" - - domain = notify.DOMAIN - service = "test" - params = {"message": "one, two, testing, testing"} message = params["message"] entry = MockConfigEntry( domain=DOMAIN, - data=data, + data={"name": "test", "platform": "notify", "file_path": full_filename}, + options={"timestamp": timestamp}, version=2, - options=options, - title=f"test [{data['file_path']}]", + title=f"test [{filename}]", ) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.async_block_till_done(wait_background_tasks=True) + assert await hass.config_entries.async_setup(entry.entry_id) freezer.move_to(dt_util.utcnow()) @@ -245,7 +69,7 @@ async def test_legacy_notify_file_entry_only_setup( await hass.services.async_call(domain, service, params, blocking=True) assert m_open.call_count == 1 - assert m_open.call_args == call(filename, "a", encoding="utf8") + assert m_open.call_args == call(full_filename, "a", encoding="utf8") assert m_open.return_value.write.call_count == 2 if not timestamp: @@ -277,14 +101,14 @@ async def test_legacy_notify_file_entry_only_setup( ], ids=["not_allowed"], ) -async def test_legacy_notify_file_not_allowed( +async def test_notify_file_not_allowed( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_is_allowed_path: MagicMock, config: dict[str, Any], options: dict[str, Any], ) -> None: - """Test legacy notify file output not allowed.""" + """Test notify file output not allowed.""" entry = MockConfigEntry( domain=DOMAIN, data=config, @@ -301,11 +125,10 @@ async def test_legacy_notify_file_not_allowed( @pytest.mark.parametrize( ("service", "params"), [ - ("test", {"message": "one, two, testing, testing"}), ( "send_message", {"entity_id": "notify.test", "message": "one, two, testing, testing"}, - ), + ) ], ) @pytest.mark.parametrize( diff --git a/tests/components/file/test_sensor.py b/tests/components/file/test_sensor.py index 634ae9d626c9f..9e6a16e3e2723 100644 --- a/tests/components/file/test_sensor.py +++ b/tests/components/file/test_sensor.py @@ -7,33 +7,10 @@ from homeassistant.components.file import DOMAIN from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, get_fixture_path -@patch("os.path.isfile", Mock(return_value=True)) -@patch("os.access", Mock(return_value=True)) -async def test_file_value_yaml_setup( - hass: HomeAssistant, mock_is_allowed_path: MagicMock -) -> None: - """Test the File sensor from YAML setup.""" - config = { - "sensor": { - "platform": "file", - "scan_interval": 30, - "name": "file1", - "file_path": get_fixture_path("file_value.txt", "file"), - } - } - - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - state = hass.states.get("sensor.file1") - assert state.state == "21" - - @patch("os.path.isfile", Mock(return_value=True)) @patch("os.access", Mock(return_value=True)) async def test_file_value_entry_setup( From c89ab7a14244768db7ffdcbb276862f617e2d3bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 9 Nov 2024 15:54:58 +0100 Subject: [PATCH 0307/1070] Bump pyTibber (#130216) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 205bc1352ebda..d1bfefec48481 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.30.4"] + "requirements": ["pyTibber==0.30.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index acc44aecb430c..2d39d79181701 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1738,7 +1738,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.30.4 +pyTibber==0.30.7 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6299b26c2cbc0..a551f731fad96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1415,7 +1415,7 @@ pyElectra==1.2.4 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.30.4 +pyTibber==0.30.7 # homeassistant.components.dlink pyW215==0.7.0 From e6d16f06fc24eacd77a50c8beb85515d2cf7e608 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 9 Nov 2024 15:55:39 +0100 Subject: [PATCH 0308/1070] Fix uptime sensor for Vodafone Station (#130215) --- homeassistant/components/vodafone_station/sensor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index 136aa94b43af0..fb76253eb3d1d 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -43,12 +43,10 @@ def _calculate_uptime( ) -> datetime: """Calculate device uptime.""" - assert isinstance(last_value, datetime) - delta_uptime = coordinator.api.convert_uptime(coordinator.data.sensors[key]) if ( - not last_value + not isinstance(last_value, datetime) or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION ): return delta_uptime From c10f078f2a2153feef85eb5ec299a893111d8a91 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 9 Nov 2024 16:04:10 +0100 Subject: [PATCH 0309/1070] Add sensors for attribute points (str, int, per, con) to Habitica (#130186) --- .../components/habitica/coordinator.py | 5 + homeassistant/components/habitica/icons.json | 12 + homeassistant/components/habitica/sensor.py | 78 ++++- .../components/habitica/strings.json | 80 +++++ homeassistant/components/habitica/util.py | 50 +++ tests/components/habitica/conftest.py | 5 + .../fixtures/common_buttons_unavailable.json | 19 +- .../components/habitica/fixtures/content.json | 287 ++++++++++++++++++ .../habitica/fixtures/healer_fixture.json | 33 +- .../fixtures/healer_skills_unavailable.json | 33 +- .../fixtures/quest_invitation_off.json | 3 +- .../habitica/fixtures/rogue_fixture.json | 33 +- .../fixtures/rogue_skills_unavailable.json | 33 +- .../fixtures/rogue_stealth_unavailable.json | 33 +- tests/components/habitica/fixtures/user.json | 33 +- .../habitica/fixtures/warrior_fixture.json | 33 +- .../fixtures/warrior_skills_unavailable.json | 33 +- .../habitica/fixtures/wizard_fixture.json | 33 +- .../fixtures/wizard_frost_unavailable.json | 33 +- .../fixtures/wizard_skills_unavailable.json | 33 +- .../habitica/snapshots/test_sensor.ambr | 220 ++++++++++++++ .../components/habitica/test_binary_sensor.py | 6 +- tests/components/habitica/test_button.py | 10 + tests/components/habitica/test_todo.py | 5 + 24 files changed, 1047 insertions(+), 96 deletions(-) create mode 100644 tests/components/habitica/fixtures/content.json diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index cce2c684ba851..f9ffb1b53bd25 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -51,12 +51,17 @@ def __init__(self, hass: HomeAssistant, habitipy: HabitipyAsync) -> None: ), ) self.api = habitipy + self.content: dict[str, Any] = {} async def _async_update_data(self) -> HabiticaData: try: user_response = await self.api.user.get() tasks_response = await self.api.tasks.user.get() tasks_response.extend(await self.api.tasks.user.get(type="completedTodos")) + if not self.content: + self.content = await self.api.content.get( + language=user_response["preferences"]["language"] + ) except ClientResponseError as error: if error.status == HTTPStatus.TOO_MANY_REQUESTS: _LOGGER.debug("Rate limit exceeded, will try again later") diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 0698b85afe17e..b2b7e548fd794 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -126,6 +126,18 @@ }, "rewards": { "default": "mdi:treasure-chest" + }, + "strength": { + "default": "mdi:arm-flex-outline" + }, + "intelligence": { + "default": "mdi:head-snowflake-outline" + }, + "perception": { + "default": "mdi:eye-outline" + }, + "constitution": { + "default": "mdi:run-fast" } }, "switch": { diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 77356f88265b0..3b2395ecc5250 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -27,7 +27,7 @@ from .const import DOMAIN, UNIT_TASKS from .entity import HabiticaBase from .types import HabiticaConfigEntry -from .util import entity_used_in +from .util import entity_used_in, get_attribute_points, get_attributes_total _LOGGER = logging.getLogger(__name__) @@ -36,7 +36,10 @@ class HabitipySensorEntityDescription(SensorEntityDescription): """Habitipy Sensor Description.""" - value_fn: Callable[[dict[str, Any]], StateType] + value_fn: Callable[[dict[str, Any], dict[str, Any]], StateType] + attributes_fn: ( + Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None] | None + ) = None @dataclass(kw_only=True, frozen=True) @@ -65,76 +68,80 @@ class HabitipySensorEntity(StrEnum): REWARDS = "rewards" GEMS = "gems" TRINKETS = "trinkets" + STRENGTH = "strength" + INTELLIGENCE = "intelligence" + CONSTITUTION = "constitution" + PERCEPTION = "perception" SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = ( HabitipySensorEntityDescription( key=HabitipySensorEntity.DISPLAY_NAME, translation_key=HabitipySensorEntity.DISPLAY_NAME, - value_fn=lambda user: user.get("profile", {}).get("name"), + value_fn=lambda user, _: user.get("profile", {}).get("name"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.HEALTH, translation_key=HabitipySensorEntity.HEALTH, native_unit_of_measurement="HP", suggested_display_precision=0, - value_fn=lambda user: user.get("stats", {}).get("hp"), + value_fn=lambda user, _: user.get("stats", {}).get("hp"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.HEALTH_MAX, translation_key=HabitipySensorEntity.HEALTH_MAX, native_unit_of_measurement="HP", entity_registry_enabled_default=False, - value_fn=lambda user: user.get("stats", {}).get("maxHealth"), + value_fn=lambda user, _: user.get("stats", {}).get("maxHealth"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.MANA, translation_key=HabitipySensorEntity.MANA, native_unit_of_measurement="MP", suggested_display_precision=0, - value_fn=lambda user: user.get("stats", {}).get("mp"), + value_fn=lambda user, _: user.get("stats", {}).get("mp"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.MANA_MAX, translation_key=HabitipySensorEntity.MANA_MAX, native_unit_of_measurement="MP", - value_fn=lambda user: user.get("stats", {}).get("maxMP"), + value_fn=lambda user, _: user.get("stats", {}).get("maxMP"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.EXPERIENCE, translation_key=HabitipySensorEntity.EXPERIENCE, native_unit_of_measurement="XP", - value_fn=lambda user: user.get("stats", {}).get("exp"), + value_fn=lambda user, _: user.get("stats", {}).get("exp"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.EXPERIENCE_MAX, translation_key=HabitipySensorEntity.EXPERIENCE_MAX, native_unit_of_measurement="XP", - value_fn=lambda user: user.get("stats", {}).get("toNextLevel"), + value_fn=lambda user, _: user.get("stats", {}).get("toNextLevel"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.LEVEL, translation_key=HabitipySensorEntity.LEVEL, - value_fn=lambda user: user.get("stats", {}).get("lvl"), + value_fn=lambda user, _: user.get("stats", {}).get("lvl"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.GOLD, translation_key=HabitipySensorEntity.GOLD, native_unit_of_measurement="GP", suggested_display_precision=2, - value_fn=lambda user: user.get("stats", {}).get("gp"), + value_fn=lambda user, _: user.get("stats", {}).get("gp"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.CLASS, translation_key=HabitipySensorEntity.CLASS, - value_fn=lambda user: user.get("stats", {}).get("class"), + value_fn=lambda user, _: user.get("stats", {}).get("class"), device_class=SensorDeviceClass.ENUM, options=["warrior", "healer", "wizard", "rogue"], ), HabitipySensorEntityDescription( key=HabitipySensorEntity.GEMS, translation_key=HabitipySensorEntity.GEMS, - value_fn=lambda user: user.get("balance", 0) * 4, + value_fn=lambda user, _: user.get("balance", 0) * 4, suggested_display_precision=0, native_unit_of_measurement="gems", ), @@ -142,7 +149,7 @@ class HabitipySensorEntity(StrEnum): key=HabitipySensorEntity.TRINKETS, translation_key=HabitipySensorEntity.TRINKETS, value_fn=( - lambda user: user.get("purchased", {}) + lambda user, _: user.get("purchased", {}) .get("plan", {}) .get("consecutive", {}) .get("trinkets", 0) @@ -150,6 +157,38 @@ class HabitipySensorEntity(StrEnum): suggested_display_precision=0, native_unit_of_measurement="⧖", ), + HabitipySensorEntityDescription( + key=HabitipySensorEntity.STRENGTH, + translation_key=HabitipySensorEntity.STRENGTH, + value_fn=lambda user, content: get_attributes_total(user, content, "str"), + attributes_fn=lambda user, content: get_attribute_points(user, content, "str"), + suggested_display_precision=0, + native_unit_of_measurement="STR", + ), + HabitipySensorEntityDescription( + key=HabitipySensorEntity.INTELLIGENCE, + translation_key=HabitipySensorEntity.INTELLIGENCE, + value_fn=lambda user, content: get_attributes_total(user, content, "int"), + attributes_fn=lambda user, content: get_attribute_points(user, content, "int"), + suggested_display_precision=0, + native_unit_of_measurement="INT", + ), + HabitipySensorEntityDescription( + key=HabitipySensorEntity.PERCEPTION, + translation_key=HabitipySensorEntity.PERCEPTION, + value_fn=lambda user, content: get_attributes_total(user, content, "per"), + attributes_fn=lambda user, content: get_attribute_points(user, content, "per"), + suggested_display_precision=0, + native_unit_of_measurement="PER", + ), + HabitipySensorEntityDescription( + key=HabitipySensorEntity.CONSTITUTION, + translation_key=HabitipySensorEntity.CONSTITUTION, + value_fn=lambda user, content: get_attributes_total(user, content, "con"), + attributes_fn=lambda user, content: get_attribute_points(user, content, "con"), + suggested_display_precision=0, + native_unit_of_measurement="CON", + ), ) @@ -243,7 +282,16 @@ class HabitipySensor(HabiticaBase, SensorEntity): def native_value(self) -> StateType: """Return the state of the device.""" - return self.entity_description.value_fn(self.coordinator.data.user) + return self.entity_description.value_fn( + self.coordinator.data.user, self.coordinator.content + ) + + @property + def extra_state_attributes(self) -> dict[str, float | None] | None: + """Return entity specific state attributes.""" + if func := self.entity_description.attributes_fn: + return func(self.coordinator.data.user, self.coordinator.content) + return None class HabitipyTaskSensor(HabiticaBase, SensorEntity): diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index f7d2f20b8f9fb..5e453c6103773 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -164,6 +164,86 @@ }, "rewards": { "name": "Rewards" + }, + "strength": { + "name": "Strength", + "state_attributes": { + "level": { + "name": "[%key:component::habitica::entity::sensor::level::name%]" + }, + "equipment": { + "name": "Battle gear" + }, + "class": { + "name": "Class equip bonus" + }, + "allocated": { + "name": "Allocated attribute points" + }, + "buffs": { + "name": "Buffs" + } + } + }, + "intelligence": { + "name": "Intelligence", + "state_attributes": { + "level": { + "name": "[%key:component::habitica::entity::sensor::level::name%]" + }, + "equipment": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::equipment::name%]" + }, + "class": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::class::name%]" + }, + "allocated": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::allocated::name%]" + }, + "buffs": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]" + } + } + }, + "perception": { + "name": "Perception", + "state_attributes": { + "level": { + "name": "[%key:component::habitica::entity::sensor::level::name%]" + }, + "equipment": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::equipment::name%]" + }, + "class": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::class::name%]" + }, + "allocated": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::allocated::name%]" + }, + "buffs": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]" + } + } + }, + "constitution": { + "name": "Constitution", + "state_attributes": { + "level": { + "name": "[%key:component::habitica::entity::sensor::level::name%]" + }, + "equipment": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::equipment::name%]" + }, + "class": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::class::name%]" + }, + "allocated": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::allocated::name%]" + }, + "buffs": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]" + } + } } }, "switch": { diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 93a7c234a5d9e..03acb08baf9de 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime +from math import floor from typing import TYPE_CHECKING, Any from dateutil.rrule import ( @@ -139,3 +140,52 @@ def get_recurrence_rule(recurrence: rrule) -> str: """ return str(recurrence).split("RRULE:")[1] + + +def get_attribute_points( + user: dict[str, Any], content: dict[str, Any], attribute: str +) -> dict[str, float]: + """Get modifiers contributing to strength attribute.""" + + gear_set = { + "weapon", + "armor", + "head", + "shield", + "back", + "headAccessory", + "eyewear", + "body", + } + + equipment = sum( + stats[attribute] + for gear in gear_set + if (equipped := user["items"]["gear"]["equipped"].get(gear)) + and (stats := content["gear"]["flat"].get(equipped)) + ) + + class_bonus = sum( + stats[attribute] / 2 + for gear in gear_set + if (equipped := user["items"]["gear"]["equipped"].get(gear)) + and (stats := content["gear"]["flat"].get(equipped)) + and stats["klass"] == user["stats"]["class"] + ) + + return { + "level": min(round(user["stats"]["lvl"] / 2), 50), + "equipment": equipment, + "class": class_bonus, + "allocated": user["stats"][attribute], + "buffs": user["stats"]["buffs"][attribute], + } + + +def get_attributes_total( + user: dict[str, Any], content: dict[str, Any], attribute: str +) -> int: + """Get total attribute points.""" + return floor( + sum(value for value in get_attribute_points(user, content, attribute).values()) + ) diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index b5ceadd276292..03b76561abc18 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -56,6 +56,11 @@ def mock_habitica(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: f"{DEFAULT_URL}/api/v3/tasks/user", json=load_json_object_fixture("tasks.json", DOMAIN), ) + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/content", + params={"language": "en"}, + json=load_json_object_fixture("content.json", DOMAIN), + ) return aioclient_mock diff --git a/tests/components/habitica/fixtures/common_buttons_unavailable.json b/tests/components/habitica/fixtures/common_buttons_unavailable.json index 08039ae176250..efee5364e023d 100644 --- a/tests/components/habitica/fixtures/common_buttons_unavailable.json +++ b/tests/components/habitica/fixtures/common_buttons_unavailable.json @@ -29,11 +29,26 @@ "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/content.json b/tests/components/habitica/fixtures/content.json new file mode 100644 index 0000000000000..e8e14dead7362 --- /dev/null +++ b/tests/components/habitica/fixtures/content.json @@ -0,0 +1,287 @@ +{ + "success": true, + "data": { + "gear": { + "flat": { + "weapon_warrior_5": { + "text": "Ruby Sword", + "notes": "Weapon whose forge-glow never fades. Increases Strength by 15. ", + "str": 15, + "value": 90, + "type": "weapon", + "key": "weapon_warrior_5", + "set": "warrior-5", + "klass": "warrior", + "index": "5", + "int": 0, + "per": 0, + "con": 0 + }, + "armor_warrior_5": { + "text": "Golden Armor", + "notes": "Looks ceremonial, but no known blade can pierce it. Increases Constitution by 11.", + "con": 11, + "value": 120, + "last": true, + "type": "armor", + "key": "armor_warrior_5", + "set": "warrior-5", + "klass": "warrior", + "index": "5", + "str": 0, + "int": 0, + "per": 0 + }, + "head_warrior_5": { + "text": "Golden Helm", + "notes": "Regal crown bound to shining armor. Increases Strength by 12.", + "str": 12, + "value": 80, + "last": true, + "type": "head", + "key": "head_warrior_5", + "set": "warrior-5", + "klass": "warrior", + "index": "5", + "int": 0, + "per": 0, + "con": 0 + }, + "shield_warrior_5": { + "text": "Golden Shield", + "notes": "Shining badge of the vanguard. Increases Constitution by 9.", + "con": 9, + "value": 90, + "last": true, + "type": "shield", + "key": "shield_warrior_5", + "set": "warrior-5", + "klass": "warrior", + "index": "5", + "str": 0, + "int": 0, + "per": 0 + }, + "weapon_wizard_5": { + "twoHanded": true, + "text": "Archmage Staff", + "notes": "Assists in weaving the most complex of spells. Increases Intelligence by 15 and Perception by 7. Two-handed item.", + "int": 15, + "per": 7, + "value": 160, + "type": "weapon", + "key": "weapon_wizard_5", + "set": "wizard-5", + "klass": "wizard", + "index": "5", + "str": 0, + "con": 0 + }, + "armor_wizard_5": { + "text": "Royal Magus Robe", + "notes": "Symbol of the power behind the throne. Increases Intelligence by 12.", + "int": 12, + "value": 120, + "last": true, + "type": "armor", + "key": "armor_wizard_5", + "set": "wizard-5", + "klass": "wizard", + "index": "5", + "str": 0, + "per": 0, + "con": 0 + }, + "head_wizard_5": { + "text": "Royal Magus Hat", + "notes": "Shows authority over fortune, weather, and lesser mages. Increases Perception by 10.", + "per": 10, + "value": 80, + "last": true, + "type": "head", + "key": "head_wizard_5", + "set": "wizard-5", + "klass": "wizard", + "index": "5", + "str": 0, + "int": 0, + "con": 0 + }, + "weapon_healer_5": { + "text": "Royal Scepter", + "notes": "Fit to grace the hand of a monarch, or of one who stands at a monarch's right hand. Increases Intelligence by 9. ", + "int": 9, + "value": 90, + "type": "weapon", + "key": "weapon_healer_5", + "set": "healer-5", + "klass": "healer", + "index": "5", + "str": 0, + "per": 0, + "con": 0 + }, + "armor_healer_5": { + "text": "Royal Mantle", + "notes": "Attire of those who have saved the lives of kings. Increases Constitution by 18.", + "con": 18, + "value": 120, + "last": true, + "type": "armor", + "key": "armor_healer_5", + "set": "healer-5", + "klass": "healer", + "index": "5", + "str": 0, + "int": 0, + "per": 0 + }, + "head_healer_5": { + "text": "Royal Diadem", + "notes": "For king, queen, or miracle-worker. Increases Intelligence by 9.", + "int": 9, + "value": 80, + "last": true, + "type": "head", + "key": "head_healer_5", + "set": "healer-5", + "klass": "healer", + "index": "5", + "str": 0, + "per": 0, + "con": 0 + }, + "shield_healer_5": { + "text": "Royal Shield", + "notes": "Bestowed upon those most dedicated to the kingdom's defense. Increases Constitution by 12.", + "con": 12, + "value": 90, + "last": true, + "type": "shield", + "key": "shield_healer_5", + "set": "healer-5", + "klass": "healer", + "index": "5", + "str": 0, + "int": 0, + "per": 0 + }, + "weapon_rogue_5": { + "text": "Ninja-to", + "notes": "Sleek and deadly as the ninja themselves. Increases Strength by 8. ", + "str": 8, + "value": 90, + "type": "weapon", + "key": "weapon_rogue_5", + "set": "rogue-5", + "klass": "rogue", + "index": "5", + "int": 0, + "per": 0, + "con": 0 + }, + "armor_rogue_5": { + "text": "Umbral Armor", + "notes": "Allows stealth in the open in broad daylight. Increases Perception by 18.", + "per": 18, + "value": 120, + "last": true, + "type": "armor", + "key": "armor_rogue_5", + "set": "rogue-5", + "klass": "rogue", + "index": "5", + "str": 0, + "int": 0, + "con": 0 + }, + "head_rogue_5": { + "text": "Umbral Hood", + "notes": "Conceals even thoughts from those who would probe them. Increases Perception by 12.", + "per": 12, + "value": 80, + "last": true, + "type": "head", + "key": "head_rogue_5", + "set": "rogue-5", + "klass": "rogue", + "index": "5", + "str": 0, + "int": 0, + "con": 0 + }, + "shield_rogue_5": { + "text": "Ninja-to", + "notes": "Sleek and deadly as the ninja themselves. Increases Strength by 8. ", + "str": 8, + "value": 90, + "type": "shield", + "key": "shield_rogue_5", + "set": "rogue-5", + "klass": "rogue", + "index": "5", + "int": 0, + "per": 0, + "con": 0 + }, + "back_special_heroicAureole": { + "text": "Heroic Aureole", + "notes": "The gems on this aureole glimmer when you tell your tales of glory. Increases all stats by 7.", + "con": 7, + "str": 7, + "per": 7, + "int": 7, + "value": 175, + "type": "back", + "key": "back_special_heroicAureole", + "set": "special-heroicAureole", + "klass": "special", + "index": "heroicAureole" + }, + "headAccessory_armoire_gogglesOfBookbinding": { + "per": 8, + "set": "bookbinder", + "notes": "These goggles will help you zero in on any task, large or small! Increases Perception by 8. Enchanted Armoire: Bookbinder Set (Item 1 of 4).", + "text": "Goggles of Bookbinding", + "value": 100, + "type": "headAccessory", + "key": "headAccessory_armoire_gogglesOfBookbinding", + "klass": "armoire", + "index": "gogglesOfBookbinding", + "str": 0, + "int": 0, + "con": 0 + }, + "eyewear_armoire_plagueDoctorMask": { + "con": 5, + "int": 5, + "set": "plagueDoctor", + "notes": "An authentic mask worn by the doctors who battle the Plague of Procrastination. Increases Constitution and Intelligence by 5 each. Enchanted Armoire: Plague Doctor Set (Item 2 of 3).", + "text": "Plague Doctor Mask", + "value": 100, + "type": "eyewear", + "key": "eyewear_armoire_plagueDoctorMask", + "klass": "armoire", + "index": "plagueDoctorMask", + "str": 0, + "per": 0 + }, + "body_special_aetherAmulet": { + "text": "Aether Amulet", + "notes": "This amulet has a mysterious history. Increases Constitution and Strength by 10 each.", + "value": 175, + "str": 10, + "con": 10, + "type": "body", + "key": "body_special_aetherAmulet", + "set": "special-aetherAmulet", + "klass": "special", + "index": "aetherAmulet", + "int": 0, + "per": 0 + } + } + } + }, + "appVersion": "5.29.2" +} diff --git a/tests/components/habitica/fixtures/healer_fixture.json b/tests/components/habitica/fixtures/healer_fixture.json index 04cbabcfa2d79..85f719f4ca7d2 100644 --- a/tests/components/habitica/fixtures/healer_fixture.json +++ b/tests/components/habitica/fixtures/healer_fixture.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,17 +24,36 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 5 + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": true, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, "needsCron": true, - "lastCron": "2024-09-21T22:01:55.586Z" + "lastCron": "2024-09-21T22:01:55.586Z", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_healer_5", + "armor": "armor_healer_5", + "head": "head_healer_5", + "shield": "shield_healer_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/healer_skills_unavailable.json b/tests/components/habitica/fixtures/healer_skills_unavailable.json index 305a5f8cda15b..a6bff246b2a93 100644 --- a/tests/components/habitica/fixtures/healer_skills_unavailable.json +++ b/tests/components/habitica/fixtures/healer_skills_unavailable.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,16 +24,35 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 0 + "points": 0, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_healer_5", + "armor": "armor_healer_5", + "head": "head_healer_5", + "shield": "shield_healer_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/quest_invitation_off.json b/tests/components/habitica/fixtures/quest_invitation_off.json index f862a85c7c45d..b5eccd99e10b1 100644 --- a/tests/components/habitica/fixtures/quest_invitation_off.json +++ b/tests/components/habitica/fixtures/quest_invitation_off.json @@ -29,7 +29,8 @@ "preferences": { "sleep": false, "automaticAllocation": true, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true diff --git a/tests/components/habitica/fixtures/rogue_fixture.json b/tests/components/habitica/fixtures/rogue_fixture.json index f0ea42a718264..1e5e996c03463 100644 --- a/tests/components/habitica/fixtures/rogue_fixture.json +++ b/tests/components/habitica/fixtures/rogue_fixture.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,17 +24,36 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 5 + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": true, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, "needsCron": true, - "lastCron": "2024-09-21T22:01:55.586Z" + "lastCron": "2024-09-21T22:01:55.586Z", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_rogue_5", + "armor": "armor_rogue_5", + "head": "head_rogue_5", + "shield": "shield_rogue_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/rogue_skills_unavailable.json b/tests/components/habitica/fixtures/rogue_skills_unavailable.json index 2709731ba55a8..c7c5ff322458a 100644 --- a/tests/components/habitica/fixtures/rogue_skills_unavailable.json +++ b/tests/components/habitica/fixtures/rogue_skills_unavailable.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": true, "seafoam": false, @@ -24,16 +24,35 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 0 + "points": 0, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_rogue_5", + "armor": "armor_rogue_5", + "head": "head_rogue_5", + "shield": "shield_rogue_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/rogue_stealth_unavailable.json b/tests/components/habitica/fixtures/rogue_stealth_unavailable.json index a4e86abbb9131..9fd7adcca4260 100644 --- a/tests/components/habitica/fixtures/rogue_stealth_unavailable.json +++ b/tests/components/habitica/fixtures/rogue_stealth_unavailable.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 4, "streaks": false, "seafoam": false, @@ -24,16 +24,35 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 0 + "points": 0, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_rogue_5", + "armor": "armor_rogue_5", + "head": "head_rogue_5", + "shield": "shield_rogue_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index 818f4ed4eda86..569c5b81a023e 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,12 +24,17 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 5 + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": true, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true @@ -59,6 +64,20 @@ } }, "needsCron": true, - "lastCron": "2024-09-21T22:01:55.586Z" + "lastCron": "2024-09-21T22:01:55.586Z", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/warrior_fixture.json b/tests/components/habitica/fixtures/warrior_fixture.json index 53d18206f9a19..3517e8a908aae 100644 --- a/tests/components/habitica/fixtures/warrior_fixture.json +++ b/tests/components/habitica/fixtures/warrior_fixture.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,17 +24,36 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 5 + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": true, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, "needsCron": true, - "lastCron": "2024-09-21T22:01:55.586Z" + "lastCron": "2024-09-21T22:01:55.586Z", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/warrior_skills_unavailable.json b/tests/components/habitica/fixtures/warrior_skills_unavailable.json index 53160646569fd..b3d33c85d5cd4 100644 --- a/tests/components/habitica/fixtures/warrior_skills_unavailable.json +++ b/tests/components/habitica/fixtures/warrior_skills_unavailable.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,16 +24,35 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 0 + "points": 0, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/wizard_fixture.json b/tests/components/habitica/fixtures/wizard_fixture.json index 0f9f2a496397e..de596e231de86 100644 --- a/tests/components/habitica/fixtures/wizard_fixture.json +++ b/tests/components/habitica/fixtures/wizard_fixture.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,17 +24,36 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 5 + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": true, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, "needsCron": true, - "lastCron": "2024-09-21T22:01:55.586Z" + "lastCron": "2024-09-21T22:01:55.586Z", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_wizard_5", + "armor": "armor_wizard_5", + "head": "head_wizard_5", + "shield": "shield_base_0", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/wizard_frost_unavailable.json b/tests/components/habitica/fixtures/wizard_frost_unavailable.json index ba57568e99e1a..31d10fde4b992 100644 --- a/tests/components/habitica/fixtures/wizard_frost_unavailable.json +++ b/tests/components/habitica/fixtures/wizard_frost_unavailable.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": true, "seafoam": false, @@ -24,16 +24,35 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 0 + "points": 0, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_wizard_5", + "armor": "armor_wizard_5", + "head": "head_wizard_5", + "shield": "shield_base_0", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/wizard_skills_unavailable.json b/tests/components/habitica/fixtures/wizard_skills_unavailable.json index 11bf0a191937e..f3bdee9dd74ca 100644 --- a/tests/components/habitica/fixtures/wizard_skills_unavailable.json +++ b/tests/components/habitica/fixtures/wizard_skills_unavailable.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,16 +24,35 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 0 + "points": 0, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_wizard_5", + "armor": "armor_wizard_5", + "head": "head_wizard_5", + "shield": "shield_base_0", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index ee75b424a93b5..3a43069bfc461 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -59,6 +59,61 @@ 'state': 'wizard', }) # --- +# name: test_sensors[sensor.test_user_constitution-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_constitution', + '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': None, + 'original_icon': None, + 'original_name': 'Constitution', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_constitution', + 'unit_of_measurement': 'CON', + }) +# --- +# name: test_sensors[sensor.test_user_constitution-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'allocated': 15, + 'buffs': 26, + 'class': 0, + 'equipment': 20, + 'friendly_name': 'test-user Constitution', + 'level': 19, + 'unit_of_measurement': 'CON', + }), + 'context': , + 'entity_id': 'sensor.test_user_constitution', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- # name: test_sensors[sensor.test_user_dailies-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -567,6 +622,61 @@ 'state': '0', }) # --- +# name: test_sensors[sensor.test_user_intelligence-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_intelligence', + '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': None, + 'original_icon': None, + 'original_name': 'Intelligence', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_intelligence', + 'unit_of_measurement': 'INT', + }) +# --- +# name: test_sensors[sensor.test_user_intelligence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'allocated': 15, + 'buffs': 26, + 'class': 0, + 'equipment': 0, + 'friendly_name': 'test-user Intelligence', + 'level': 19, + 'unit_of_measurement': 'INT', + }), + 'context': , + 'entity_id': 'sensor.test_user_intelligence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- # name: test_sensors[sensor.test_user_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -854,6 +964,61 @@ 'state': '880', }) # --- +# name: test_sensors[sensor.test_user_perception-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_perception', + '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': None, + 'original_icon': None, + 'original_name': 'Perception', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_perception', + 'unit_of_measurement': 'PER', + }) +# --- +# name: test_sensors[sensor.test_user_perception-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'allocated': 15, + 'buffs': 26, + 'class': 0, + 'equipment': 8, + 'friendly_name': 'test-user Perception', + 'level': 19, + 'unit_of_measurement': 'PER', + }), + 'context': , + 'entity_id': 'sensor.test_user_perception', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '68', + }) +# --- # name: test_sensors[sensor.test_user_rewards-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -915,6 +1080,61 @@ 'state': '1', }) # --- +# name: test_sensors[sensor.test_user_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_strength', + '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': None, + 'original_icon': None, + 'original_name': 'Strength', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_strength', + 'unit_of_measurement': 'STR', + }) +# --- +# name: test_sensors[sensor.test_user_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'allocated': 15, + 'buffs': 26, + 'class': 0, + 'equipment': 27, + 'friendly_name': 'test-user Strength', + 'level': 19, + 'unit_of_measurement': 'STR', + }), + 'context': , + 'entity_id': 'sensor.test_user_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87', + }) +# --- # name: test_sensors[sensor.test_user_to_do_s-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/habitica/test_binary_sensor.py b/tests/components/habitica/test_binary_sensor.py index 5b19cd008bf5b..1710f8f217eff 100644 --- a/tests/components/habitica/test_binary_sensor.py +++ b/tests/components/habitica/test_binary_sensor.py @@ -66,7 +66,11 @@ async def test_pending_quest_states( json=load_json_object_fixture(f"{fixture}.json", DOMAIN), ) aioclient_mock.get(f"{DEFAULT_URL}/api/v3/tasks/user", json={"data": []}) - + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/content", + params={"language": "en"}, + json=load_json_object_fixture("content.json", DOMAIN), + ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/habitica/test_button.py b/tests/components/habitica/test_button.py index 6bd62f3a58e1b..979cefef9238b 100644 --- a/tests/components/habitica/test_button.py +++ b/tests/components/habitica/test_button.py @@ -63,6 +63,11 @@ async def test_buttons( f"{DEFAULT_URL}/api/v3/tasks/user", json=load_json_object_fixture("tasks.json", DOMAIN), ) + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/content", + params={"language": "en"}, + json=load_json_object_fixture("content.json", DOMAIN), + ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -163,6 +168,11 @@ async def test_button_press( f"{DEFAULT_URL}/api/v3/tasks/user", json=load_json_object_fixture("tasks.json", DOMAIN), ) + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/content", + params={"language": "en"}, + json=load_json_object_fixture("content.json", DOMAIN), + ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py index 88947caba2df7..c9a4b3dd37a94 100644 --- a/tests/components/habitica/test_todo.py +++ b/tests/components/habitica/test_todo.py @@ -672,6 +672,11 @@ async def test_next_due_date( f"{DEFAULT_URL}/api/v3/tasks/user", json=load_json_object_fixture(fixture, DOMAIN), ) + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/content", + params={"language": "en"}, + json=load_json_object_fixture("content.json", DOMAIN), + ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) From 97fa568876b1e1672e9a725f49563bc8c69c9d7a Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sat, 9 Nov 2024 16:11:34 +0100 Subject: [PATCH 0310/1070] No longer thrown an error when device is offline in linkplay (#130161) --- homeassistant/components/linkplay/media_player.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 36834610c04a6..983d8777a6a31 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -9,7 +9,7 @@ from linkplay.bridge import LinkPlayBridge from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus from linkplay.controller import LinkPlayController, LinkPlayMultiroom -from linkplay.exceptions import LinkPlayException, LinkPlayRequestException +from linkplay.exceptions import LinkPlayRequestException import voluptuous as vol from homeassistant.components import media_source @@ -201,9 +201,8 @@ async def async_update(self) -> None: try: await self._bridge.player.update_status() self._update_properties() - except LinkPlayException: + except LinkPlayRequestException: self._attr_available = False - raise @exception_wrap async def async_select_source(self, source: str) -> None: From 622682eb4397f60bdcc35c3facef5fe983cfc951 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sat, 9 Nov 2024 16:42:10 +0100 Subject: [PATCH 0311/1070] Change update after button press for lamarzocco (#129616) --- homeassistant/components/lamarzocco/button.py | 24 ++++++++++++++----- tests/components/lamarzocco/test_button.py | 22 ++++++++++------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py index b9bc7fc8844b9..ae79e21897ffe 100644 --- a/homeassistant/components/lamarzocco/button.py +++ b/homeassistant/components/lamarzocco/button.py @@ -1,11 +1,11 @@ """Button platform for La Marzocco espresso machines.""" +import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.lm_machine import LaMarzoccoMachine from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant @@ -13,9 +13,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import LaMarzoccoConfigEntry +from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +BACKFLUSH_ENABLED_DURATION = 15 + @dataclass(frozen=True, kw_only=True) class LaMarzoccoButtonEntityDescription( @@ -24,14 +26,25 @@ class LaMarzoccoButtonEntityDescription( ): """Description of a La Marzocco button.""" - press_fn: Callable[[LaMarzoccoMachine], Coroutine[Any, Any, None]] + press_fn: Callable[[LaMarzoccoUpdateCoordinator], Coroutine[Any, Any, None]] + + +async def async_backflush_and_update(coordinator: LaMarzoccoUpdateCoordinator) -> None: + """Press backflush button.""" + await coordinator.device.start_backflush() + # lib will set state optimistically + coordinator.async_set_updated_data(None) + # backflush is enabled for 15 seconds + # then turns off automatically + await asyncio.sleep(BACKFLUSH_ENABLED_DURATION + 1) + await coordinator.async_request_refresh() ENTITIES: tuple[LaMarzoccoButtonEntityDescription, ...] = ( LaMarzoccoButtonEntityDescription( key="start_backflush", translation_key="start_backflush", - press_fn=lambda machine: machine.start_backflush(), + press_fn=async_backflush_and_update, ), ) @@ -59,7 +72,7 @@ class LaMarzoccoButtonEntity(LaMarzoccoEntity, ButtonEntity): async def async_press(self) -> None: """Press button.""" try: - await self.entity_description.press_fn(self.coordinator.device) + await self.entity_description.press_fn(self.coordinator) except RequestNotSuccessful as exc: raise HomeAssistantError( translation_domain=DOMAIN, @@ -68,4 +81,3 @@ async def async_press(self) -> None: "key": self.entity_description.key, }, ) from exc - await self.coordinator.async_request_refresh() diff --git a/tests/components/lamarzocco/test_button.py b/tests/components/lamarzocco/test_button.py index fdea26c9f6f20..61b7ba77c22a7 100644 --- a/tests/components/lamarzocco/test_button.py +++ b/tests/components/lamarzocco/test_button.py @@ -1,6 +1,6 @@ """Tests for the La Marzocco Buttons.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from pylamarzocco.exceptions import RequestNotSuccessful import pytest @@ -33,14 +33,18 @@ async def test_start_backflush( assert entry assert entry == snapshot - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - { - ATTR_ENTITY_ID: f"button.{serial_number}_start_backflush", - }, - blocking=True, - ) + with patch( + "homeassistant.components.lamarzocco.button.asyncio.sleep", + new_callable=AsyncMock, + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: f"button.{serial_number}_start_backflush", + }, + blocking=True, + ) assert len(mock_lamarzocco.start_backflush.mock_calls) == 1 mock_lamarzocco.start_backflush.assert_called_once() From 928e5348e41ada697464d8b7ad000f27832c34d5 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Sat, 9 Nov 2024 16:47:02 +0100 Subject: [PATCH 0312/1070] Add custom integration action sections support to hassfest (#130148) --- script/hassfest/services.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 92fca14d373c2..8c9ab5c0c0b1d 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -75,6 +75,14 @@ def unique_field_validator(fields: Any) -> Any: } ) +CUSTOM_INTEGRATION_SECTION_SCHEMA = vol.Schema( + { + vol.Optional("collapsed"): bool, + vol.Required("fields"): vol.Schema({str: CUSTOM_INTEGRATION_FIELD_SCHEMA}), + } +) + + CORE_INTEGRATION_SERVICE_SCHEMA = vol.Any( vol.Schema( { @@ -105,7 +113,17 @@ def unique_field_validator(fields: Any) -> Any: vol.Optional("target"): vol.Any( selector.TargetSelector.CONFIG_SCHEMA, None ), - vol.Optional("fields"): vol.Schema({str: CUSTOM_INTEGRATION_FIELD_SCHEMA}), + vol.Optional("fields"): vol.All( + vol.Schema( + { + str: vol.Any( + CUSTOM_INTEGRATION_FIELD_SCHEMA, + CUSTOM_INTEGRATION_SECTION_SCHEMA, + ) + } + ), + unique_field_validator, + ), } ), None, From b61580a937832f285707940522258b8fd4a61074 Mon Sep 17 00:00:00 2001 From: Daniel Oltmanns Date: Sat, 9 Nov 2024 16:48:00 +0100 Subject: [PATCH 0313/1070] Add fan preset mode icons and strings to vesync (#129584) --- homeassistant/components/vesync/fan.py | 1 + homeassistant/components/vesync/icons.json | 16 ++++++++++++++++ homeassistant/components/vesync/strings.json | 14 ++++++++++++++ tests/components/vesync/snapshots/test_fan.ambr | 8 ++++---- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 58a262e769f17..098a17e90f0fe 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -94,6 +94,7 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): | FanEntityFeature.TURN_ON ) _attr_name = None + _attr_translation_key = "vesync" _enable_turn_on_off_backwards_compatibility = False def __init__(self, fan) -> None: diff --git a/homeassistant/components/vesync/icons.json b/homeassistant/components/vesync/icons.json index cfdefb2ed09d4..e4769acc9a514 100644 --- a/homeassistant/components/vesync/icons.json +++ b/homeassistant/components/vesync/icons.json @@ -1,4 +1,20 @@ { + "entity": { + "fan": { + "vesync": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "mdi:fan-auto", + "sleep": "mdi:sleep", + "pet": "mdi:paw", + "turbo": "mdi:weather-tornado" + } + } + } + } + } + }, "services": { "update_devices": { "service": "mdi:update" diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 5ff0aa58722f7..b6e4e2fd957fe 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -42,6 +42,20 @@ "current_voltage": { "name": "Current voltage" } + }, + "fan": { + "vesync": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "Auto", + "sleep": "Sleep", + "pet": "Pet", + "turbo": "Turbo" + } + } + } + } } }, "services": { diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 21985afd7bff0..60af4ae3d5be9 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -67,7 +67,7 @@ 'platform': 'vesync', 'previous_unique_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vesync', 'unique_id': 'air-purifier', 'unit_of_measurement': None, }), @@ -158,7 +158,7 @@ 'platform': 'vesync', 'previous_unique_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vesync', 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', 'unit_of_measurement': None, }), @@ -256,7 +256,7 @@ 'platform': 'vesync', 'previous_unique_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vesync', 'unique_id': '400s-purifier', 'unit_of_measurement': None, }), @@ -355,7 +355,7 @@ 'platform': 'vesync', 'previous_unique_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vesync', 'unique_id': '600s-purifier', 'unit_of_measurement': None, }), From 31b505828bd6aee1f386bb433a08418cb88acd70 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Sat, 9 Nov 2024 17:13:07 +0100 Subject: [PATCH 0314/1070] Simplify Bang & Olufsen source determination (#130072) --- .../components/bang_olufsen/const.py | 59 +------------------ .../components/bang_olufsen/media_player.py | 30 ---------- tests/components/bang_olufsen/const.py | 6 +- .../snapshots/test_media_player.ambr | 2 +- .../bang_olufsen/test_media_player.py | 58 +++++------------- 5 files changed, 24 insertions(+), 131 deletions(-) diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 1e06f153cdb05..209311d3e8aae 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -17,62 +17,9 @@ class BangOlufsenSource: """Class used for associating device source ids with friendly names. May not include all sources.""" - URI_STREAMER: Final[Source] = Source( - name="Audio Streamer", - id="uriStreamer", - is_seekable=False, - is_enabled=True, - is_playable=True, - ) - BLUETOOTH: Final[Source] = Source( - name="Bluetooth", - id="bluetooth", - is_seekable=False, - is_enabled=True, - is_playable=True, - ) - CHROMECAST: Final[Source] = Source( - name="Chromecast built-in", - id="chromeCast", - is_seekable=False, - is_enabled=True, - is_playable=True, - ) - LINE_IN: Final[Source] = Source( - name="Line-In", - id="lineIn", - is_seekable=False, - is_enabled=True, - is_playable=True, - ) - SPDIF: Final[Source] = Source( - name="Optical", - id="spdif", - is_seekable=False, - is_enabled=True, - is_playable=True, - ) - NET_RADIO: Final[Source] = Source( - name="B&O Radio", - id="netRadio", - is_seekable=False, - is_enabled=True, - is_playable=True, - ) - DEEZER: Final[Source] = Source( - name="Deezer", - id="deezer", - is_seekable=True, - is_enabled=True, - is_playable=True, - ) - TIDAL: Final[Source] = Source( - name="Tidal", - id="tidal", - is_seekable=True, - is_enabled=True, - is_playable=True, - ) + LINE_IN: Final[Source] = Source(name="Line-In", id="lineIn") + SPDIF: Final[Source] = Source(name="Optical", id="spdif") + URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer") BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = { diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 5dd4557367222..56aa66d32e80f 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -688,36 +688,6 @@ def media_channel(self) -> str | None: @property def source(self) -> str | None: """Return the current audio source.""" - - # Try to fix some of the source_change chromecast weirdness. - if hasattr(self._playback_metadata, "title"): - # source_change is chromecast but line in is selected. - if self._playback_metadata.title == BangOlufsenSource.LINE_IN.name: - return BangOlufsenSource.LINE_IN.name - - # source_change is chromecast but bluetooth is selected. - if self._playback_metadata.title == BangOlufsenSource.BLUETOOTH.name: - return BangOlufsenSource.BLUETOOTH.name - - # source_change is line in, bluetooth or optical but stale metadata is sent through the WebSocket, - # And the source has not changed. - if self._source_change.id in ( - BangOlufsenSource.BLUETOOTH.id, - BangOlufsenSource.LINE_IN.id, - BangOlufsenSource.SPDIF.id, - ): - return BangOlufsenSource.CHROMECAST.name - - # source_change is chromecast and there is metadata but no artwork. Bluetooth does support metadata but not artwork - # So i assume that it is bluetooth and not chromecast - if ( - hasattr(self._playback_metadata, "art") - and self._playback_metadata.art is not None - and len(self._playback_metadata.art) == 0 - and self._source_change.id == BangOlufsenSource.CHROMECAST.id - ): - return BangOlufsenSource.BLUETOOTH.name - return self._source_change.name @property diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 3769aef5cd3ab..6602a898eb688 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -16,6 +16,7 @@ PlayQueueItemType, RenderingState, SceneProperties, + Source, UserFlow, VolumeLevel, VolumeMute, @@ -125,7 +126,10 @@ }, ) -TEST_AUDIO_SOURCES = [BangOlufsenSource.TIDAL.name, BangOlufsenSource.LINE_IN.name] +TEST_SOURCE = Source( + name="Tidal", id="tidal", is_seekable=True, is_enabled=True, is_playable=True +) +TEST_AUDIO_SOURCES = [TEST_SOURCE.name, BangOlufsenSource.LINE_IN.name] TEST_VIDEO_SOURCES = ["HDMI A"] TEST_SOURCES = TEST_AUDIO_SOURCES + TEST_VIDEO_SOURCES TEST_FALLBACK_SOURCES = [ diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr index e48dc39198bcc..ea96e28682146 100644 --- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -573,7 +573,7 @@ 'Test Listening Mode (234)', 'Test Listening Mode 2 (345)', ]), - 'source': 'Chromecast built-in', + 'source': 'Line-In', 'source_list': list([ 'Tidal', 'Line-In', diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index e991ab3d1bcf0..aa35b0265dc38 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -105,6 +105,7 @@ TEST_SEEK_POSITION_HOME_ASSISTANT_FORMAT, TEST_SOUND_MODE_2, TEST_SOUND_MODES, + TEST_SOURCE, TEST_SOURCES, TEST_VIDEO_SOURCES, TEST_VOLUME, @@ -231,7 +232,7 @@ async def test_async_update_sources_availability( # Add a source that is available and playable mock_mozart_client.get_available_sources.return_value = SourceArray( - items=[BangOlufsenSource.TIDAL] + items=[TEST_SOURCE] ) # Send playback_source. The source is not actually used, so its attributes don't matter @@ -239,7 +240,7 @@ async def test_async_update_sources_availability( assert mock_mozart_client.get_available_sources.call_count == 2 assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) - assert states.attributes[ATTR_INPUT_SOURCE_LIST] == [BangOlufsenSource.TIDAL.name] + assert states.attributes[ATTR_INPUT_SOURCE_LIST] == [TEST_SOURCE.name] async def test_async_update_playback_metadata( @@ -357,19 +358,17 @@ async def test_async_update_playback_state( @pytest.mark.parametrize( - ("reported_source", "real_source", "content_type", "progress", "metadata"), + ("source", "content_type", "progress", "metadata"), [ - # Normal source, music mediatype expected, no progress expected + # Normal source, music mediatype expected ( - BangOlufsenSource.TIDAL, - BangOlufsenSource.TIDAL, + TEST_SOURCE, MediaType.MUSIC, TEST_PLAYBACK_PROGRESS.progress, PlaybackContentMetadata(), ), - # URI source, url media type expected, no progress expected + # URI source, url media type expected ( - BangOlufsenSource.URI_STREAMER, BangOlufsenSource.URI_STREAMER, MediaType.URL, TEST_PLAYBACK_PROGRESS.progress, @@ -378,44 +377,17 @@ async def test_async_update_playback_state( # Line-In source,media type expected, progress 0 expected ( BangOlufsenSource.LINE_IN, - BangOlufsenSource.CHROMECAST, MediaType.MUSIC, 0, PlaybackContentMetadata(), ), - # Chromecast as source, but metadata says Line-In. - # Progress is not set to 0 as the source is Chromecast first - ( - BangOlufsenSource.CHROMECAST, - BangOlufsenSource.LINE_IN, - MediaType.MUSIC, - TEST_PLAYBACK_PROGRESS.progress, - PlaybackContentMetadata(title=BangOlufsenSource.LINE_IN.name), - ), - # Chromecast as source, but metadata says Bluetooth - ( - BangOlufsenSource.CHROMECAST, - BangOlufsenSource.BLUETOOTH, - MediaType.MUSIC, - TEST_PLAYBACK_PROGRESS.progress, - PlaybackContentMetadata(title=BangOlufsenSource.BLUETOOTH.name), - ), - # Chromecast as source, but metadata says Bluetooth in another way - ( - BangOlufsenSource.CHROMECAST, - BangOlufsenSource.BLUETOOTH, - MediaType.MUSIC, - TEST_PLAYBACK_PROGRESS.progress, - PlaybackContentMetadata(art=[]), - ), ], ) async def test_async_update_source_change( hass: HomeAssistant, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, - reported_source: Source, - real_source: Source, + source: Source, content_type: MediaType, progress: int, metadata: PlaybackContentMetadata, @@ -444,10 +416,10 @@ async def test_async_update_source_change( # Simulate metadata playback_metadata_callback(metadata) - source_change_callback(reported_source) + source_change_callback(source) assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) - assert states.attributes[ATTR_INPUT_SOURCE] == real_source.name + assert states.attributes[ATTR_INPUT_SOURCE] == source.name assert states.attributes[ATTR_MEDIA_CONTENT_TYPE] == content_type assert states.attributes[ATTR_MEDIA_POSITION] == progress @@ -774,7 +746,7 @@ async def test_async_media_next_track( ("source", "expected_result", "seek_called_times"), [ # Seekable source, seek expected - (BangOlufsenSource.DEEZER, does_not_raise(), 1), + (TEST_SOURCE, does_not_raise(), 1), # Non seekable source, seek shouldn't work (BangOlufsenSource.LINE_IN, pytest.raises(HomeAssistantError), 0), # Malformed source, seek shouldn't work @@ -862,7 +834,7 @@ async def test_async_clear_playlist( # Invalid source ("Test source", pytest.raises(ServiceValidationError), 0, 0), # Valid audio source - (BangOlufsenSource.TIDAL.name, does_not_raise(), 1, 0), + (TEST_SOURCE.name, does_not_raise(), 1, 0), # Valid video source (TEST_VIDEO_SOURCES[0], does_not_raise(), 0, 1), ], @@ -1432,7 +1404,7 @@ async def test_async_join_players( await hass.config_entries.async_setup(mock_config_entry_2.entry_id) # Set the source to a beolink expandable source - source_change_callback(BangOlufsenSource.TIDAL) + source_change_callback(TEST_SOURCE) await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -1468,7 +1440,7 @@ async def test_async_join_players( ), # Invalid media_player entity ( - BangOlufsenSource.TIDAL, + TEST_SOURCE, [TEST_MEDIA_PLAYER_ENTITY_ID_3], pytest.raises(ServiceValidationError), "invalid_grouping_entity", @@ -1637,7 +1609,7 @@ async def test_async_beolink_expand( ) # Set the source to a beolink expandable source - source_change_callback(BangOlufsenSource.TIDAL) + source_change_callback(TEST_SOURCE) await hass.services.async_call( DOMAIN, From e3315383ab9af2b2de1aacba8554c26595039063 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Sat, 9 Nov 2024 11:13:57 -0500 Subject: [PATCH 0315/1070] Improve entity test coverage for Russound RIO (#129828) --- tests/components/russound_rio/__init__.py | 12 +++++ tests/components/russound_rio/conftest.py | 39 +++++++++++++--- .../russound_rio/fixtures/get_sources.json | 10 +++++ .../russound_rio/fixtures/get_zones.json | 22 ++++++++++ .../russound_rio/snapshots/test_init.ambr | 37 ++++++++++++++++ .../russound_rio/test_config_flow.py | 14 +++--- tests/components/russound_rio/test_init.py | 44 +++++++++++++++++++ 7 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 tests/components/russound_rio/fixtures/get_sources.json create mode 100644 tests/components/russound_rio/fixtures/get_zones.json create mode 100644 tests/components/russound_rio/snapshots/test_init.ambr create mode 100644 tests/components/russound_rio/test_init.py diff --git a/tests/components/russound_rio/__init__.py b/tests/components/russound_rio/__init__.py index 96171071907eb..d0e6d77f1ee14 100644 --- a/tests/components/russound_rio/__init__.py +++ b/tests/components/russound_rio/__init__.py @@ -1 +1,13 @@ """Tests for the Russound RIO integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 91d009f13f473..5c4d105e03afc 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -1,16 +1,19 @@ """Test fixtures for Russound RIO integration.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch +from aiorussound import Controller, RussoundTcpConnectionHandler, Source +from aiorussound.rio import ZoneControlSurface +from aiorussound.util import controller_device_str, zone_device_str import pytest from homeassistant.components.russound_rio.const import DOMAIN from homeassistant.core import HomeAssistant -from .const import HARDWARE_MAC, MOCK_CONFIG, MOCK_CONTROLLERS, MODEL +from .const import HARDWARE_MAC, HOST, MOCK_CONFIG, MODEL, PORT -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -33,7 +36,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture -def mock_russound() -> Generator[AsyncMock]: +def mock_russound_client() -> Generator[AsyncMock]: """Mock the Russound RIO client.""" with ( patch( @@ -41,8 +44,30 @@ def mock_russound() -> Generator[AsyncMock]: ) as mock_client, patch( "homeassistant.components.russound_rio.config_flow.RussoundClient", - return_value=mock_client, + new=mock_client, ), ): - mock_client.controllers = MOCK_CONTROLLERS - yield mock_client + client = mock_client.return_value + zones = { + int(k): ZoneControlSurface.from_dict(v) + for k, v in load_json_object_fixture("get_zones.json", DOMAIN).items() + } + client.sources = { + int(k): Source.from_dict(v) + for k, v in load_json_object_fixture("get_sources.json", DOMAIN).items() + } + for k, v in zones.items(): + v.device_str = zone_device_str(1, k) + v.fetch_current_source = Mock( + side_effect=lambda current_source=v.current_source: client.sources.get( + int(current_source) + ) + ) + + client.controllers = { + 1: Controller( + 1, "MCA-C5", client, controller_device_str(1), HARDWARE_MAC, None, zones + ) + } + client.connection_handler = RussoundTcpConnectionHandler(HOST, PORT) + yield client diff --git a/tests/components/russound_rio/fixtures/get_sources.json b/tests/components/russound_rio/fixtures/get_sources.json new file mode 100644 index 0000000000000..e39d702b8a1cf --- /dev/null +++ b/tests/components/russound_rio/fixtures/get_sources.json @@ -0,0 +1,10 @@ +{ + "1": { + "name": "Aux", + "type": "Miscellaneous Audio" + }, + "2": { + "name": "Spotify", + "type": "Russound Media Streamer" + } +} diff --git a/tests/components/russound_rio/fixtures/get_zones.json b/tests/components/russound_rio/fixtures/get_zones.json new file mode 100644 index 0000000000000..396310339b3ce --- /dev/null +++ b/tests/components/russound_rio/fixtures/get_zones.json @@ -0,0 +1,22 @@ +{ + "1": { + "name": "Backyard", + "volume": "10", + "status": "ON", + "enabled": "True", + "current_source": "1" + }, + "2": { + "name": "Kitchen", + "volume": "50", + "status": "OFF", + "enabled": "True", + "current_source": "2" + }, + "3": { + "name": "Bedroom", + "volume": "10", + "status": "OFF", + "enabled": "False" + } +} diff --git a/tests/components/russound_rio/snapshots/test_init.ambr b/tests/components/russound_rio/snapshots/test_init.ambr new file mode 100644 index 0000000000000..fcd59dd06f705 --- /dev/null +++ b/tests/components/russound_rio/snapshots/test_init.ambr @@ -0,0 +1,37 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + '00:11:22:33:44:55', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'russound_rio', + '00:11:22:33:44:55', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Russound', + 'model': 'MCA-C5', + 'model_id': None, + 'name': 'MCA-C5', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/russound_rio/test_config_flow.py b/tests/components/russound_rio/test_config_flow.py index 9461fe1d5bee1..cf754852731e3 100644 --- a/tests/components/russound_rio/test_config_flow.py +++ b/tests/components/russound_rio/test_config_flow.py @@ -11,7 +11,7 @@ async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound_client: AsyncMock ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -32,13 +32,13 @@ async def test_form( async def test_form_cannot_connect( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound_client: AsyncMock ) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - mock_russound.connect.side_effect = TimeoutError + mock_russound_client.connect.side_effect = TimeoutError result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG, @@ -48,7 +48,7 @@ async def test_form_cannot_connect( assert result["errors"] == {"base": "cannot_connect"} # Recover with correct information - mock_russound.connect.side_effect = None + mock_russound_client.connect.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG, @@ -61,7 +61,7 @@ async def test_form_cannot_connect( async def test_import( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound_client: AsyncMock ) -> None: """Test we import a config entry.""" result = await hass.config_entries.flow.async_init( @@ -77,10 +77,10 @@ async def test_import( async def test_import_cannot_connect( - hass: HomeAssistant, mock_russound: AsyncMock + hass: HomeAssistant, mock_russound_client: AsyncMock ) -> None: """Test we handle import cannot connect error.""" - mock_russound.connect.side_effect = TimeoutError + mock_russound_client.connect.side_effect = TimeoutError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG diff --git a/tests/components/russound_rio/test_init.py b/tests/components/russound_rio/test_init.py new file mode 100644 index 0000000000000..6787ee37c79ce --- /dev/null +++ b/tests/components/russound_rio/test_init.py @@ -0,0 +1,44 @@ +"""Tests for the Russound RIO integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.russound_rio.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test the Cambridge Audio configuration entry not ready.""" + mock_russound_client.connect.side_effect = TimeoutError + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_russound_client.connect = AsyncMock(return_value=True) + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry == snapshot From 2cc54867944d804f7033f0ff3f5e458ec579aabe Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sat, 9 Nov 2024 10:14:40 -0600 Subject: [PATCH 0316/1070] Bump SoCo to 0.30.6 (#130223) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index d6c5eb298d821..76a7d0bfa9198 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.30.4", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.6", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index 2d39d79181701..78ccbc5a3af02 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2689,7 +2689,7 @@ smhi-pkg==1.0.18 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.4 +soco==0.30.6 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a551f731fad96..d9c5131d5c1ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2144,7 +2144,7 @@ smhi-pkg==1.0.18 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.4 +soco==0.30.6 # homeassistant.components.solarlog solarlog_cli==0.3.2 From 0de4bfcc2c4d4812363df1f75d7993acf66f23a7 Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Sat, 9 Nov 2024 18:33:28 +0100 Subject: [PATCH 0317/1070] Add missing translation string for NINA (#129826) --- homeassistant/components/nina/strings.json | 6 ++---- tests/components/nina/test_config_flow.py | 5 ----- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index 9747feaddb7ae..98ea88d8798c1 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -38,12 +38,10 @@ } } }, - "abort": { - "unknown": "[%key:common::config_flow::error::unknown%]" - }, "error": { "no_selection": "[%key:component::nina::config::error::no_selection%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } } } diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index cd0904b181dfa..309c8860c2094 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -8,7 +8,6 @@ from unittest.mock import patch from pynina import ApiError -import pytest from homeassistant.components.nina.const import ( CONF_AREA_FILTER, @@ -279,10 +278,6 @@ async def test_options_flow_connection_error(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.nina.options.error.unknown"], -) async def test_options_flow_unexpected_exception(hass: HomeAssistant) -> None: """Test config flow options but with an unexpected exception.""" config_entry = MockConfigEntry( From 21d81d5a5ca93f60c18130135f0d8ad5c11a7b83 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 9 Nov 2024 10:02:15 -0800 Subject: [PATCH 0318/1070] Bump google-nest-sdm to 6.1.5 (#130229) --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 581113f0c96ad..44eaeeaf62d19 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==6.1.4"] + "requirements": ["google-nest-sdm==6.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 78ccbc5a3af02..35c0f06186321 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1018,7 +1018,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==6.1.4 +google-nest-sdm==6.1.5 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d9c5131d5c1ab..05a32f0420e88 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -868,7 +868,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==6.1.4 +google-nest-sdm==6.1.5 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From 5d0277a0d1a07db1659268f5f96b912651eedfb1 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 9 Nov 2024 19:34:25 +0100 Subject: [PATCH 0319/1070] Add actions for quest handling to Habitica (#129650) --- homeassistant/components/habitica/const.py | 7 +- homeassistant/components/habitica/icons.json | 18 +++ homeassistant/components/habitica/services.py | 63 ++++++++++ .../components/habitica/services.yaml | 20 +++- .../components/habitica/strings.json | 69 ++++++++++- tests/components/habitica/test_services.py | 110 +++++++++++++++++- 6 files changed, 282 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 55322a13e6a52..2107386c709a7 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -26,7 +26,12 @@ ATTR_SKILL = "skill" ATTR_TASK = "task" SERVICE_CAST_SKILL = "cast_skill" - +SERVICE_START_QUEST = "start_quest" +SERVICE_ACCEPT_QUEST = "accept_quest" +SERVICE_CANCEL_QUEST = "cancel_quest" +SERVICE_ABORT_QUEST = "abort_quest" +SERVICE_REJECT_QUEST = "reject_quest" +SERVICE_LEAVE_QUEST = "leave_quest" WARRIOR = "warrior" ROGUE = "rogue" HEALER = "healer" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index b2b7e548fd794..bf59aa78d5c62 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -163,6 +163,24 @@ }, "cast_skill": { "service": "mdi:creation-outline" + }, + "accept_quest": { + "service": "mdi:script-text" + }, + "reject_quest": { + "service": "mdi:script-text" + }, + "leave_quest": { + "service": "mdi:script-text" + }, + "abort_quest": { + "service": "mdi:script-text-key" + }, + "cancel_quest": { + "service": "mdi:script-text-key" + }, + "start_quest": { + "service": "mdi:script-text-key" } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 440e2d4fb2365..9bea15aae712f 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -30,8 +30,14 @@ ATTR_TASK, DOMAIN, EVENT_API_CALL_SUCCESS, + SERVICE_ABORT_QUEST, + SERVICE_ACCEPT_QUEST, SERVICE_API_CALL, + SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_LEAVE_QUEST, + SERVICE_REJECT_QUEST, + SERVICE_START_QUEST, ) from .types import HabiticaConfigEntry @@ -54,6 +60,12 @@ } ) +SERVICE_MANAGE_QUEST_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + } +) + def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: """Return config entry or raise if not found or not loaded.""" @@ -160,6 +172,57 @@ async def cast_skill(call: ServiceCall) -> ServiceResponse: await coordinator.async_request_refresh() return response + async def manage_quests(call: ServiceCall) -> ServiceResponse: + """Accept, reject, start, leave or cancel quests.""" + entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + + COMMAND_MAP = { + SERVICE_ABORT_QUEST: "abort", + SERVICE_ACCEPT_QUEST: "accept", + SERVICE_CANCEL_QUEST: "cancel", + SERVICE_LEAVE_QUEST: "leave", + SERVICE_REJECT_QUEST: "reject", + SERVICE_START_QUEST: "force-start", + } + try: + return await coordinator.api.groups.party.quests[ + COMMAND_MAP[call.service] + ].post() + except ClientResponseError as e: + if e.status == HTTPStatus.TOO_MANY_REQUESTS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + if e.status == HTTPStatus.UNAUTHORIZED: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_action_unallowed" + ) from e + if e.status == HTTPStatus.NOT_FOUND: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_not_found" + ) from e + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="service_call_exception" + ) from e + + for service in ( + SERVICE_ABORT_QUEST, + SERVICE_ACCEPT_QUEST, + SERVICE_CANCEL_QUEST, + SERVICE_LEAVE_QUEST, + SERVICE_REJECT_QUEST, + SERVICE_START_QUEST, + ): + hass.services.async_register( + DOMAIN, + service, + manage_quests, + schema=SERVICE_MANAGE_QUEST_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( DOMAIN, SERVICE_API_CALL, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 546ac8c1c342d..955a0779cd326 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -17,7 +17,7 @@ api_call: object: cast_skill: fields: - config_entry: + config_entry: &config_entry required: true selector: config_entry: @@ -37,3 +37,21 @@ cast_skill: required: true selector: text: +accept_quest: + fields: + config_entry: *config_entry +reject_quest: + fields: + config_entry: *config_entry +start_quest: + fields: + config_entry: *config_entry +cancel_quest: + fields: + config_entry: *config_entry +abort_quest: + fields: + config_entry: *config_entry +leave_quest: + fields: + config_entry: *config_entry diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 5e453c6103773..42f1dbee459f5 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -1,7 +1,8 @@ { "common": { "todos": "To-Do's", - "dailies": "Dailies" + "dailies": "Dailies", + "config_entry_name": "Select character" }, "config": { "abort": { @@ -311,6 +312,12 @@ }, "task_not_found": { "message": "Unable to cast skill, could not find the task {task}" + }, + "quest_action_unallowed": { + "message": "Action not allowed, only quest leader or group leader can perform this action" + }, + "quest_not_found": { + "message": "Unable to complete action, quest or group not found" } }, "issues": { @@ -355,6 +362,66 @@ "description": "The name (or task ID) of the task you want to target with the skill or spell." } } + }, + "accept_quest": { + "name": "Accept a quest invitation", + "description": "Accept a pending invitation to a quest.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Choose the Habitica character for which to perform the action." + } + } + }, + "reject_quest": { + "name": "Reject a quest invitation", + "description": "Reject a pending invitation to a quest.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]" + } + } + }, + "leave_quest": { + "name": "Leave a quest", + "description": "Leave the current quest you are participating in.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]" + } + } + }, + "abort_quest": { + "name": "Abort an active quest", + "description": "Terminate your party's ongoing quest. All progress will be lost and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]" + } + } + }, + "cancel_quest": { + "name": "Cancel a pending quest", + "description": "Cancel a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]" + } + } + }, + "start_quest": { + "name": "Force-start a pending quest", + "description": "Begin the quest immediately, bypassing any pending invitations that haven't been accepted or rejected. Only quest leader or group leader can perform this action.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]" + } + } } }, "selector": { diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 1dd7b74893672..390077e220586 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -13,7 +13,13 @@ ATTR_TASK, DEFAULT_URL, DOMAIN, + SERVICE_ABORT_QUEST, + SERVICE_ACCEPT_QUEST, + SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_LEAVE_QUEST, + SERVICE_REJECT_QUEST, + SERVICE_START_QUEST, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,6 +30,9 @@ from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica, try again later" +RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again later" + @pytest.fixture(autouse=True) def services_only() -> Generator[None]: @@ -168,7 +177,7 @@ async def test_cast_skill( }, HTTPStatus.TOO_MANY_REQUESTS, ServiceValidationError, - "Rate limit exceeded, try again later", + RATE_LIMIT_EXCEPTION_MSG, ), ( { @@ -195,7 +204,7 @@ async def test_cast_skill( }, HTTPStatus.BAD_REQUEST, HomeAssistantError, - "Unable to connect to Habitica, try again later", + REQUEST_EXCEPTION_MSG, ), ], ) @@ -271,3 +280,100 @@ async def test_get_config_entry( return_response=True, blocking=True, ) + + +@pytest.mark.parametrize( + ("service", "command"), + [ + (SERVICE_ABORT_QUEST, "abort"), + (SERVICE_ACCEPT_QUEST, "accept"), + (SERVICE_CANCEL_QUEST, "cancel"), + (SERVICE_LEAVE_QUEST, "leave"), + (SERVICE_REJECT_QUEST, "reject"), + (SERVICE_START_QUEST, "force-start"), + ], + ids=[], +) +async def test_handle_quests( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service: str, + command: str, +) -> None: + """Test Habitica actions for quest handling.""" + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/groups/party/quests/{command}", + json={"success": True, "data": {}}, + ) + + await hass.services.async_call( + DOMAIN, + service, + service_data={ATTR_CONFIG_ENTRY: config_entry.entry_id}, + return_response=True, + blocking=True, + ) + + assert mock_called_with( + mock_habitica, + "post", + f"{DEFAULT_URL}/api/v3/groups/party/quests/{command}", + ) + + +@pytest.mark.parametrize( + ( + "http_status", + "expected_exception", + "expected_exception_msg", + ), + [ + ( + HTTPStatus.TOO_MANY_REQUESTS, + ServiceValidationError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + HTTPStatus.NOT_FOUND, + ServiceValidationError, + "Unable to complete action, quest or group not found", + ), + ( + HTTPStatus.UNAUTHORIZED, + ServiceValidationError, + "Action not allowed, only quest leader or group leader can perform this action", + ), + ( + HTTPStatus.BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ], +) +@pytest.mark.usefixtures("mock_habitica") +async def test_handle_quests_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + http_status: HTTPStatus, + expected_exception: Exception, + expected_exception_msg: str, +) -> None: + """Test Habitica handle quests action exceptions.""" + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/groups/party/quests/accept", + json={"success": True, "data": {}}, + status=http_status, + ) + + with pytest.raises(expected_exception, match=expected_exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_ACCEPT_QUEST, + service_data={ATTR_CONFIG_ENTRY: config_entry.entry_id}, + return_response=True, + blocking=True, + ) From adb1c59859c490712eb1c9b05660f3f425d45329 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Nov 2024 21:37:56 +0100 Subject: [PATCH 0320/1070] Update grpcio to 1.67.1 (#130240) --- homeassistant/package_constraints.txt | 6 +++--- script/gen_requirements_all.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a8a7e009c4a43..9a5d046fbc352 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -81,9 +81,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.66.2 -grpcio-status==1.66.2 -grpcio-reflection==1.66.2 +grpcio==1.67.1 +grpcio-status==1.67.1 +grpcio-reflection==1.67.1 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index edcbc69c15de3..37d0ea1d105c6 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -117,9 +117,9 @@ # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.66.2 -grpcio-status==1.66.2 -grpcio-reflection==1.66.2 +grpcio==1.67.1 +grpcio-status==1.67.1 +grpcio-reflection==1.67.1 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 From 0fc019305e034e0d5c8116a9fabbf5318783a231 Mon Sep 17 00:00:00 2001 From: Max Shcherbina <17325179+maxshcherbina@users.noreply.github.com> Date: Sat, 9 Nov 2024 15:38:29 -0500 Subject: [PATCH 0321/1070] Fix typo in reminder date language string in Todoist integration (#130241) --- homeassistant/components/todoist/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 5b083ac58bfe8..721b491bbf5c0 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -78,7 +78,7 @@ "description": "When should user be reminded of this task, in natural language." }, "reminder_date_lang": { - "name": "Reminder data language", + "name": "Reminder date language", "description": "The language of reminder_date_string." }, "reminder_date": { From 31a2bb1b986d26885f1ad849ef55c480521b4c35 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Nov 2024 22:58:16 +0100 Subject: [PATCH 0322/1070] Fix flaky modbus tests (#130252) --- tests/components/modbus/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 5c612f9f8ad91..cdea046ceeab8 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -57,7 +57,7 @@ def check_config_loaded_fixture(): @pytest.fixture(name="register_words") def register_words_fixture(): """Set default for register_words.""" - return [0x00, 0x00] + return [0x00] @pytest.fixture(name="config_addon") From ecd8dde3473d0416ef57c62cf62c3a26d32989ca Mon Sep 17 00:00:00 2001 From: Lothar Bach Date: Sat, 9 Nov 2024 23:21:29 +0100 Subject: [PATCH 0323/1070] Fix path to tesla fleet key file in config folder (#130124) * Tesla Fleet load key file from config folder * Fix test --------- Co-authored-by: G Johansson --- homeassistant/components/tesla_fleet/__init__.py | 2 +- tests/components/tesla_fleet/test_button.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 70db4a183aae6..e7030b568b3d1 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -134,7 +134,7 @@ async def _refresh_token() -> str: signing = product["command_signing"] == "required" if signing: if not tesla.private_key: - await tesla.get_private_key("config/tesla_fleet.key") + await tesla.get_private_key(hass.config.path("tesla_fleet.key")) api = VehicleSigned(tesla.vehicle, vin) else: api = VehicleSpecific(tesla.vehicle, vin) diff --git a/tests/components/tesla_fleet/test_button.py b/tests/components/tesla_fleet/test_button.py index 07fdc962be94f..ef1cfd9035742 100644 --- a/tests/components/tesla_fleet/test_button.py +++ b/tests/components/tesla_fleet/test_button.py @@ -77,9 +77,13 @@ async def test_press_signing_error( new_product["response"][0]["command_signing"] = "required" mock_products.return_value = new_product - await setup_platform(hass, normal_config_entry, [Platform.BUTTON]) + with ( + patch("homeassistant.components.tesla_fleet.TeslaFleetApi.get_private_key"), + ): + await setup_platform(hass, normal_config_entry, [Platform.BUTTON]) with ( + patch("homeassistant.components.tesla_fleet.TeslaFleetApi.get_private_key"), patch( "homeassistant.components.tesla_fleet.VehicleSigned.flash_lights", side_effect=NotOnWhitelistFault, From 73a62a09b06415d6c27e677e7ab7c2942f25464d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 10 Nov 2024 00:54:52 -0800 Subject: [PATCH 0324/1070] Update nest tests to unload config entries to perform clean teardown (#130266) --- tests/components/nest/common.py | 1 + tests/components/nest/conftest.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index 5d4719918a670..f34c40e09f9cb 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -107,6 +107,7 @@ class FakeSubscriber(GoogleNestSubscriber): def __init__(self) -> None: # pylint: disable=super-init-not-called """Initialize Fake Subscriber.""" self._device_manager = DeviceManager() + self._subscriber_name = "fake-name" def set_update_callback(self, target: Callable[[EventMessage], Awaitable[None]]): """Capture the callback set by Home Assistant.""" diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 85c64aff37954..b070d0256124e 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -22,6 +22,7 @@ ) from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import CONF_SUBSCRIBER_ID, SDM_SCOPES +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -287,6 +288,8 @@ async def _setup_func() -> bool: await hass.async_block_till_done() yield _setup_func + if config_entry and config_entry.state == ConfigEntryState.LOADED: + await hass.config_entries.async_unload(config_entry.entry_id) @pytest.fixture From cafa598fd64b2b0e6bfab7915bfc097ba1520193 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Nov 2024 10:18:12 +0000 Subject: [PATCH 0325/1070] Bump aiohttp to 3.11.0b5 (#130264) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9a5d046fbc352..2c03e45892038 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.11.0b4 +aiohttp==3.11.0b5 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 7855a6671cc9c..3cb7fa0e43943 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.0b4", + "aiohttp==3.11.0b5", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index c7436cab5b8d2..f69fc2b02bf1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.0b4 +aiohttp==3.11.0b5 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From f3229c723c40f15a58ffb1f7251b9ff81a2a5b91 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 10 Nov 2024 11:19:10 +0100 Subject: [PATCH 0326/1070] Bump pynordpool to 0.2.2 (#130257) --- homeassistant/components/nordpool/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json index ba435c38b5e6e..bf093eb3ee928 100644 --- a/homeassistant/components/nordpool/manifest.json +++ b/homeassistant/components/nordpool/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pynordpool"], - "requirements": ["pynordpool==0.2.1"], + "requirements": ["pynordpool==0.2.2"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 35c0f06186321..cb0b156cfff58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2099,7 +2099,7 @@ pynetio==0.1.9.1 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.1 +pynordpool==0.2.2 # homeassistant.components.nuki pynuki==1.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05a32f0420e88..a13f27c3b9828 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1692,7 +1692,7 @@ pynetgear==0.10.10 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.1 +pynordpool==0.2.2 # homeassistant.components.nuki pynuki==1.6.3 From d0dbca41f7b5b574b1d95e88f2f567a5853f3033 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Sun, 10 Nov 2024 05:20:55 -0500 Subject: [PATCH 0327/1070] Support additional media player states for Russound RIO (#130261) --- .../components/russound_rio/entity.py | 4 +- .../components/russound_rio/media_player.py | 9 +++ tests/components/russound_rio/conftest.py | 6 +- tests/components/russound_rio/const.py | 6 ++ .../russound_rio/test_media_player.py | 58 +++++++++++++++++++ 5 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 tests/components/russound_rio/test_media_player.py diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py index 0233305bb1f56..9790ff43e68d5 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -96,6 +96,4 @@ async def async_added_to_hass(self) -> None: async def async_will_remove_from_hass(self) -> None: """Remove callbacks.""" - await self._client.unregister_state_update_callbacks( - self._state_update_callback - ) + self._client.unregister_state_update_callbacks(self._state_update_callback) diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 316e4d2be7c5f..561f3b008c789 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -132,7 +132,16 @@ def _source(self) -> Source: def state(self) -> MediaPlayerState | None: """Return the state of the device.""" status = self._zone.status + mode = self._source.mode if status == "ON": + if mode == "playing": + return MediaPlayerState.PLAYING + if mode == "paused": + return MediaPlayerState.PAUSED + if mode == "transitioning": + return MediaPlayerState.BUFFERING + if mode == "stopped": + return MediaPlayerState.IDLE return MediaPlayerState.ON if status == "OFF": return MediaPlayerState.OFF diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 5c4d105e03afc..09cccd7d83fc3 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -28,11 +28,9 @@ def mock_setup_entry(): @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Mock a Russound RIO config entry.""" - entry = MockConfigEntry( + return MockConfigEntry( domain=DOMAIN, data=MOCK_CONFIG, unique_id=HARDWARE_MAC, title=MODEL ) - entry.add_to_hass(hass) - return entry @pytest.fixture @@ -70,4 +68,6 @@ def mock_russound_client() -> Generator[AsyncMock]: ) } client.connection_handler = RussoundTcpConnectionHandler(HOST, PORT) + client.is_connected = Mock(return_value=True) + client.unregister_state_update_callbacks.return_value = True yield client diff --git a/tests/components/russound_rio/const.py b/tests/components/russound_rio/const.py index 527f4fe337739..3d2924693d276 100644 --- a/tests/components/russound_rio/const.py +++ b/tests/components/russound_rio/const.py @@ -2,6 +2,8 @@ from collections import namedtuple +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN + HOST = "127.0.0.1" PORT = 9621 MODEL = "MCA-C5" @@ -14,3 +16,7 @@ _CONTROLLER = namedtuple("Controller", ["mac_address", "controller_type"]) # noqa: PYI024 MOCK_CONTROLLERS = {1: _CONTROLLER(mac_address=HARDWARE_MAC, controller_type=MODEL)} + +DEVICE_NAME = "mca_c5" +NAME_ZONE_1 = "backyard" +ENTITY_ID_ZONE_1 = f"{MP_DOMAIN}.{DEVICE_NAME}_{NAME_ZONE_1}" diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py new file mode 100644 index 0000000000000..38ef603c21d09 --- /dev/null +++ b/tests/components/russound_rio/test_media_player.py @@ -0,0 +1,58 @@ +"""Tests for the Russound RIO media player.""" + +from unittest.mock import AsyncMock + +from aiorussound.models import CallbackType +import pytest + +from homeassistant.const import ( + STATE_BUFFERING, + STATE_IDLE, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .const import ENTITY_ID_ZONE_1 + +from tests.common import MockConfigEntry + + +async def mock_state_update(client: AsyncMock) -> None: + """Trigger a callback in the media player.""" + for callback in client.register_state_update_callbacks.call_args_list: + await callback[0][0](client, CallbackType.STATE) + + +@pytest.mark.parametrize( + ("zone_status", "source_mode", "media_player_state"), + [ + ("ON", None, STATE_ON), + ("ON", "playing", STATE_PLAYING), + ("ON", "paused", STATE_PAUSED), + ("ON", "transitioning", STATE_BUFFERING), + ("ON", "stopped", STATE_IDLE), + ("OFF", None, STATE_OFF), + ("OFF", "stopped", STATE_OFF), + ], +) +async def test_entity_state( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, + zone_status: str, + source_mode: str | None, + media_player_state: str, +) -> None: + """Test media player state.""" + await setup_integration(hass, mock_config_entry) + mock_russound_client.controllers[1].zones[1].status = zone_status + mock_russound_client.sources[1].mode = source_mode + await mock_state_update(mock_russound_client) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID_ZONE_1) + assert state.state == media_player_state From 7fdcb985181662a4f08241c429ea78152b7fb7f6 Mon Sep 17 00:00:00 2001 From: Max Shcherbina <17325179+maxshcherbina@users.noreply.github.com> Date: Sun, 10 Nov 2024 05:25:32 -0500 Subject: [PATCH 0328/1070] Update description for generic hygrostat description (#130244) --- homeassistant/components/generic_hygrostat/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/generic_hygrostat/strings.json b/homeassistant/components/generic_hygrostat/strings.json index a21ab68c62830..2be3955eff1ee 100644 --- a/homeassistant/components/generic_hygrostat/strings.json +++ b/homeassistant/components/generic_hygrostat/strings.json @@ -4,7 +4,7 @@ "step": { "user": { "title": "Add generic hygrostat", - "description": "Create a entity that control the humidity via a switch and sensor.", + "description": "Create a humidifier entity that control the humidity via a switch and sensor.", "data": { "device_class": "Device class", "dry_tolerance": "Dry tolerance", From e382f924e6af17f2cdad283ad19b644d363c649a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 10 Nov 2024 11:38:56 +0100 Subject: [PATCH 0329/1070] Add support for Python 3.13 (#129442) --- .github/workflows/ci.yaml | 2 +- .github/workflows/wheels.yml | 12 +++++----- homeassistant/components/huum/__init__.py | 15 +++++++++---- homeassistant/components/huum/climate.py | 12 +++++----- homeassistant/components/huum/config_flow.py | 7 ++++-- homeassistant/components/huum/manifest.json | 2 +- homeassistant/components/profiler/__init__.py | 4 ++++ .../components/profiler/manifest.json | 2 +- homeassistant/package_constraints.txt | 3 +++ pyproject.toml | 14 ++++++++++++ requirements.txt | 3 +++ requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/components/huum/conftest.py | 6 +++++ tests/components/profiler/test_init.py | 22 +++++++++++++++++++ 15 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 tests/components/huum/conftest.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 778ab8b064777..fa05f6082a20e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -42,7 +42,7 @@ env: MYPY_CACHE_VERSION: 9 HA_SHORT_VERSION: "2024.12" DEFAULT_PYTHON: "3.12" - ALL_PYTHON_VERSIONS: "['3.12']" + ALL_PYTHON_VERSIONS: "['3.12', '3.13']" # 10.3 is the oldest supported version # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) # 10.6 is the current long-term-support diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index ef01bb122d39b..b9f54bba08144 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -112,7 +112,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp312"] + abi: ["cp312", "cp313"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository @@ -156,7 +156,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp312"] + abi: ["cp312", "cp313"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository @@ -198,6 +198,7 @@ jobs: split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt - name: Create requirements for cython<3 + if: matrix.abi == 'cp312' run: | # Some dependencies still require 'cython<3' # and don't yet use isolated build environments. @@ -209,6 +210,7 @@ jobs: - name: Build wheels (old cython) uses: home-assistant/wheels@2024.11.0 + if: matrix.abi == 'cp312' with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -231,7 +233,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl + skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -245,7 +247,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl + skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -259,7 +261,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl + skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" diff --git a/homeassistant/components/huum/__init__.py b/homeassistant/components/huum/__init__.py index 75faf1923df6c..c533ca34ef3a5 100644 --- a/homeassistant/components/huum/__init__.py +++ b/homeassistant/components/huum/__init__.py @@ -3,23 +3,30 @@ from __future__ import annotations import logging - -from huum.exceptions import Forbidden, NotAuthenticated -from huum.huum import Huum +import sys from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS +if sys.version_info < (3, 13): + from huum.exceptions import Forbidden, NotAuthenticated + from huum.huum import Huum + _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Huum from a config entry.""" + if sys.version_info >= (3, 13): + raise HomeAssistantError( + "Huum is not supported on Python 3.13. Please use Python 3.12." + ) + username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index df740aea3d120..b659e33038a57 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -3,13 +3,9 @@ from __future__ import annotations import logging +import sys from typing import Any -from huum.const import SaunaStatus -from huum.exceptions import SafetyException -from huum.huum import Huum -from huum.schemas import HuumStatusResponse - from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -24,6 +20,12 @@ from .const import DOMAIN +if sys.version_info < (3, 13): + from huum.const import SaunaStatus + from huum.exceptions import SafetyException + from huum.huum import Huum + from huum.schemas import HuumStatusResponse + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py index 6a5fd96b99d95..10c3137818464 100644 --- a/homeassistant/components/huum/config_flow.py +++ b/homeassistant/components/huum/config_flow.py @@ -3,10 +3,9 @@ from __future__ import annotations import logging +import sys from typing import Any -from huum.exceptions import Forbidden, NotAuthenticated -from huum.huum import Huum import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -15,6 +14,10 @@ from .const import DOMAIN +if sys.version_info < (3, 13): + from huum.exceptions import Forbidden, NotAuthenticated + from huum.huum import Huum + _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index cc393f3785ff6..025d1b97f216f 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", - "requirements": ["huum==0.7.11"] + "requirements": ["huum==0.7.11;python_version<'3.13'"] } diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 9b2b97365746e..389e3384ad9b9 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -436,6 +436,10 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall) # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time + if sys.version_info >= (3, 13): + raise HomeAssistantError( + "Memory profiling is not supported on Python 3.13. Please use Python 3.12." + ) from guppy import hpy # pylint: disable=import-outside-toplevel start_time = int(time.time() * 1000000) diff --git a/homeassistant/components/profiler/manifest.json b/homeassistant/components/profiler/manifest.json index 9f27ee7f7d076..8d2814c8c7f58 100644 --- a/homeassistant/components/profiler/manifest.json +++ b/homeassistant/components/profiler/manifest.json @@ -7,7 +7,7 @@ "quality_scale": "internal", "requirements": [ "pyprof2calltree==1.4.5", - "guppy3==3.1.4.post1", + "guppy3==3.1.4.post1;python_version<'3.13'", "objgraph==3.5.0" ], "single_config_entry": true diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2c03e45892038..0606cdd343563 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,6 +13,7 @@ async-interrupt==1.2.0 async-upnp-client==0.41.0 atomicwrites-homeassistant==1.4.1 attrs==24.2.0 +audioop-lts==0.2.1;python_version>='3.13' av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 @@ -59,6 +60,8 @@ PyYAML==6.0.2 requests==2.32.3 securetar==2024.2.1 SQLAlchemy==2.0.31 +standard-aifc==3.13.0;python_version>='3.13' +standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 diff --git a/pyproject.toml b/pyproject.toml index 3cb7fa0e43943..c18f616abad22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "async-interrupt==1.2.0", "attrs==24.2.0", "atomicwrites-homeassistant==1.4.1", + "audioop-lts==0.2.1;python_version>='3.13'", "awesomeversion==24.6.0", "bcrypt==4.2.0", "certifi>=2021.5.30", @@ -65,6 +66,8 @@ dependencies = [ "requests==2.32.3", "securetar==2024.2.1", "SQLAlchemy==2.0.31", + "standard-aifc==3.13.0;python_version>='3.13'", + "standard-telnetlib==3.13.0;python_version>='3.13'", "typing-extensions>=4.12.2,<5.0", "ulid-transform==1.0.2", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 @@ -617,6 +620,17 @@ filterwarnings = [ # https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2 "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i", + # -- New in Python 3.13 + # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 + # https://github.com/kurtmckee/feedparser/issues/481 + "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html", + # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib + "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", + # -- unmaintained projects, last release about 2+ years # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", diff --git a/requirements.txt b/requirements.txt index f69fc2b02bf1c..d3c60eb302e0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ astral==2.2 async-interrupt==1.2.0 attrs==24.2.0 atomicwrites-homeassistant==1.4.1 +audioop-lts==0.2.1;python_version>='3.13' awesomeversion==24.6.0 bcrypt==4.2.0 certifi>=2021.5.30 @@ -37,6 +38,8 @@ PyYAML==6.0.2 requests==2.32.3 securetar==2024.2.1 SQLAlchemy==2.0.31 +standard-aifc==3.13.0;python_version>='3.13' +standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 diff --git a/requirements_all.txt b/requirements_all.txt index cb0b156cfff58..7813e5fc7331c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1066,7 +1066,7 @@ gspread==5.5.0 gstreamer-player==1.1.2 # homeassistant.components.profiler -guppy3==3.1.4.post1 +guppy3==3.1.4.post1;python_version<'3.13' # homeassistant.components.iaqualink h2==4.1.0 @@ -1148,7 +1148,7 @@ httplib2==0.20.4 huawei-lte-api==1.10.0 # homeassistant.components.huum -huum==0.7.11 +huum==0.7.11;python_version<'3.13' # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a13f27c3b9828..2843974cc9a6f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -904,7 +904,7 @@ growattServer==1.5.0 gspread==5.5.0 # homeassistant.components.profiler -guppy3==3.1.4.post1 +guppy3==3.1.4.post1;python_version<'3.13' # homeassistant.components.iaqualink h2==4.1.0 @@ -971,7 +971,7 @@ httplib2==0.20.4 huawei-lte-api==1.10.0 # homeassistant.components.huum -huum==0.7.11 +huum==0.7.11;python_version<'3.13' # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/tests/components/huum/conftest.py b/tests/components/huum/conftest.py new file mode 100644 index 0000000000000..da66cc54b72ea --- /dev/null +++ b/tests/components/huum/conftest.py @@ -0,0 +1,6 @@ +"""Skip test collection for Python 3.13.""" + +import sys + +if sys.version_info >= (3, 13): + collect_ignore_glob = ["test_*.py"] diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 3f0e0b92056ac..37940df437bd3 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -5,6 +5,7 @@ import logging import os from pathlib import Path +import sys from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory @@ -70,6 +71,9 @@ def _mock_path(filename: str) -> str: await hass.async_block_till_done() +@pytest.mark.skipif( + sys.version_info >= (3, 13), reason="not yet available on Python 3.13" +) async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: """Test we can setup and the service is registered.""" test_dir = tmp_path / "profiles" @@ -101,6 +105,24 @@ def _mock_path(filename: str) -> str: await hass.async_block_till_done() +@pytest.mark.skipif(sys.version_info < (3, 13), reason="still works on python 3.12") +async def test_memory_usage_py313(hass: HomeAssistant, tmp_path: Path) -> None: + """Test raise an error on python3.13.""" + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, SERVICE_MEMORY) + with pytest.raises( + HomeAssistantError, + match="Memory profiling is not supported on Python 3.13. Please use Python 3.12.", + ): + await hass.services.async_call( + DOMAIN, SERVICE_MEMORY, {CONF_SECONDS: 0.000001}, blocking=True + ) + + async def test_object_growth_logging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, From 7515deddab3ebd18b43bc0cd35fa313ee52ce660 Mon Sep 17 00:00:00 2001 From: dotvav Date: Sun, 10 Nov 2024 11:48:52 +0100 Subject: [PATCH 0330/1070] Palazzetti DHCP Discovery (#129731) Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- .../components/palazzetti/config_flow.py | 41 ++++++++++++++++ .../components/palazzetti/manifest.json | 9 ++++ .../components/palazzetti/strings.json | 3 ++ homeassistant/generated/dhcp.py | 9 ++++ .../components/palazzetti/test_config_flow.py | 48 ++++++++++++++++++- 5 files changed, 109 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/palazzetti/config_flow.py b/homeassistant/components/palazzetti/config_flow.py index a58461b9ca7d0..fe892b6624dd7 100644 --- a/homeassistant/components/palazzetti/config_flow.py +++ b/homeassistant/components/palazzetti/config_flow.py @@ -6,6 +6,7 @@ from pypalazzetti.exceptions import CommunicationError import voluptuous as vol +from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers import device_registry as dr @@ -16,6 +17,8 @@ class PalazzettiConfigFlow(ConfigFlow, domain=DOMAIN): """Palazzetti config flow.""" + _discovered_device: PalazzettiClient + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -48,3 +51,41 @@ async def async_step_user( data_schema=vol.Schema({vol.Required(CONF_HOST): str}), errors=errors, ) + + async def async_step_dhcp( + self, discovery_info: dhcp.DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + + LOGGER.debug( + "DHCP discovery detected Palazzetti: %s", discovery_info.macaddress + ) + + await self.async_set_unique_id(dr.format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured() + self._discovered_device = PalazzettiClient(hostname=discovery_info.ip) + try: + await self._discovered_device.connect() + except CommunicationError: + return self.async_abort(reason="cannot_connect") + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self._discovered_device.name, + data={CONF_HOST: self._discovered_device.host}, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + "name": self._discovered_device.name, + "host": self._discovered_device.host, + }, + ) diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index a1b25f563bf42..552289ebeacb4 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -3,6 +3,15 @@ "name": "Palazzetti", "codeowners": ["@dotvav"], "config_flow": true, + "dhcp": [ + { + "hostname": "connbox*", + "macaddress": "40F3857*" + }, + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index fdf50f29f0dba..cc10c8ed5c63a 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -8,6 +8,9 @@ "data_description": { "host": "The host name or the IP address of the Palazzetti CBox" } + }, + "discovery_confirm": { + "description": "Do you want to add {name} ({host}) to Home Assistant?" } }, "abort": { diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index cd20b88b285df..7dacf9a0bcabb 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -379,6 +379,15 @@ "hostname": "gateway*", "macaddress": "F8811A*", }, + { + "domain": "palazzetti", + "hostname": "connbox*", + "macaddress": "40F3857*", + }, + { + "domain": "palazzetti", + "registered_devices": True, + }, { "domain": "powerwall", "hostname": "1118431-*", diff --git a/tests/components/palazzetti/test_config_flow.py b/tests/components/palazzetti/test_config_flow.py index 960ad7a1184bd..03c56c33d0ce3 100644 --- a/tests/components/palazzetti/test_config_flow.py +++ b/tests/components/palazzetti/test_config_flow.py @@ -4,8 +4,9 @@ from pypalazzetti.exceptions import CommunicationError +from homeassistant.components import dhcp from homeassistant.components.palazzetti.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -92,3 +93,48 @@ async def test_duplicate( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_dhcp_flow( + hass: HomeAssistant, mock_palazzetti_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the DHCP flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=dhcp.DhcpServiceInfo( + hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" + ), + context={"source": SOURCE_DHCP}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stove" + assert result["result"].unique_id == "11:22:33:44:55:66" + + +async def test_dhcp_flow_error( + hass: HomeAssistant, mock_palazzetti_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the DHCP flow.""" + mock_palazzetti_client.connect.side_effect = CommunicationError() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=dhcp.DhcpServiceInfo( + hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" + ), + context={"source": SOURCE_DHCP}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" From 7925007ab45050aa25c4a9c9f5819d83a8c6e03e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 10 Nov 2024 12:00:45 +0100 Subject: [PATCH 0331/1070] Bump psutil to 6.1.0 (#130254) --- homeassistant/components/systemmonitor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 236f25bb1ed41..4c6ae0653d307 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.0.0"] + "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7813e5fc7331c..e09673d4534cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1651,7 +1651,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==6.0.0 +psutil==6.1.0 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2843974cc9a6f..c3db5b00adfcf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1349,7 +1349,7 @@ prometheus-client==0.21.0 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==6.0.0 +psutil==6.1.0 # homeassistant.components.androidtv pure-python-adb[async]==0.3.0.dev0 From e8dc62411a1f0d5bc57412ca4f31388f02720801 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 10 Nov 2024 03:01:59 -0800 Subject: [PATCH 0332/1070] Improve nest camera stream expiration to be defensive against errors (#130265) --- homeassistant/components/nest/camera.py | 176 ++++++++++++++---------- tests/components/nest/test_camera.py | 44 ++++++ 2 files changed, 144 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 2bee54df3dd79..4cb88e6364166 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -2,9 +2,9 @@ from __future__ import annotations -from abc import ABC, abstractmethod +from abc import ABC import asyncio -from collections.abc import Callable +from collections.abc import Awaitable, Callable import datetime import functools import logging @@ -46,6 +46,11 @@ # Used to schedule an alarm to refresh the stream before expiration STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30) +# Refresh streams with a bounded interval and backoff on failure +MIN_REFRESH_BACKOFF_INTERVAL = datetime.timedelta(minutes=1) +MAX_REFRESH_BACKOFF_INTERVAL = datetime.timedelta(minutes=10) +BACKOFF_MULTIPLIER = 1.5 + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -67,6 +72,68 @@ async def async_setup_entry( async_add_entities(entities) +class StreamRefresh: + """Class that will refresh an expiring stream. + + This class will schedule an alarm for the next expiration time of a stream. + When the alarm fires, it runs the provided `refresh_cb` to extend the + lifetime of the stream and return a new expiration time. + + A simple backoff will be applied when the refresh callback fails. + """ + + def __init__( + self, + hass: HomeAssistant, + expires_at: datetime.datetime, + refresh_cb: Callable[[], Awaitable[datetime.datetime | None]], + ) -> None: + """Initialize StreamRefresh.""" + self._hass = hass + self._unsub: Callable[[], None] | None = None + self._min_refresh_interval = MIN_REFRESH_BACKOFF_INTERVAL + self._refresh_cb = refresh_cb + self._schedule_stream_refresh(expires_at - STREAM_EXPIRATION_BUFFER) + + def unsub(self) -> None: + """Invalidates the stream.""" + if self._unsub: + self._unsub() + + async def _handle_refresh(self, _: datetime.datetime) -> None: + """Alarm that fires to check if the stream should be refreshed.""" + self._unsub = None + try: + expires_at = await self._refresh_cb() + except ApiException as err: + _LOGGER.debug("Failed to refresh stream: %s", err) + # Increase backoff until the max backoff interval is reached + self._min_refresh_interval = min( + self._min_refresh_interval * BACKOFF_MULTIPLIER, + MAX_REFRESH_BACKOFF_INTERVAL, + ) + refresh_time = utcnow() + self._min_refresh_interval + else: + if expires_at is None: + return + self._min_refresh_interval = MIN_REFRESH_BACKOFF_INTERVAL # Reset backoff + # Defend against invalid stream expiration time in the past + refresh_time = max( + expires_at - STREAM_EXPIRATION_BUFFER, + utcnow() + self._min_refresh_interval, + ) + self._schedule_stream_refresh(refresh_time) + + def _schedule_stream_refresh(self, refresh_time: datetime.datetime) -> None: + """Schedules an alarm to refresh any streams before expiration.""" + _LOGGER.debug("Scheduling stream refresh for %s", refresh_time) + self._unsub = async_track_point_in_utc_time( + self._hass, + self._handle_refresh, + refresh_time, + ) + + class NestCameraBaseEntity(Camera, ABC): """Devices that support cameras.""" @@ -86,41 +153,6 @@ def __init__(self, device: Device) -> None: self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 # The API "name" field is a unique device identifier. self._attr_unique_id = f"{self._device.name}-camera" - self._stream_refresh_unsub: Callable[[], None] | None = None - - @abstractmethod - def _stream_expires_at(self) -> datetime.datetime | None: - """Next time when a stream expires.""" - - @abstractmethod - async def _async_refresh_stream(self) -> None: - """Refresh any stream to extend expiration time.""" - - def _schedule_stream_refresh(self) -> None: - """Schedules an alarm to refresh any streams before expiration.""" - if self._stream_refresh_unsub is not None: - self._stream_refresh_unsub() - - expiration_time = self._stream_expires_at() - if not expiration_time: - return - refresh_time = expiration_time - STREAM_EXPIRATION_BUFFER - _LOGGER.debug("Scheduled next stream refresh for %s", refresh_time) - - self._stream_refresh_unsub = async_track_point_in_utc_time( - self.hass, - self._handle_stream_refresh, - refresh_time, - ) - - async def _handle_stream_refresh(self, _: datetime.datetime) -> None: - """Alarm that fires to check if the stream should be refreshed.""" - _LOGGER.debug("Examining streams to refresh") - self._stream_refresh_unsub = None - try: - await self._async_refresh_stream() - finally: - self._schedule_stream_refresh() async def async_added_to_hass(self) -> None: """Run when entity is added to register update signal handler.""" @@ -128,12 +160,6 @@ async def async_added_to_hass(self) -> None: self._device.add_update_listener(self.async_write_ha_state) ) - async def async_will_remove_from_hass(self) -> None: - """Invalidates the RTSP token when unloaded.""" - await super().async_will_remove_from_hass() - if self._stream_refresh_unsub: - self._stream_refresh_unsub() - class NestRTSPEntity(NestCameraBaseEntity): """Nest cameras that use RTSP.""" @@ -146,6 +172,7 @@ def __init__(self, device: Device) -> None: super().__init__(device) self._create_stream_url_lock = asyncio.Lock() self._rtsp_live_stream_trait = device.traits[CameraLiveStreamTrait.NAME] + self._refresh_unsub: Callable[[], None] | None = None @property def use_stream_for_stills(self) -> bool: @@ -173,20 +200,21 @@ async def stream_source(self) -> str | None: ) except ApiException as err: raise HomeAssistantError(f"Nest API error: {err}") from err - self._schedule_stream_refresh() + refresh = StreamRefresh( + self.hass, + self._rtsp_stream.expires_at, + self._async_refresh_stream, + ) + self._refresh_unsub = refresh.unsub assert self._rtsp_stream if self._rtsp_stream.expires_at < utcnow(): _LOGGER.warning("Stream already expired") return self._rtsp_stream.rtsp_stream_url - def _stream_expires_at(self) -> datetime.datetime | None: - """Next time when a stream expires.""" - return self._rtsp_stream.expires_at if self._rtsp_stream else None - - async def _async_refresh_stream(self) -> None: + async def _async_refresh_stream(self) -> datetime.datetime | None: """Refresh stream to extend expiration time.""" if not self._rtsp_stream: - return + return None _LOGGER.debug("Extending RTSP stream") try: self._rtsp_stream = await self._rtsp_stream.extend_rtsp_stream() @@ -197,14 +225,17 @@ async def _async_refresh_stream(self) -> None: if self.stream: await self.stream.stop() self.stream = None - return + return None # Update the stream worker with the latest valid url if self.stream: self.stream.update_source(self._rtsp_stream.rtsp_stream_url) + return self._rtsp_stream.expires_at async def async_will_remove_from_hass(self) -> None: """Invalidates the RTSP token when unloaded.""" await super().async_will_remove_from_hass() + if self._refresh_unsub is not None: + self._refresh_unsub() if self._rtsp_stream: try: await self._rtsp_stream.stop_stream() @@ -220,37 +251,23 @@ def __init__(self, device: Device) -> None: """Initialize the camera.""" super().__init__(device) self._webrtc_sessions: dict[str, WebRtcStream] = {} + self._refresh_unsub: dict[str, Callable[[], None]] = {} @property def frontend_stream_type(self) -> StreamType | None: """Return the type of stream supported by this camera.""" return StreamType.WEB_RTC - def _stream_expires_at(self) -> datetime.datetime | None: - """Next time when a stream expires.""" - if not self._webrtc_sessions: - return None - return min(stream.expires_at for stream in self._webrtc_sessions.values()) - - async def _async_refresh_stream(self) -> None: + async def _async_refresh_stream(self, session_id: str) -> datetime.datetime | None: """Refresh stream to extend expiration time.""" - now = utcnow() - for session_id, webrtc_stream in list(self._webrtc_sessions.items()): - if session_id not in self._webrtc_sessions: - continue - if now < (webrtc_stream.expires_at - STREAM_EXPIRATION_BUFFER): - _LOGGER.debug( - "Stream does not yet expire: %s", webrtc_stream.expires_at - ) - continue - _LOGGER.debug("Extending WebRTC stream %s", webrtc_stream.media_session_id) - try: - webrtc_stream = await webrtc_stream.extend_stream() - except ApiException as err: - _LOGGER.debug("Failed to extend stream: %s", err) - else: - if session_id in self._webrtc_sessions: - self._webrtc_sessions[session_id] = webrtc_stream + if not (webrtc_stream := self._webrtc_sessions.get(session_id)): + return None + _LOGGER.debug("Extending WebRTC stream %s", webrtc_stream.media_session_id) + webrtc_stream = await webrtc_stream.extend_stream() + if session_id in self._webrtc_sessions: + self._webrtc_sessions[session_id] = webrtc_stream + return webrtc_stream.expires_at + return None async def async_camera_image( self, width: int | None = None, height: int | None = None @@ -278,7 +295,12 @@ async def async_handle_async_webrtc_offer( ) self._webrtc_sessions[session_id] = stream send_message(WebRTCAnswer(stream.answer_sdp)) - self._schedule_stream_refresh() + refresh = StreamRefresh( + self.hass, + stream.expires_at, + functools.partial(self._async_refresh_stream, session_id), + ) + self._refresh_unsub[session_id] = refresh.unsub @callback def close_webrtc_session(self, session_id: str) -> None: @@ -287,6 +309,8 @@ def close_webrtc_session(self, session_id: str) -> None: _LOGGER.debug( "Closing WebRTC session %s, %s", session_id, stream.media_session_id ) + unsub = self._refresh_unsub.pop(session_id) + unsub() async def stop_stream() -> None: try: diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 500dbc0f46f05..029879f1413c2 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -483,6 +483,50 @@ async def test_stream_response_already_expired( assert stream_source == "rtsp://some/url?auth=g.2.streamingToken" +async def test_extending_stream_already_expired( + hass: HomeAssistant, + auth: FakeAuth, + setup_platform: PlatformSetup, + camera_device: None, +) -> None: + """Test a API response when extending the stream returns an expired stream url.""" + now = utcnow() + stream_1_expiration = now + datetime.timedelta(seconds=180) + stream_2_expiration = now + datetime.timedelta(seconds=30) # Will be in the past + stream_3_expiration = now + datetime.timedelta(seconds=600) + auth.responses = [ + make_stream_url_response(stream_1_expiration, token_num=1), + make_stream_url_response(stream_2_expiration, token_num=2), + make_stream_url_response(stream_3_expiration, token_num=3), + ] + await setup_platform() + + assert len(hass.states.async_all()) == 1 + cam = hass.states.get("camera.my_camera") + assert cam is not None + assert cam.state == CameraState.STREAMING + + # The stream is expired, but we return it anyway + stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") + assert stream_source == "rtsp://some/url?auth=g.1.streamingToken" + + # Jump to when the stream will be refreshed + await fire_alarm(hass, now + datetime.timedelta(seconds=160)) + stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") + assert stream_source == "rtsp://some/url?auth=g.2.streamingToken" + + # The stream will have expired in the past, but 1 minute min refresh interval is applied. + # The stream token is not updated. + await fire_alarm(hass, now + datetime.timedelta(seconds=170)) + stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") + assert stream_source == "rtsp://some/url?auth=g.2.streamingToken" + + # Now go past the min update interval and the stream is refreshed + await fire_alarm(hass, now + datetime.timedelta(seconds=225)) + stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") + assert stream_source == "rtsp://some/url?auth=g.3.streamingToken" + + async def test_camera_removed( hass: HomeAssistant, auth: FakeAuth, From 7d2d6a82b0fcaee12bdcb702c46cca2c96be6cea Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sun, 10 Nov 2024 12:02:55 +0100 Subject: [PATCH 0333/1070] Allow dynamic max preset in linkplay play preset (#130160) --- homeassistant/components/linkplay/media_player.py | 5 ++++- homeassistant/components/linkplay/services.yaml | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 983d8777a6a31..a625412852eef 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -291,7 +291,10 @@ async def async_play_media( @exception_wrap async def async_play_preset(self, preset_number: int) -> None: """Play preset number.""" - await self._bridge.player.play_preset(preset_number) + try: + await self._bridge.player.play_preset(preset_number) + except ValueError as err: + raise HomeAssistantError(err) from err @exception_wrap async def async_join_players(self, group_members: list[str]) -> None: diff --git a/homeassistant/components/linkplay/services.yaml b/homeassistant/components/linkplay/services.yaml index 20bc47be7a774..0d7335a28c85c 100644 --- a/homeassistant/components/linkplay/services.yaml +++ b/homeassistant/components/linkplay/services.yaml @@ -11,5 +11,4 @@ play_preset: selector: number: min: 1 - max: 10 mode: box From d0ad834d93643dab7f8e91aa358be05a20e2ed65 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 10 Nov 2024 12:14:13 +0100 Subject: [PATCH 0334/1070] Move manual trigger entity tests (#130134) --- .../test_trigger_template_entity.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{components/template/test_manual_trigger_entity.py => helpers/test_trigger_template_entity.py} (100%) diff --git a/tests/components/template/test_manual_trigger_entity.py b/tests/helpers/test_trigger_template_entity.py similarity index 100% rename from tests/components/template/test_manual_trigger_entity.py rename to tests/helpers/test_trigger_template_entity.py From 0677bba5bd7fdfecf2baef4c962fc0c87176468e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 10 Nov 2024 12:26:07 +0100 Subject: [PATCH 0335/1070] Add actions for scoring habits and rewards in Habitica (#129605) --- homeassistant/components/habitica/const.py | 4 + homeassistant/components/habitica/icons.json | 6 + homeassistant/components/habitica/services.py | 74 +++++++- .../components/habitica/services.yaml | 19 +- .../components/habitica/strings.json | 39 +++- tests/components/habitica/conftest.py | 2 +- tests/components/habitica/fixtures/tasks.json | 3 +- tests/components/habitica/test_services.py | 171 +++++++++++++++++- 8 files changed, 311 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 2107386c709a7..ae98cb13dcb52 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -25,6 +25,7 @@ ATTR_CONFIG_ENTRY = "config_entry" ATTR_SKILL = "skill" ATTR_TASK = "task" +ATTR_DIRECTION = "direction" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" SERVICE_ACCEPT_QUEST = "accept_quest" @@ -32,6 +33,9 @@ SERVICE_ABORT_QUEST = "abort_quest" SERVICE_REJECT_QUEST = "reject_quest" SERVICE_LEAVE_QUEST = "leave_quest" +SERVICE_SCORE_HABIT = "score_habit" +SERVICE_SCORE_REWARD = "score_reward" + WARRIOR = "warrior" ROGUE = "rogue" HEALER = "healer" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index bf59aa78d5c62..d33b9c60c966b 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -181,6 +181,12 @@ }, "start_quest": { "service": "mdi:script-text-key" + }, + "score_habit": { + "service": "mdi:counter" + }, + "score_reward": { + "service": "mdi:sack" } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 9bea15aae712f..df62067569992 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -25,6 +25,7 @@ ATTR_ARGS, ATTR_CONFIG_ENTRY, ATTR_DATA, + ATTR_DIRECTION, ATTR_PATH, ATTR_SKILL, ATTR_TASK, @@ -37,6 +38,8 @@ SERVICE_CAST_SKILL, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, + SERVICE_SCORE_HABIT, + SERVICE_SCORE_REWARD, SERVICE_START_QUEST, ) from .types import HabiticaConfigEntry @@ -65,6 +68,13 @@ vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), } ) +SERVICE_SCORE_TASK_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_TASK): cv.string, + vol.Optional(ATTR_DIRECTION): cv.string, + } +) def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: @@ -82,7 +92,7 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: return entry -def async_setup_services(hass: HomeAssistant) -> None: +def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Set up services for Habitica integration.""" async def handle_api_call(call: ServiceCall) -> None: @@ -223,6 +233,53 @@ async def manage_quests(call: ServiceCall) -> ServiceResponse: supports_response=SupportsResponse.ONLY, ) + async def score_task(call: ServiceCall) -> ServiceResponse: + """Score a task action.""" + entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + try: + task_id, task_value = next( + (task["id"], task.get("value")) + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (task["id"], task.get("alias")) + or call.data[ATTR_TASK] == task["text"] + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e + + try: + response: dict[str, Any] = ( + await coordinator.api.tasks[task_id] + .score[call.data.get(ATTR_DIRECTION, "up")] + .post() + ) + except ClientResponseError as e: + if e.status == HTTPStatus.TOO_MANY_REQUESTS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + if e.status == HTTPStatus.UNAUTHORIZED and task_value is not None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_enough_gold", + translation_placeholders={ + "gold": f"{coordinator.data.user["stats"]["gp"]:.2f} GP", + "cost": f"{task_value} GP", + }, + ) from e + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + ) from e + else: + await coordinator.async_request_refresh() + return response + hass.services.async_register( DOMAIN, SERVICE_API_CALL, @@ -237,3 +294,18 @@ async def manage_quests(call: ServiceCall) -> ServiceResponse: schema=SERVICE_CAST_SKILL_SCHEMA, supports_response=SupportsResponse.ONLY, ) + + hass.services.async_register( + DOMAIN, + SERVICE_SCORE_HABIT, + score_task, + schema=SERVICE_SCORE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SCORE_REWARD, + score_task, + schema=SERVICE_SCORE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 955a0779cd326..b539f6c65bf92 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -33,7 +33,7 @@ cast_skill: - "fireball" mode: dropdown translation_key: "skill_select" - task: + task: &task required: true selector: text: @@ -55,3 +55,20 @@ abort_quest: leave_quest: fields: config_entry: *config_entry +score_habit: + fields: + config_entry: *config_entry + task: *task + direction: + required: true + selector: + select: + options: + - value: up + label: "➕" + - value: down + label: "➖" +score_reward: + fields: + config_entry: *config_entry + task: *task diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 42f1dbee459f5..fd793675a5c5c 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -301,6 +301,9 @@ "not_enough_mana": { "message": "Unable to cast skill, not enough mana. Your character has {mana}, but the skill costs {cost}." }, + "not_enough_gold": { + "message": "Unable to buy reward, not enough gold. Your character has {gold}, but the reward costs {cost}." + }, "skill_not_found": { "message": "Unable to cast skill, your character does not have the skill or spell {skill}." }, @@ -311,7 +314,7 @@ "message": "The selected character is currently not loaded or disabled in Home Assistant." }, "task_not_found": { - "message": "Unable to cast skill, could not find the task {task}" + "message": "Unable to complete action, could not find the task {task}" }, "quest_action_unallowed": { "message": "Action not allowed, only quest leader or group leader can perform this action" @@ -350,7 +353,7 @@ "description": "Use a skill or spell from your Habitica character on a specific task to affect its progress or status.", "fields": { "config_entry": { - "name": "Select character", + "name": "[%key:component::habitica::common::config_entry_name%]", "description": "Choose the Habitica character to cast the skill." }, "skill": { @@ -422,6 +425,38 @@ "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]" } } + }, + "score_habit": { + "name": "Track a habit", + "description": "Increase the positive or negative streak of a habit to track its progress.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica character tracking your habit." + }, + "task": { + "name": "Habit name", + "description": "The name (or task ID) of the Habitica habit." + }, + "direction": { + "name": "Reward or loss", + "description": "Is it positive or negative progress you want to track for your habit." + } + } + }, + "score_reward": { + "name": "Buy a reward", + "description": "Reward yourself and buy one of your custom rewards with gold earned by fulfilling tasks.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica character buying the reward." + }, + "task": { + "name": "Reward name", + "description": "The name (or task ID) of the custom reward." + } + } } }, "selector": { diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 03b76561abc18..8d729f4358fcd 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -34,7 +34,7 @@ def mock_called_with( ( call for call in mock_client.mock_calls - if call[0] == method.upper() and call[1] == URL(url) + if call[0].upper() == method.upper() and call[1] == URL(url) ), None, ) diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 768768b44788d..2e8305283d083 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -121,7 +121,8 @@ "createdAt": "2024-07-07T17:51:53.264Z", "updatedAt": "2024-07-12T09:58:45.438Z", "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", - "id": "e97659e0-2c42-4599-a7bb-00282adc410d" + "id": "e97659e0-2c42-4599-a7bb-00282adc410d", + "alias": "create_a_task" }, { "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 390077e220586..403779bcbfbb5 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -9,6 +9,7 @@ from homeassistant.components.habitica.const import ( ATTR_CONFIG_ENTRY, + ATTR_DIRECTION, ATTR_SKILL, ATTR_TASK, DEFAULT_URL, @@ -19,6 +20,8 @@ SERVICE_CAST_SKILL, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, + SERVICE_SCORE_HABIT, + SERVICE_SCORE_REWARD, SERVICE_START_QUEST, ) from homeassistant.config_entries import ConfigEntryState @@ -168,7 +171,7 @@ async def test_cast_skill( }, HTTPStatus.OK, ServiceValidationError, - "Unable to cast skill, could not find the task 'task-not-found", + "Unable to complete action, could not find the task 'task-not-found'", ), ( { @@ -377,3 +380,169 @@ async def test_handle_quests_exceptions( return_response=True, blocking=True, ) + + +@pytest.mark.parametrize( + ("service", "service_data", "task_id"), + [ + ( + SERVICE_SCORE_HABIT, + { + ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", + ATTR_DIRECTION: "up", + }, + "e97659e0-2c42-4599-a7bb-00282adc410d", + ), + ( + SERVICE_SCORE_HABIT, + { + ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", + ATTR_DIRECTION: "down", + }, + "e97659e0-2c42-4599-a7bb-00282adc410d", + ), + ( + SERVICE_SCORE_REWARD, + { + ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", + }, + "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", + ), + ( + SERVICE_SCORE_HABIT, + { + ATTR_TASK: "Füge eine Aufgabe zu Habitica hinzu", + ATTR_DIRECTION: "up", + }, + "e97659e0-2c42-4599-a7bb-00282adc410d", + ), + ( + SERVICE_SCORE_HABIT, + { + ATTR_TASK: "create_a_task", + ATTR_DIRECTION: "up", + }, + "e97659e0-2c42-4599-a7bb-00282adc410d", + ), + ], + ids=[ + "habit score up", + "habit score down", + "buy reward", + "match task by name", + "match task by alias", + ], +) +async def test_score_task( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service: str, + service_data: dict[str, Any], + task_id: str, +) -> None: + """Test Habitica score task action.""" + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/tasks/{task_id}/score/{service_data.get(ATTR_DIRECTION, "up")}", + json={"success": True, "data": {}}, + ) + + await hass.services.async_call( + DOMAIN, + service, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + + assert mock_called_with( + mock_habitica, + "post", + f"{DEFAULT_URL}/api/v3/tasks/{task_id}/score/{service_data.get(ATTR_DIRECTION, "up")}", + ) + + +@pytest.mark.parametrize( + ( + "service_data", + "http_status", + "expected_exception", + "expected_exception_msg", + ), + [ + ( + { + ATTR_TASK: "task does not exist", + ATTR_DIRECTION: "up", + }, + HTTPStatus.OK, + ServiceValidationError, + "Unable to complete action, could not find the task 'task does not exist'", + ), + ( + { + ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", + ATTR_DIRECTION: "up", + }, + HTTPStatus.TOO_MANY_REQUESTS, + ServiceValidationError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + { + ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", + ATTR_DIRECTION: "up", + }, + HTTPStatus.BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + { + ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", + ATTR_DIRECTION: "up", + }, + HTTPStatus.UNAUTHORIZED, + HomeAssistantError, + "Unable to buy reward, not enough gold. Your character has 137.63 GP, but the reward costs 10 GP", + ), + ], +) +@pytest.mark.usefixtures("mock_habitica") +async def test_score_task_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service_data: dict[str, Any], + http_status: HTTPStatus, + expected_exception: Exception, + expected_exception_msg: str, +) -> None: + """Test Habitica score task action exceptions.""" + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/tasks/e97659e0-2c42-4599-a7bb-00282adc410d/score/up", + json={"success": True, "data": {}}, + status=http_status, + ) + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/tasks/5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b/score/up", + json={"success": True, "data": {}}, + status=http_status, + ) + + with pytest.raises(expected_exception, match=expected_exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_SCORE_HABIT, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) From 433321136de91051ebc879c2f4d03cb9d8454a22 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 10 Nov 2024 12:28:18 +0100 Subject: [PATCH 0336/1070] Remove incorrect mark fixture in nordpool (#130278) --- tests/components/nordpool/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/nordpool/conftest.py b/tests/components/nordpool/conftest.py index 305179c531ada..d1c1972c568fe 100644 --- a/tests/components/nordpool/conftest.py +++ b/tests/components/nordpool/conftest.py @@ -23,7 +23,6 @@ from tests.test_util.aiohttp import AiohttpClientMocker -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") @pytest.fixture async def load_int( hass: HomeAssistant, get_data: DeliveryPeriodData From a1a08f77555c58ce0fac689a04470a17b4cc78b0 Mon Sep 17 00:00:00 2001 From: Nicholas Romyn <13968908+nromyn@users.noreply.github.com> Date: Sun, 10 Nov 2024 08:13:01 -0500 Subject: [PATCH 0337/1070] Ecobee aux cutover threshold (#129474) * removing extra blank space * Adding EcobeeAuxCutoverThreshold First pass. * minor reorg and changes; testing local check-in * Adding entity, setting device class and name * Bumping max value slightly to hopefully accomodate celsius, setting numberMode=box * fixing the entity name for aux cutover threshold * Combined async_add_entities * Using a list comprehension Co-authored-by: Joost Lekkerkerker * fixing stuff with listcomprehension * exchanging call to list.append() to extend with list comprehension * Updating the class name and the entity name to match the device UI. Removing abbreviations from entity names * Fixing tests to match new entity names * respecting 88 column limit * Formatting * Adding test coverage for update/set compressorMinTemp values --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/ecobee/number.py | 84 ++++++++++++++++--- homeassistant/components/ecobee/strings.json | 9 +- .../ecobee/fixtures/ecobee-data.json | 1 + tests/components/ecobee/test_number.py | 51 ++++++++++- tests/components/ecobee/test_switch.py | 2 +- 5 files changed, 129 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py index ab09407903db9..ed3744bf11edd 100644 --- a/homeassistant/components/ecobee/number.py +++ b/homeassistant/components/ecobee/number.py @@ -6,9 +6,14 @@ from dataclasses import dataclass import logging -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTime +from homeassistant.const import UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -54,21 +59,30 @@ async def async_setup_entry( ) -> None: """Set up the ecobee thermostat number entity.""" data: EcobeeData = hass.data[DOMAIN] - _LOGGER.debug("Adding min time ventilators numbers (if present)") - async_add_entities( + assert data is not None + + entities: list[NumberEntity] = [ + EcobeeVentilatorMinTime(data, index, numbers) + for index, thermostat in enumerate(data.ecobee.thermostats) + if thermostat["settings"]["ventilatorType"] != "none" + for numbers in VENTILATOR_NUMBERS + ] + + _LOGGER.debug("Adding compressor min temp number (if present)") + entities.extend( ( - EcobeeVentilatorMinTime(data, index, numbers) + EcobeeCompressorMinTemp(data, index) for index, thermostat in enumerate(data.ecobee.thermostats) - if thermostat["settings"]["ventilatorType"] != "none" - for numbers in VENTILATOR_NUMBERS - ), - True, + if thermostat["settings"]["hasHeatPump"] + ) ) + async_add_entities(entities, True) + class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity): - """A number class, representing min time for an ecobee thermostat with ventilator attached.""" + """A number class, representing min time for an ecobee thermostat with ventilator attached.""" entity_description: EcobeeNumberEntityDescription @@ -105,3 +119,53 @@ def set_native_value(self, value: float) -> None: """Set new ventilator Min On Time value.""" self.entity_description.set_fn(self.data, self.thermostat_index, int(value)) self.update_without_throttle = True + + +class EcobeeCompressorMinTemp(EcobeeBaseEntity, NumberEntity): + """Minimum outdoor temperature at which the compressor will operate. + + This applies more to air source heat pumps than geothermal. This serves as a safety + feature (compressors have a minimum operating temperature) as well as + providing the ability to choose fuel in a dual-fuel system (i.e. choose between + electrical heat pump and fossil auxiliary heat depending on Time of Use, Solar, + etc.). + Note that python-ecobee-api refers to this as Aux Cutover Threshold, but Ecobee + uses Compressor Protection Min Temp. + """ + + _attr_device_class = NumberDeviceClass.TEMPERATURE + _attr_has_entity_name = True + _attr_icon = "mdi:thermometer-off" + _attr_mode = NumberMode.BOX + _attr_native_min_value = -25 + _attr_native_max_value = 66 + _attr_native_step = 5 + _attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT + _attr_translation_key = "compressor_protection_min_temp" + + def __init__( + self, + data: EcobeeData, + thermostat_index: int, + ) -> None: + """Initialize ecobee compressor min temperature.""" + super().__init__(data, thermostat_index) + self._attr_unique_id = f"{self.base_unique_id}_compressor_protection_min_temp" + self.update_without_throttle = False + + async def async_update(self) -> None: + """Get the latest state from the thermostat.""" + if self.update_without_throttle: + await self.data.update(no_throttle=True) + self.update_without_throttle = False + else: + await self.data.update() + + self._attr_native_value = ( + (self.thermostat["settings"]["compressorProtectionMinTemp"]) / 10 + ) + + def set_native_value(self, value: float) -> None: + """Set new compressor minimum temperature.""" + self.data.ecobee.set_aux_cutover_threshold(self.thermostat_index, value) + self.update_without_throttle = True diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 18929cb45de86..8c636bd9b04fe 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -33,15 +33,18 @@ }, "number": { "ventilator_min_type_home": { - "name": "Ventilator min time home" + "name": "Ventilator minimum time home" }, "ventilator_min_type_away": { - "name": "Ventilator min time away" + "name": "Ventilator minimum time away" + }, + "compressor_protection_min_temp": { + "name": "Compressor minimum temperature" } }, "switch": { "aux_heat_only": { - "name": "Aux heat only" + "name": "Auxiliary heat only" } } }, diff --git a/tests/components/ecobee/fixtures/ecobee-data.json b/tests/components/ecobee/fixtures/ecobee-data.json index 1573484795f30..e0e82d688633d 100644 --- a/tests/components/ecobee/fixtures/ecobee-data.json +++ b/tests/components/ecobee/fixtures/ecobee-data.json @@ -160,6 +160,7 @@ "hasHumidifier": true, "humidifierMode": "manual", "hasHeatPump": true, + "compressorProtectionMinTemp": 100, "humidity": "30" }, "equipmentStatus": "fan", diff --git a/tests/components/ecobee/test_number.py b/tests/components/ecobee/test_number.py index 5b01fe8c5bae5..be65b6dbb3039 100644 --- a/tests/components/ecobee/test_number.py +++ b/tests/components/ecobee/test_number.py @@ -12,8 +12,8 @@ from .common import setup_platform -VENTILATOR_MIN_HOME_ID = "number.ecobee_ventilator_min_time_home" -VENTILATOR_MIN_AWAY_ID = "number.ecobee_ventilator_min_time_away" +VENTILATOR_MIN_HOME_ID = "number.ecobee_ventilator_minimum_time_home" +VENTILATOR_MIN_AWAY_ID = "number.ecobee_ventilator_minimum_time_away" THERMOSTAT_ID = 0 @@ -26,7 +26,9 @@ async def test_ventilator_min_on_home_attributes(hass: HomeAssistant) -> None: assert state.attributes.get("min") == 0 assert state.attributes.get("max") == 60 assert state.attributes.get("step") == 5 - assert state.attributes.get("friendly_name") == "ecobee Ventilator min time home" + assert ( + state.attributes.get("friendly_name") == "ecobee Ventilator minimum time home" + ) assert state.attributes.get("unit_of_measurement") == UnitOfTime.MINUTES @@ -39,7 +41,9 @@ async def test_ventilator_min_on_away_attributes(hass: HomeAssistant) -> None: assert state.attributes.get("min") == 0 assert state.attributes.get("max") == 60 assert state.attributes.get("step") == 5 - assert state.attributes.get("friendly_name") == "ecobee Ventilator min time away" + assert ( + state.attributes.get("friendly_name") == "ecobee Ventilator minimum time away" + ) assert state.attributes.get("unit_of_measurement") == UnitOfTime.MINUTES @@ -77,3 +81,42 @@ async def test_set_min_time_away(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() mock_set_min_away_time.assert_called_once_with(THERMOSTAT_ID, target_value) + + +COMPRESSOR_MIN_TEMP_ID = "number.ecobee2_compressor_minimum_temperature" + + +async def test_compressor_protection_min_temp_attributes(hass: HomeAssistant) -> None: + """Test the compressor min temp value is correct. + + Ecobee runs in Fahrenheit; the test rig runs in Celsius. Conversions are necessary. + """ + await setup_platform(hass, NUMBER_DOMAIN) + + state = hass.states.get(COMPRESSOR_MIN_TEMP_ID) + assert state.state == "-12.2" + assert ( + state.attributes.get("friendly_name") + == "ecobee2 Compressor minimum temperature" + ) + + +async def test_set_compressor_protection_min_temp(hass: HomeAssistant) -> None: + """Test the number can set minimum compressor operating temp. + + Ecobee runs in Fahrenheit; the test rig runs in Celsius. Conversions are necessary + """ + target_value = 0 + with patch( + "homeassistant.components.ecobee.Ecobee.set_aux_cutover_threshold" + ) as mock_set_compressor_min_temp: + await setup_platform(hass, NUMBER_DOMAIN) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: COMPRESSOR_MIN_TEMP_ID, ATTR_VALUE: target_value}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_compressor_min_temp.assert_called_once_with(1, 32) diff --git a/tests/components/ecobee/test_switch.py b/tests/components/ecobee/test_switch.py index 31c8ce8f72d44..b3c4c4f8296a4 100644 --- a/tests/components/ecobee/test_switch.py +++ b/tests/components/ecobee/test_switch.py @@ -118,7 +118,7 @@ async def test_turn_off_20min_ventilator(hass: HomeAssistant) -> None: mock_set_20min_ventilator.assert_called_once_with(THERMOSTAT_ID, False) -DEVICE_ID = "switch.ecobee2_aux_heat_only" +DEVICE_ID = "switch.ecobee2_auxiliary_heat_only" async def test_aux_heat_only_turn_on(hass: HomeAssistant) -> None: From 70211ab78e8ff5338d6220fc69ae3020d5205009 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Nov 2024 13:45:46 +0000 Subject: [PATCH 0338/1070] Bump aiohttp to 3.11.0rc0 (#130284) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0606cdd343563..3b3c50b3326fb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.11.0b5 +aiohttp==3.11.0rc0 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index c18f616abad22..143330f5adb52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.0b5", + "aiohttp==3.11.0rc0", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index d3c60eb302e0a..aa72a7d23ebed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.0b5 +aiohttp==3.11.0rc0 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From de391fa98bdf0826c364a6edb26460f11288ebb9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 14:58:44 +0100 Subject: [PATCH 0339/1070] Remove geniushub yaml support after 6 months of deprecation (#130285) * Remove geniushub YAML import after 6 moths of deprecation * Update homeassistant/components/geniushub/__init__.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/geniushub/__init__.py | 82 +------- .../components/geniushub/config_flow.py | 12 -- .../components/geniushub/test_config_flow.py | 182 +----------------- 3 files changed, 3 insertions(+), 273 deletions(-) diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index f3081e50289ff..9ca6ecfcfe068 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -9,7 +9,6 @@ from geniushubclient import GeniusHub import voluptuous as vol -from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, @@ -21,20 +20,12 @@ CONF_USERNAME, Platform, ) -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - HomeAssistant, - ServiceCall, - callback, -) -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import verify_domain_control -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -45,27 +36,6 @@ MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$" -CLOUD_API_SCHEMA = vol.Schema( - { - vol.Required(CONF_TOKEN): cv.string, - vol.Required(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP), - } -) - - -LOCAL_API_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP), - } -) - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Any(LOCAL_API_SCHEMA, CLOUD_API_SCHEMA)}, extra=vol.ALLOW_EXTRA -) - ATTR_ZONE_MODE = "mode" ATTR_DURATION = "duration" @@ -100,56 +70,6 @@ ] -async def _async_import(hass: HomeAssistant, base_config: ConfigType) -> None: - """Import a config entry from configuration.yaml.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=base_config[DOMAIN], - ) - if ( - result["type"] is FlowResultType.CREATE_ENTRY - or result["reason"] == "already_configured" - ): - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Genius Hub", - }, - ) - return - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result['reason']}", - breaks_in_ha_version="2024.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result['reason']}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Genius Hub", - }, - ) - - -async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: - """Set up a Genius Hub system.""" - if DOMAIN in base_config: - hass.async_create_task(_async_import(hass, base_config)) - return True - - type GeniusHubConfigEntry = ConfigEntry[GeniusBroker] diff --git a/homeassistant/components/geniushub/config_flow.py b/homeassistant/components/geniushub/config_flow.py index 601eac6c2f286..b106f9907bb43 100644 --- a/homeassistant/components/geniushub/config_flow.py +++ b/homeassistant/components/geniushub/config_flow.py @@ -13,7 +13,6 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -123,14 +122,3 @@ async def async_step_cloud_api( return self.async_show_form( step_id="cloud_api", errors=errors, data_schema=CLOUD_API_SCHEMA ) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import the yaml config.""" - if CONF_HOST in import_data: - result = await self.async_step_local_api(import_data) - else: - result = await self.async_step_cloud_api(import_data) - if result["type"] is FlowResultType.FORM: - assert result["errors"] - return self.async_abort(reason=result["errors"]["base"]) - return result diff --git a/tests/components/geniushub/test_config_flow.py b/tests/components/geniushub/test_config_flow.py index 9234e03e35a25..7d1d33a22451a 100644 --- a/tests/components/geniushub/test_config_flow.py +++ b/tests/components/geniushub/test_config_flow.py @@ -2,21 +2,14 @@ from http import HTTPStatus import socket -from typing import Any from unittest.mock import AsyncMock from aiohttp import ClientConnectionError, ClientResponseError import pytest from homeassistant.components.geniushub import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import ( - CONF_HOST, - CONF_MAC, - CONF_PASSWORD, - CONF_TOKEN, - CONF_USERNAME, -) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -309,174 +302,3 @@ async def test_cloud_duplicate( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -@pytest.mark.parametrize( - ("data"), - [ - { - CONF_HOST: "10.0.0.130", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - { - CONF_HOST: "10.0.0.130", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_MAC: "aa:bb:cc:dd:ee:ff", - }, - ], -) -async def test_import_local_flow( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_geniushub_client: AsyncMock, - data: dict[str, Any], -) -> None: - """Test full local import flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=data, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "10.0.0.130" - assert result["data"] == data - assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" - - -@pytest.mark.parametrize( - ("data"), - [ - { - CONF_TOKEN: "abcdef", - }, - { - CONF_TOKEN: "abcdef", - CONF_MAC: "aa:bb:cc:dd:ee:ff", - }, - ], -) -async def test_import_cloud_flow( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_geniushub_client: AsyncMock, - data: dict[str, Any], -) -> None: - """Test full cloud import flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=data, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Genius hub" - assert result["data"] == data - - -@pytest.mark.parametrize( - ("data"), - [ - { - CONF_HOST: "10.0.0.130", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - { - CONF_HOST: "10.0.0.130", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_MAC: "aa:bb:cc:dd:ee:ff", - }, - { - CONF_TOKEN: "abcdef", - }, - { - CONF_TOKEN: "abcdef", - CONF_MAC: "aa:bb:cc:dd:ee:ff", - }, - ], -) -@pytest.mark.parametrize( - ("exception", "reason"), - [ - (socket.gaierror, "invalid_host"), - ( - ClientResponseError(AsyncMock(), (), status=HTTPStatus.UNAUTHORIZED), - "invalid_auth", - ), - ( - ClientResponseError(AsyncMock(), (), status=HTTPStatus.NOT_FOUND), - "invalid_host", - ), - (TimeoutError, "cannot_connect"), - (ClientConnectionError, "cannot_connect"), - (Exception, "unknown"), - ], -) -async def test_import_flow_exceptions( - hass: HomeAssistant, - mock_geniushub_client: AsyncMock, - data: dict[str, Any], - exception: Exception, - reason: str, -) -> None: - """Test import flow exceptions.""" - mock_geniushub_client.request.side_effect = exception - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=data, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason - - -@pytest.mark.parametrize( - ("data"), - [ - { - CONF_HOST: "10.0.0.130", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - { - CONF_HOST: "10.0.0.131", - CONF_USERNAME: "test-username1", - CONF_PASSWORD: "test-password", - }, - ], -) -async def test_import_flow_local_duplicate( - hass: HomeAssistant, - mock_geniushub_client: AsyncMock, - mock_local_config_entry: MockConfigEntry, - data: dict[str, Any], -) -> None: - """Test import flow aborts on local duplicate data.""" - mock_local_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=data, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_import_flow_cloud_duplicate( - hass: HomeAssistant, - mock_geniushub_client: AsyncMock, - mock_cloud_config_entry: MockConfigEntry, -) -> None: - """Test import flow aborts on cloud duplicate data.""" - mock_cloud_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_TOKEN: "abcdef", - }, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" From 7fd9339ad8c291af452025b17570bbf72142a123 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 15:34:08 +0100 Subject: [PATCH 0340/1070] Remove unused `file` CONFIG_SCHEMA (#130287) --- homeassistant/components/file/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index 4139b021422cf..7bc206057c858 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -7,12 +7,9 @@ from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv from .const import DOMAIN -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] From 1da4579a09d14938371d365f64daafe7269d826d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Sun, 10 Nov 2024 15:46:50 +0100 Subject: [PATCH 0341/1070] Add more f-series models to myuplink (#130283) --- homeassistant/components/myuplink/binary_sensor.py | 6 ++++-- homeassistant/components/myuplink/const.py | 2 ++ homeassistant/components/myuplink/helpers.py | 14 ++++++++++++-- homeassistant/components/myuplink/number.py | 6 ++++-- homeassistant/components/myuplink/sensor.py | 6 ++++-- homeassistant/components/myuplink/switch.py | 6 ++++-- 6 files changed, 30 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/myuplink/binary_sensor.py b/homeassistant/components/myuplink/binary_sensor.py index 0ba6ac7b0782b..953859986d0af 100644 --- a/homeassistant/components/myuplink/binary_sensor.py +++ b/homeassistant/components/myuplink/binary_sensor.py @@ -12,11 +12,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyUplinkConfigEntry, MyUplinkDataCoordinator +from .const import F_SERIES from .entity import MyUplinkEntity, MyUplinkSystemEntity -from .helpers import find_matching_platform +from .helpers import find_matching_platform, transform_model_series CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, BinarySensorEntityDescription]] = { - "F730": { + F_SERIES: { "43161": BinarySensorEntityDescription( key="elect_add", translation_key="elect_add", @@ -50,6 +51,7 @@ def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription 2. Default to None """ prefix, _, _ = device_point.category.partition(" ") + prefix = transform_model_series(prefix) return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id) diff --git a/homeassistant/components/myuplink/const.py b/homeassistant/components/myuplink/const.py index 3541a8078c3c4..6fd354a21ec44 100644 --- a/homeassistant/components/myuplink/const.py +++ b/homeassistant/components/myuplink/const.py @@ -6,3 +6,5 @@ OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize" OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token" OAUTH2_SCOPES = ["WRITESYSTEM", "READSYSTEM", "offline_access"] + +F_SERIES = "f-series" diff --git a/homeassistant/components/myuplink/helpers.py b/homeassistant/components/myuplink/helpers.py index eb4881c410e10..de5486d8dea20 100644 --- a/homeassistant/components/myuplink/helpers.py +++ b/homeassistant/components/myuplink/helpers.py @@ -6,6 +6,8 @@ from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import Platform +from .const import F_SERIES + def find_matching_platform( device_point: DevicePoint, @@ -86,8 +88,9 @@ def find_matching_platform( "47941", "47975", "48009", - "48042", "48072", + "48442", + "49909", "50113", ) @@ -110,7 +113,7 @@ def skip_entity(model: str, device_point: DevicePoint) -> bool: ): return False return True - if "F730" in model: + if model.lower().startswith("f"): # Entity names containing weekdays are used for advanced scheduling in the # heat pump and should not be exposed in the integration if any(d in device_point.parameter_name.lower() for d in WEEKDAYS): @@ -118,3 +121,10 @@ def skip_entity(model: str, device_point: DevicePoint) -> bool: if device_point.parameter_id in PARAMETER_ID_TO_EXCLUDE_F730: return True return False + + +def transform_model_series(prefix: str) -> str: + """Remap all F-series models.""" + if prefix.lower().startswith("f"): + return F_SERIES + return prefix diff --git a/homeassistant/components/myuplink/number.py b/homeassistant/components/myuplink/number.py index 0c7da0c716f2d..b05ab5d46c969 100644 --- a/homeassistant/components/myuplink/number.py +++ b/homeassistant/components/myuplink/number.py @@ -10,8 +10,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyUplinkConfigEntry, MyUplinkDataCoordinator +from .const import F_SERIES from .entity import MyUplinkEntity -from .helpers import find_matching_platform, skip_entity +from .helpers import find_matching_platform, skip_entity, transform_model_series DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, NumberEntityDescription] = { "DM": NumberEntityDescription( @@ -22,7 +23,7 @@ } CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, NumberEntityDescription]] = { - "F730": { + F_SERIES: { "40940": NumberEntityDescription( key="degree_minutes", translation_key="degree_minutes", @@ -48,6 +49,7 @@ def get_description(device_point: DevicePoint) -> NumberEntityDescription | None 3. Default to None """ prefix, _, _ = device_point.category.partition(" ") + prefix = transform_model_series(prefix) description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get( device_point.parameter_id ) diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index 7feb20bc093de..ef827fc1fb102 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -25,8 +25,9 @@ from homeassistant.helpers.typing import StateType from . import MyUplinkConfigEntry, MyUplinkDataCoordinator +from .const import F_SERIES from .entity import MyUplinkEntity -from .helpers import find_matching_platform, skip_entity +from .helpers import find_matching_platform, skip_entity, transform_model_series DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = { "°C": SensorEntityDescription( @@ -139,7 +140,7 @@ MARKER_FOR_UNKNOWN_VALUE = -32768 CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SensorEntityDescription]] = { - "F730": { + F_SERIES: { "43108": SensorEntityDescription( key="fan_mode", translation_key="fan_mode", @@ -200,6 +201,7 @@ def get_description(device_point: DevicePoint) -> SensorEntityDescription | None """ description = None prefix, _, _ = device_point.category.partition(" ") + prefix = transform_model_series(prefix) description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get( device_point.parameter_id ) diff --git a/homeassistant/components/myuplink/switch.py b/homeassistant/components/myuplink/switch.py index 5c47c8294fec4..75ba6bd7819f6 100644 --- a/homeassistant/components/myuplink/switch.py +++ b/homeassistant/components/myuplink/switch.py @@ -12,11 +12,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyUplinkConfigEntry, MyUplinkDataCoordinator +from .const import F_SERIES from .entity import MyUplinkEntity -from .helpers import find_matching_platform, skip_entity +from .helpers import find_matching_platform, skip_entity, transform_model_series CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SwitchEntityDescription]] = { - "F730": { + F_SERIES: { "50004": SwitchEntityDescription( key="temporary_lux", translation_key="temporary_lux", @@ -47,6 +48,7 @@ def get_description(device_point: DevicePoint) -> SwitchEntityDescription | None 2. Default to None """ prefix, _, _ = device_point.category.partition(" ") + prefix = transform_model_series(prefix) return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id) From f10063c9bea102cf5d6a4fcf13911bf7fb82550f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 10 Nov 2024 16:28:58 +0100 Subject: [PATCH 0342/1070] Fix translation key for `done` response in conversation (#130247) --- .../components/conversation/default_agent.py | 2 +- .../conversation/test_default_agent.py | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 6b5cef89fd66c..a7110c3579555 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -294,7 +294,7 @@ async def async_process(self, user_input: ConversationInput) -> ConversationResu self.hass, language, DOMAIN, [DOMAIN] ) response_text = translations.get( - f"component.{DOMAIN}.agent.done", "Done" + f"component.{DOMAIN}.conversation.agent.done", "Done" ) response.async_set_speech(response_text) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 14a9b0ca88c54..9f54671d8a1b1 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -418,6 +418,44 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None: assert len(callback.mock_calls) == 0 +@pytest.mark.parametrize( + ("language", "expected"), + [("en", "English done"), ("de", "German done"), ("not_translated", "Done")], +) +@pytest.mark.usefixtures("init_components") +async def test_trigger_sentence_response_translation( + hass: HomeAssistant, language: str, expected: str +) -> None: + """Test translation of default response 'done'.""" + hass.config.language = language + + agent = hass.data[DATA_DEFAULT_ENTITY] + assert isinstance(agent, default_agent.DefaultAgent) + + translations = { + "en": {"component.conversation.conversation.agent.done": "English done"}, + "de": {"component.conversation.conversation.agent.done": "German done"}, + "not_translated": {}, + } + + with patch( + "homeassistant.components.conversation.default_agent.translation.async_get_translations", + return_value=translations.get(language), + ): + unregister = agent.register_trigger( + ["test sentence"], AsyncMock(return_value=None) + ) + result = await conversation.async_converse( + hass, "test sentence", None, Context() + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech == { + "plain": {"speech": expected, "extra_data": None} + } + + unregister() + + @pytest.mark.usefixtures("init_components", "sl_setup") async def test_shopping_list_add_item(hass: HomeAssistant) -> None: """Test adding an item to the shopping list through the default agent.""" From ae1203336d6baefafa0a72e4c4fb39a937ce61ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 10 Nov 2024 16:37:53 +0100 Subject: [PATCH 0343/1070] Add links to deprecation issue message for Home Connect Binary door (#129779) --- .../components/home_connect/binary_sensor.py | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index f044a3fdfb414..232b581d58bfb 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import ( IssueSeverity, @@ -192,11 +193,32 @@ def __init__( async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" await super().async_added_to_hass() - entity_automations = automations_with_entity(self.hass, self.entity_id) - entity_scripts = scripts_with_entity(self.hass, self.entity_id) - items = entity_automations + entity_scripts + automations = automations_with_entity(self.hass, self.entity_id) + scripts = scripts_with_entity(self.hass, self.entity_id) + items = automations + scripts if not items: return + + entity_reg: er.EntityRegistry = er.async_get(self.hass) + entity_automations = [ + automation_entity + for automation_id in automations + if (automation_entity := entity_reg.async_get(automation_id)) + ] + entity_scripts = [ + script_entity + for script_id in scripts + if (script_entity := entity_reg.async_get(script_id)) + ] + + items_list = [ + f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" + for item in entity_automations + ] + [ + f"- [{item.original_name}](/config/script/edit/{item.unique_id})" + for item in entity_scripts + ] + async_create_issue( self.hass, DOMAIN, @@ -207,7 +229,7 @@ async def async_added_to_hass(self) -> None: translation_key="deprecated_binary_common_door_sensor", translation_placeholders={ "entity": self.entity_id, - "items": "\n".join([f"- {item}" for item in items]), + "items": "\n".join(items_list), }, ) From ee41725b536d3589b899a8ddc78ecd5b3b70855f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 16:51:08 +0100 Subject: [PATCH 0344/1070] Remove jewish_calendar yaml support after 6 months of deprecation (#130291) --- .../components/jewish_calendar/__init__.py | 64 +--------------- .../components/jewish_calendar/config_flow.py | 19 +---- .../jewish_calendar/test_config_flow.py | 49 ------------ tests/components/jewish_calendar/test_init.py | 75 ------------------- 4 files changed, 2 insertions(+), 205 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 4598cf7cd91a4..b4535097ef51e 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -5,23 +5,17 @@ from functools import partial from hdate import Location -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_ELEVATION, CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, - CONF_NAME, CONF_TIME_ZONE, Platform, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.entity_registry as er -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from .binary_sensor import BINARY_SENSORS from .const import ( @@ -32,7 +26,6 @@ DEFAULT_DIASPORA, DEFAULT_HAVDALAH_OFFSET_MINUTES, DEFAULT_LANGUAGE, - DEFAULT_NAME, DOMAIN, ) from .entity import JewishCalendarConfigEntry, JewishCalendarData @@ -40,32 +33,6 @@ PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.deprecated(DOMAIN), - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_DIASPORA, default=DEFAULT_DIASPORA): cv.boolean, - vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, - vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In( - ["hebrew", "english"] - ), - vol.Optional( - CONF_CANDLE_LIGHT_MINUTES, default=DEFAULT_CANDLE_LIGHT - ): int, - # Default of 0 means use 8.5 degrees / 'three_stars' time. - vol.Optional( - CONF_HAVDALAH_OFFSET_MINUTES, - default=DEFAULT_HAVDALAH_OFFSET_MINUTES, - ): int, - }, - ) - }, - extra=vol.ALLOW_EXTRA, -) - def get_unique_prefix( location: Location, @@ -91,35 +58,6 @@ def get_unique_prefix( return f"{prefix}" -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Jewish Calendar component.""" - if DOMAIN not in config: - return True - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - breaks_in_ha_version="2024.12.0", - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": DEFAULT_NAME, - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - - return True - - async def async_setup_entry( hass: HomeAssistant, config_entry: JewishCalendarConfigEntry ) -> bool: diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index 9673fc6cf2294..a2eadbf57bd71 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -101,23 +101,10 @@ async def async_step_user( ) -> ConfigFlowResult: """Handle the initial step.""" if user_input is not None: - _options = {} - if CONF_CANDLE_LIGHT_MINUTES in user_input: - _options[CONF_CANDLE_LIGHT_MINUTES] = user_input[ - CONF_CANDLE_LIGHT_MINUTES - ] - del user_input[CONF_CANDLE_LIGHT_MINUTES] - if CONF_HAVDALAH_OFFSET_MINUTES in user_input: - _options[CONF_HAVDALAH_OFFSET_MINUTES] = user_input[ - CONF_HAVDALAH_OFFSET_MINUTES - ] - del user_input[CONF_HAVDALAH_OFFSET_MINUTES] if CONF_LOCATION in user_input: user_input[CONF_LATITUDE] = user_input[CONF_LOCATION][CONF_LATITUDE] user_input[CONF_LONGITUDE] = user_input[CONF_LOCATION][CONF_LONGITUDE] - return self.async_create_entry( - title=DEFAULT_NAME, data=user_input, options=_options - ) + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) return self.async_show_form( step_id="user", @@ -126,10 +113,6 @@ async def async_step_user( ), ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_data) - async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index dbd4ecd802d9e..e00fe41749ff7 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -2,8 +2,6 @@ from unittest.mock import AsyncMock -import pytest - from homeassistant import config_entries, setup from homeassistant.components.jewish_calendar.const import ( CONF_CANDLE_LIGHT_MINUTES, @@ -20,12 +18,10 @@ CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, - CONF_NAME, CONF_TIME_ZONE, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -59,51 +55,6 @@ async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert entries[0].data[CONF_TIME_ZONE] == hass.config.time_zone -@pytest.mark.parametrize("diaspora", [True, False]) -@pytest.mark.parametrize("language", ["hebrew", "english"]) -async def test_import_no_options(hass: HomeAssistant, language, diaspora) -> None: - """Test that the import step works.""" - conf = { - DOMAIN: {CONF_NAME: "test", CONF_LANGUAGE: language, CONF_DIASPORA: diaspora} - } - - assert await async_setup_component(hass, DOMAIN, conf.copy()) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert CONF_LANGUAGE in entries[0].data - assert CONF_DIASPORA in entries[0].data - for entry_key, entry_val in entries[0].data.items(): - assert entry_val == conf[DOMAIN][entry_key] - - -async def test_import_with_options(hass: HomeAssistant) -> None: - """Test that the import step works.""" - conf = { - DOMAIN: { - CONF_NAME: "test", - CONF_DIASPORA: DEFAULT_DIASPORA, - CONF_LANGUAGE: DEFAULT_LANGUAGE, - CONF_CANDLE_LIGHT_MINUTES: 20, - CONF_HAVDALAH_OFFSET_MINUTES: 50, - CONF_LATITUDE: 31.76, - CONF_LONGITUDE: 35.235, - } - } - - # Simulate HomeAssistant setting up the component - assert await async_setup_component(hass, DOMAIN, conf.copy()) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - for entry_key, entry_val in entries[0].data.items(): - assert entry_val == conf[DOMAIN][entry_key] - for entry_key, entry_val in entries[0].options.items(): - assert entry_val == conf[DOMAIN][entry_key] - - async def test_single_instance_allowed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/jewish_calendar/test_init.py b/tests/components/jewish_calendar/test_init.py index b8454b41a603f..cb982afec0f0d 100644 --- a/tests/components/jewish_calendar/test_init.py +++ b/tests/components/jewish_calendar/test_init.py @@ -1,76 +1 @@ """Tests for the Jewish Calendar component's init.""" - -from hdate import Location - -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSORS -from homeassistant.components.jewish_calendar import get_unique_prefix -from homeassistant.components.jewish_calendar.const import ( - CONF_CANDLE_LIGHT_MINUTES, - CONF_DIASPORA, - CONF_HAVDALAH_OFFSET_MINUTES, - DEFAULT_DIASPORA, - DEFAULT_LANGUAGE, - DOMAIN, -) -from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME -from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er -from homeassistant.setup import async_setup_component - - -async def test_import_unique_id_migration(hass: HomeAssistant) -> None: - """Test unique_id migration.""" - yaml_conf = { - DOMAIN: { - CONF_NAME: "test", - CONF_DIASPORA: DEFAULT_DIASPORA, - CONF_LANGUAGE: DEFAULT_LANGUAGE, - CONF_CANDLE_LIGHT_MINUTES: 20, - CONF_HAVDALAH_OFFSET_MINUTES: 50, - CONF_LATITUDE: 31.76, - CONF_LONGITUDE: 35.235, - } - } - - # Create an entry in the entity registry with the data from conf - ent_reg = er.async_get(hass) - location = Location( - latitude=yaml_conf[DOMAIN][CONF_LATITUDE], - longitude=yaml_conf[DOMAIN][CONF_LONGITUDE], - timezone=hass.config.time_zone, - diaspora=DEFAULT_DIASPORA, - ) - old_prefix = get_unique_prefix(location, DEFAULT_LANGUAGE, 20, 50) - sample_entity = ent_reg.async_get_or_create( - BINARY_SENSORS, - DOMAIN, - unique_id=f"{old_prefix}_erev_shabbat_hag", - suggested_object_id=f"{DOMAIN}_erev_shabbat_hag", - ) - # Save the existing unique_id, DEFAULT_LANGUAGE should be part of it - old_unique_id = sample_entity.unique_id - assert DEFAULT_LANGUAGE in old_unique_id - - # Simulate HomeAssistant setting up the component - assert await async_setup_component(hass, DOMAIN, yaml_conf.copy()) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - for entry_key, entry_val in entries[0].data.items(): - assert entry_val == yaml_conf[DOMAIN][entry_key] - for entry_key, entry_val in entries[0].options.items(): - assert entry_val == yaml_conf[DOMAIN][entry_key] - - # Assert that the unique_id was updated - new_unique_id = ent_reg.async_get(sample_entity.entity_id).unique_id - assert new_unique_id != old_unique_id - assert DEFAULT_LANGUAGE not in new_unique_id - - # Confirm that when the component is reloaded, the unique_id is not changed - assert ent_reg.async_get(sample_entity.entity_id).unique_id == new_unique_id - - # Confirm that all the unique_ids are prefixed correctly - await hass.config_entries.async_reload(entries[0].entry_id) - er_entries = er.async_entries_for_config_entry(ent_reg, entries[0].entry_id) - assert all(entry.unique_id.startswith(entries[0].entry_id) for entry in er_entries) From d8b55d39e43e186771ae9d6ae448b87070930a87 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 19:27:11 +0100 Subject: [PATCH 0345/1070] Remove tibber legacy notify service after 6 months of deprecation (#130292) --- homeassistant/components/tibber/__init__.py | 21 +------- homeassistant/components/tibber/notify.py | 42 ---------------- tests/components/tibber/test_diagnostics.py | 9 ++-- tests/components/tibber/test_notify.py | 20 -------- tests/components/tibber/test_repairs.py | 56 --------------------- 5 files changed, 4 insertions(+), 144 deletions(-) delete mode 100644 tests/components/tibber/test_repairs.py diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index ce05b8070f6c8..9b5c7ee11689f 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -6,15 +6,9 @@ import tibber from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_NAME, - EVENT_HOMEASSISTANT_STOP, - Platform, -) +from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -73,19 +67,6 @@ async def _close(event: Event) -> None: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Use discovery to load platform legacy notify platform - # The use of the legacy notify service was deprecated with HA Core 2024.6 - # Support will be removed with HA Core 2024.12 - hass.async_create_task( - discovery.async_load_platform( - hass, - Platform.NOTIFY, - DOMAIN, - {CONF_NAME: DOMAIN}, - hass.data[DATA_HASS_CONFIG], - ) - ) - return True diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index 1c9f86ed50261..fdeeeba68ef93 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -2,38 +2,21 @@ from __future__ import annotations -from collections.abc import Callable -from typing import Any - from tibber import Tibber from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, - BaseNotificationService, NotifyEntity, NotifyEntityFeature, - migrate_notify_issue, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as TIBBER_DOMAIN -async def async_get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> TibberNotificationService: - """Get the Tibber notification service.""" - tibber_connection: Tibber = hass.data[TIBBER_DOMAIN] - return TibberNotificationService(tibber_connection.send_notification) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -41,31 +24,6 @@ async def async_setup_entry( async_add_entities([TibberNotificationEntity(entry.entry_id)]) -class TibberNotificationService(BaseNotificationService): - """Implement the notification service for Tibber.""" - - def __init__(self, notify: Callable) -> None: - """Initialize the service.""" - self._notify = notify - - async def async_send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to Tibber devices.""" - migrate_notify_issue( - self.hass, - TIBBER_DOMAIN, - "Tibber", - "2024.12.0", - service_name=self._service_name, - ) - title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - try: - await self._notify(title=title, message=message) - except TimeoutError as exc: - raise HomeAssistantError( - translation_domain=TIBBER_DOMAIN, translation_key="send_message_timeout" - ) from exc - - class TibberNotificationEntity(NotifyEntity): """Implement the notification entity service for Tibber.""" diff --git a/tests/components/tibber/test_diagnostics.py b/tests/components/tibber/test_diagnostics.py index 34ecb63dfec96..16c735596d086 100644 --- a/tests/components/tibber/test_diagnostics.py +++ b/tests/components/tibber/test_diagnostics.py @@ -19,12 +19,9 @@ async def test_entry_diagnostics( config_entry, ) -> None: """Test config entry diagnostics.""" - with ( - patch( - "tibber.Tibber.update_info", - return_value=None, - ), - patch("homeassistant.components.tibber.discovery.async_load_platform"), + with patch( + "tibber.Tibber.update_info", + return_value=None, ): assert await async_setup_component(hass, "tibber", {}) diff --git a/tests/components/tibber/test_notify.py b/tests/components/tibber/test_notify.py index 69af92c4d5dc0..9b731e78bf694 100644 --- a/tests/components/tibber/test_notify.py +++ b/tests/components/tibber/test_notify.py @@ -6,7 +6,6 @@ import pytest from homeassistant.components.recorder import Recorder -from homeassistant.components.tibber import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -19,18 +18,8 @@ async def test_notification_services( notify_state = hass.states.get("notify.tibber") assert notify_state is not None - # Assert legacy notify service hass been added - assert hass.services.has_service("notify", DOMAIN) - - # Test legacy notify service - service = "tibber" - service_data = {"message": "The message", "title": "A title"} - await hass.services.async_call("notify", service, service_data, blocking=True) calls: MagicMock = mock_tibber_setup.send_notification - calls.assert_called_once_with(message="The message", title="A title") - calls.reset_mock() - # Test notify entity service service = "send_message" service_data = { @@ -44,15 +33,6 @@ async def test_notification_services( calls.side_effect = TimeoutError - with pytest.raises(HomeAssistantError): - # Test legacy notify service - await hass.services.async_call( - "notify", - service="tibber", - service_data={"message": "The message", "title": "A title"}, - blocking=True, - ) - with pytest.raises(HomeAssistantError): # Test notify entity service await hass.services.async_call( diff --git a/tests/components/tibber/test_repairs.py b/tests/components/tibber/test_repairs.py deleted file mode 100644 index 5e5fde4569e21..0000000000000 --- a/tests/components/tibber/test_repairs.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Test loading of the Tibber config entry.""" - -from unittest.mock import MagicMock - -from homeassistant.components.recorder import Recorder -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir - -from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow -from tests.typing import ClientSessionGenerator - - -async def test_repair_flow( - recorder_mock: Recorder, - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - mock_tibber_setup: MagicMock, - hass_client: ClientSessionGenerator, -) -> None: - """Test unloading the entry.""" - - # Test legacy notify service - service = "tibber" - service_data = {"message": "The message", "title": "A title"} - await hass.services.async_call("notify", service, service_data, blocking=True) - calls: MagicMock = mock_tibber_setup.send_notification - - calls.assert_called_once_with(message="The message", title="A title") - calls.reset_mock() - - http_client = await hass_client() - # Assert the issue is present - assert issue_registry.async_get_issue( - domain="notify", - issue_id=f"migrate_notify_tibber_{service}", - ) - assert len(issue_registry.issues) == 1 - - data = await start_repair_fix_flow( - http_client, "notify", f"migrate_notify_tibber_{service}" - ) - - flow_id = data["flow_id"] - assert data["step_id"] == "confirm" - - # Simulate the users confirmed the repair flow - data = await process_repair_fix_flow(http_client, flow_id) - assert data["type"] == "create_entry" - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue( - domain="notify", - issue_id=f"migrate_notify_tibber_{service}", - ) - assert len(issue_registry.issues) == 0 From 7f9ec2a79eee5a638a4b294762c53bf76d2528a3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 10 Nov 2024 10:27:40 -0800 Subject: [PATCH 0346/1070] Ignore WebRTC candidates for nest cameras (#130294) --- homeassistant/components/nest/camera.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 4cb88e6364166..0a46d67a3ad52 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -19,6 +19,7 @@ from google_nest_sdm.device import Device from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.exceptions import ApiException +from webrtc_models import RTCIceCandidate from homeassistant.components.camera import ( Camera, @@ -302,6 +303,12 @@ async def async_handle_async_webrtc_offer( ) self._refresh_unsub[session_id] = refresh.unsub + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: + """Ignore WebRTC candidates for Nest cloud based cameras.""" + return + @callback def close_webrtc_session(self, session_id: str) -> None: """Close a WebRTC session.""" From fbc4a87166040e42540c9702806d9d3b82effda8 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Sun, 10 Nov 2024 20:35:01 +0200 Subject: [PATCH 0347/1070] Remove Jewish Calendar config flow upgrade (#129612) --- .../components/jewish_calendar/__init__.py | 62 +------------------ 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index b4535097ef51e..823e9bd59be54 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -14,10 +14,8 @@ CONF_TIME_ZONE, Platform, ) -from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.entity_registry as er +from homeassistant.core import HomeAssistant -from .binary_sensor import BINARY_SENSORS from .const import ( CONF_CANDLE_LIGHT_MINUTES, CONF_DIASPORA, @@ -26,38 +24,12 @@ DEFAULT_DIASPORA, DEFAULT_HAVDALAH_OFFSET_MINUTES, DEFAULT_LANGUAGE, - DOMAIN, ) from .entity import JewishCalendarConfigEntry, JewishCalendarData -from .sensor import INFO_SENSORS, TIME_SENSORS PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -def get_unique_prefix( - location: Location, - language: str, - candle_lighting_offset: int | None, - havdalah_offset: int | None, -) -> str: - """Create a prefix for unique ids.""" - # location.altitude was unset before 2024.6 when this method - # was used to create the unique id. As such it would always - # use the default altitude of 754. - config_properties = [ - location.latitude, - location.longitude, - location.timezone, - 754, - location.diaspora, - language, - candle_lighting_offset, - havdalah_offset, - ] - prefix = "_".join(map(str, config_properties)) - return f"{prefix}" - - async def async_setup_entry( hass: HomeAssistant, config_entry: JewishCalendarConfigEntry ) -> bool: @@ -91,16 +63,6 @@ async def async_setup_entry( havdalah_offset, ) - # Update unique ID to be unrelated to user defined options - old_prefix = get_unique_prefix( - location, language, candle_lighting_offset, havdalah_offset - ) - - ent_reg = er.async_get(hass) - entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) - if not entries or any(entry.unique_id.startswith(old_prefix) for entry in entries): - async_update_unique_ids(ent_reg, config_entry.entry_id, old_prefix) - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async def update_listener( @@ -118,25 +80,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -@callback -def async_update_unique_ids( - ent_reg: er.EntityRegistry, new_prefix: str, old_prefix: str -) -> None: - """Update unique ID to be unrelated to user defined options. - - Introduced with release 2024.6 - """ - platform_descriptions = { - Platform.BINARY_SENSOR: BINARY_SENSORS, - Platform.SENSOR: (*INFO_SENSORS, *TIME_SENSORS), - } - for platform, descriptions in platform_descriptions.items(): - for description in descriptions: - new_unique_id = f"{new_prefix}-{description.key}" - old_unique_id = f"{old_prefix}_{description.key}" - if entity_id := ent_reg.async_get_entity_id( - platform, DOMAIN, old_unique_id - ): - ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) From 980b0fa5e693fb5e51640b96d398d1a6ef32bae5 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 10 Nov 2024 19:37:41 +0100 Subject: [PATCH 0348/1070] Deprecate api_call action in Habitica integration (#128119) --- homeassistant/components/habitica/services.py | 14 ++++++++++++++ homeassistant/components/habitica/strings.json | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index df62067569992..a50e5f1e6e3ed 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -19,6 +19,7 @@ ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.selector import ConfigEntrySelector from .const import ( @@ -96,6 +97,19 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Set up services for Habitica integration.""" async def handle_api_call(call: ServiceCall) -> None: + async_create_issue( + hass, + DOMAIN, + "deprecated_api_call", + breaks_in_ha_version="2025.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_api_call", + ) + _LOGGER.warning( + "Deprecated action called: 'habitica.api_call' is deprecated and will be removed in Home Assistant version 2025.6.0" + ) + name = call.data[ATTR_NAME] path = call.data[ATTR_PATH] entries = hass.config_entries.async_entries(DOMAIN) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index fd793675a5c5c..ac1faf5fcef1d 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -327,6 +327,10 @@ "deprecated_task_entity": { "title": "The Habitica {task_name} sensor is deprecated", "description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`." + }, + "deprecated_api_call": { + "title": "The Habitica action habitica.api_call is deprecated", + "description": "The Habitica action `habitica.api_call` is deprecated and will be removed in Home Assistant 2025.5.0.\n\nPlease update your automations and scripts to use other Habitica actions and entities." } }, "services": { From 73929e6791969e3dd9993574853bcf124d07f4d7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 10 Nov 2024 20:11:42 +0100 Subject: [PATCH 0349/1070] Avoid Shelly data update during shutdown (#130301) --- homeassistant/components/shelly/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 6332e139244b9..a66fbb20f481e 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -603,7 +603,7 @@ def _async_device_event_handler(self, event_data: dict[str, Any]) -> None: async def _async_update_data(self) -> None: """Fetch data.""" - if self.update_sleep_period(): + if self.update_sleep_period() or self.hass.is_stopping: return if self.sleep_period: From 3a37ff13a6e3076a7b10109025e8d4bcde005a50 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sun, 10 Nov 2024 20:12:46 +0100 Subject: [PATCH 0350/1070] Bump eq3btsmart to 1.2.1 (#130297) --- homeassistant/components/eq3btsmart/climate.py | 10 ++++++++-- homeassistant/components/eq3btsmart/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index 7b8ccb6c99011..9984c4f7229cd 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -143,6 +143,9 @@ def _async_on_updated(self) -> None: def _async_on_status_updated(self) -> None: """Handle updated status from the thermostat.""" + if self._thermostat.status is None: + return + self._target_temperature = self._thermostat.status.target_temperature.value self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode] self._attr_current_temperature = self._get_current_temperature() @@ -154,13 +157,16 @@ def _async_on_status_updated(self) -> None: def _async_on_device_updated(self) -> None: """Handle updated device data from the thermostat.""" + if self._thermostat.device_data is None: + return + device_registry = dr.async_get(self.hass) if device := device_registry.async_get_device( connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, ): device_registry.async_update_device( device.id, - sw_version=self._thermostat.device_data.firmware_version, + sw_version=str(self._thermostat.device_data.firmware_version), serial_number=self._thermostat.device_data.device_serial.value, ) @@ -265,7 +271,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: self.async_write_ha_state() try: - await self._thermostat.async_set_temperature(self._target_temperature) + await self._thermostat.async_set_temperature(temperature) except Eq3Exception: _LOGGER.error( "[%s] Failed setting temperature", self._eq3_config.mac_address diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index e25c675bf826f..bd3f14939ca91 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -23,5 +23,5 @@ "iot_class": "local_polling", "loggers": ["eq3btsmart"], "quality_scale": "silver", - "requirements": ["eq3btsmart==1.2.0", "bleak-esphome==1.1.0"] + "requirements": ["eq3btsmart==1.2.1", "bleak-esphome==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e09673d4534cc..7a2aa07342e17 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -860,7 +860,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.2.0 +eq3btsmart==1.2.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3db5b00adfcf..b92442854afc9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -729,7 +729,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.2.0 +eq3btsmart==1.2.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 From 88c227681d702f1341ced8873ad1b87431192557 Mon Sep 17 00:00:00 2001 From: dotvav Date: Sun, 10 Nov 2024 20:13:31 +0100 Subject: [PATCH 0351/1070] Bump pypalazzetti to 0.1.11 (#130293) --- homeassistant/components/palazzetti/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index 552289ebeacb4..aff82275e2efc 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pypalazzetti==0.1.10"] + "requirements": ["pypalazzetti==0.1.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7a2aa07342e17..7cf0190a6aa4b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2152,7 +2152,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.10 +pypalazzetti==0.1.11 # homeassistant.components.elv pypca==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b92442854afc9..9332c74adc31b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1739,7 +1739,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.10 +pypalazzetti==0.1.11 # homeassistant.components.lcn pypck==0.7.24 From 0468e7e7a3234e37b7b300f02cb555ae68b361b0 Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Sun, 10 Nov 2024 12:23:23 -0700 Subject: [PATCH 0352/1070] Update Sonarr config flow to standardize ports (#127625) Co-authored-by: Joost Lekkerkerker Co-authored-by: Franck Nijhof --- .../components/sonarr/config_flow.py | 7 ++++ tests/components/sonarr/__init__.py | 2 +- tests/components/sonarr/test_config_flow.py | 32 +++++++++++++++++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index c868c04f7d0c4..e1cedba10e756 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -93,6 +93,13 @@ async def async_step_user( errors = {} if user_input is not None: + # aiopyarr defaults to the service port if one isn't given + # this is counter to standard practice where http = 80 + # and https = 443. + if CONF_URL in user_input: + url = yarl.URL(user_input[CONF_URL]) + user_input[CONF_URL] = f"{url.scheme}://{url.host}:{url.port}{url.path}" + if self.source == SOURCE_REAUTH: user_input = {**self._get_reauth_entry().data, **user_input} diff --git a/tests/components/sonarr/__init__.py b/tests/components/sonarr/__init__.py index b6050808a34cc..660102ed08267 100644 --- a/tests/components/sonarr/__init__.py +++ b/tests/components/sonarr/__init__.py @@ -5,6 +5,6 @@ MOCK_REAUTH_INPUT = {CONF_API_KEY: "test-api-key-reauth"} MOCK_USER_INPUT = { - CONF_URL: "http://192.168.1.189:8989", + CONF_URL: "http://192.168.1.189:8989/", CONF_API_KEY: "MOCK_API_KEY", } diff --git a/tests/components/sonarr/test_config_flow.py b/tests/components/sonarr/test_config_flow.py index 118d5020cba3e..efbfbd749b3e9 100644 --- a/tests/components/sonarr/test_config_flow.py +++ b/tests/components/sonarr/test_config_flow.py @@ -50,6 +50,34 @@ async def test_cannot_connect( assert result["errors"] == {"base": "cannot_connect"} +async def test_url_rewrite( + hass: HomeAssistant, + mock_sonarr_config_flow: MagicMock, + mock_setup_entry: None, +) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + user_input = MOCK_USER_INPUT.copy() + user_input[CONF_URL] = "https://192.168.1.189" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "192.168.1.189" + + assert result["data"] + assert result["data"][CONF_URL] == "https://192.168.1.189:443/" + + async def test_invalid_auth( hass: HomeAssistant, mock_sonarr_config_flow: MagicMock ) -> None: @@ -145,7 +173,7 @@ async def test_full_user_flow_implementation( assert result["title"] == "192.168.1.189" assert result["data"] - assert result["data"][CONF_URL] == "http://192.168.1.189:8989" + assert result["data"][CONF_URL] == "http://192.168.1.189:8989/" async def test_full_user_flow_advanced_options( @@ -175,7 +203,7 @@ async def test_full_user_flow_advanced_options( assert result["title"] == "192.168.1.189" assert result["data"] - assert result["data"][CONF_URL] == "http://192.168.1.189:8989" + assert result["data"][CONF_URL] == "http://192.168.1.189:8989/" assert result["data"][CONF_VERIFY_SSL] From 784ad20fb6ed38e6c052beda073bf748a1787dd6 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sun, 10 Nov 2024 20:31:40 +0100 Subject: [PATCH 0353/1070] Add diagnostics to LinkPlay (#126768) --- .../components/linkplay/diagnostics.py | 17 +++ tests/components/linkplay/__init__.py | 15 +++ tests/components/linkplay/conftest.py | 70 ++++++++++- .../linkplay/fixtures/getPlayerEx.json | 19 +++ .../linkplay/fixtures/getStatusEx.json | 81 ++++++++++++ .../linkplay/snapshots/test_diagnostics.ambr | 115 ++++++++++++++++++ tests/components/linkplay/test_diagnostics.py | 53 ++++++++ 7 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/linkplay/diagnostics.py create mode 100644 tests/components/linkplay/fixtures/getPlayerEx.json create mode 100644 tests/components/linkplay/fixtures/getStatusEx.json create mode 100644 tests/components/linkplay/snapshots/test_diagnostics.ambr create mode 100644 tests/components/linkplay/test_diagnostics.py diff --git a/homeassistant/components/linkplay/diagnostics.py b/homeassistant/components/linkplay/diagnostics.py new file mode 100644 index 0000000000000..cfc1346aff4ad --- /dev/null +++ b/homeassistant/components/linkplay/diagnostics.py @@ -0,0 +1,17 @@ +"""Diagnostics support for Linkplay.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import LinkPlayConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: LinkPlayConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = entry.runtime_data + return {"device_info": data.bridge.to_dict()} diff --git a/tests/components/linkplay/__init__.py b/tests/components/linkplay/__init__.py index 5962f7fdaba04..f825826f19692 100644 --- a/tests/components/linkplay/__init__.py +++ b/tests/components/linkplay/__init__.py @@ -1 +1,16 @@ """Tests for the LinkPlay integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/linkplay/conftest.py b/tests/components/linkplay/conftest.py index be83dd2412d32..81ae993f6c3a1 100644 --- a/tests/components/linkplay/conftest.py +++ b/tests/components/linkplay/conftest.py @@ -1,12 +1,22 @@ """Test configuration and mocks for LinkPlay component.""" -from collections.abc import Generator +from collections.abc import Generator, Iterator +from contextlib import contextmanager +from typing import Any +from unittest import mock from unittest.mock import AsyncMock, patch from aiohttp import ClientSession from linkplay.bridge import LinkPlayBridge, LinkPlayDevice import pytest +from homeassistant.components.linkplay.const import DOMAIN +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_CLOSE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture +from tests.conftest import AiohttpClientMocker + HOST = "10.0.0.150" HOST_REENTRY = "10.0.0.66" UUID = "FF31F09E-5001-FBDE-0546-2DBFFF31F09E" @@ -24,15 +34,15 @@ def mock_linkplay_factory_bridge() -> Generator[AsyncMock]: ), patch( "homeassistant.components.linkplay.config_flow.linkplay_factory_httpapi_bridge", - ) as factory, + ) as conf_factory, ): bridge = AsyncMock(spec=LinkPlayBridge) bridge.endpoint = HOST bridge.device = AsyncMock(spec=LinkPlayDevice) bridge.device.uuid = UUID bridge.device.name = NAME - factory.return_value = bridge - yield factory + conf_factory.return_value = bridge + yield conf_factory @pytest.fixture @@ -43,3 +53,55 @@ def mock_setup_entry() -> Generator[AsyncMock]: return_value=True, ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=NAME, + data={CONF_HOST: HOST}, + unique_id=UUID, + ) + + +@pytest.fixture +def mock_player_ex( + mock_player_ex: AsyncMock, +) -> AsyncMock: + """Mock a update_status of the LinkPlayPlayer.""" + mock_player_ex.return_value = load_fixture("getPlayerEx.json", DOMAIN) + return mock_player_ex + + +@pytest.fixture +def mock_status_ex( + mock_status_ex: AsyncMock, +) -> AsyncMock: + """Mock a update_status of the LinkPlayDevice.""" + mock_status_ex.return_value = load_fixture("getStatusEx.json", DOMAIN) + return mock_status_ex + + +@contextmanager +def mock_lp_aiohttp_client() -> Iterator[AiohttpClientMocker]: + """Context manager to mock aiohttp client.""" + mocker = AiohttpClientMocker() + + def create_session(hass: HomeAssistant, *args: Any, **kwargs: Any) -> ClientSession: + session = mocker.create_session(hass.loop) + + async def close_session(event): + """Close session.""" + await session.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, close_session) + + return session + + with mock.patch( + "homeassistant.components.linkplay.async_get_client_session", + side_effect=create_session, + ): + yield mocker diff --git a/tests/components/linkplay/fixtures/getPlayerEx.json b/tests/components/linkplay/fixtures/getPlayerEx.json new file mode 100644 index 0000000000000..79d09f942df2e --- /dev/null +++ b/tests/components/linkplay/fixtures/getPlayerEx.json @@ -0,0 +1,19 @@ +{ + "type": "0", + "ch": "0", + "mode": "0", + "loop": "0", + "eq": "0", + "status": "stop", + "curpos": "0", + "offset_pts": "0", + "totlen": "0", + "Title": "", + "Artist": "", + "Album": "", + "alarmflag": "0", + "plicount": "0", + "plicurr": "0", + "vol": "80", + "mute": "0" +} diff --git a/tests/components/linkplay/fixtures/getStatusEx.json b/tests/components/linkplay/fixtures/getStatusEx.json new file mode 100644 index 0000000000000..17eda4aeee8d8 --- /dev/null +++ b/tests/components/linkplay/fixtures/getStatusEx.json @@ -0,0 +1,81 @@ +{ + "uuid": "FF31F09E5001FBDE05462DBFFF31F09E", + "DeviceName": "Smart Zone 1_54B9", + "GroupName": "Smart Zone 1_54B9", + "ssid": "Smart Zone 1_54B9", + "language": "en_us", + "firmware": "4.6.415145", + "hardware": "A31", + "build": "release", + "project": "SMART_ZONE4_AMP", + "priv_prj": "SMART_ZONE4_AMP", + "project_build_name": "a31rakoit", + "Release": "20220427", + "temp_uuid": "97296CE38DE8CC3D", + "hideSSID": "1", + "SSIDStrategy": "2", + "branch": "A31_stable_4.6", + "group": "0", + "wmrm_version": "4.2", + "internet": "1", + "MAC": "00:22:6C:21:7F:1D", + "STA_MAC": "00:00:00:00:00:00", + "CountryCode": "CN", + "CountryRegion": "1", + "netstat": "0", + "essid": "", + "apcli0": "", + "eth2": "192.168.168.197", + "ra0": "10.10.10.254", + "eth_dhcp": "1", + "VersionUpdate": "0", + "NewVer": "0", + "set_dns_enable": "1", + "mcu_ver": "37", + "mcu_ver_new": "0", + "dsp_ver": "0", + "dsp_ver_new": "0", + "date": "2024:10:29", + "time": "17:13:22", + "tz": "1.0000", + "dst_enable": "1", + "region": "unknown", + "prompt_status": "1", + "iot_ver": "1.0.0", + "upnp_version": "1005", + "cap1": "0x305200", + "capability": "0x28e90b80", + "languages": "0x6", + "streams_all": "0x7bff7ffe", + "streams": "0x7b9831fe", + "external": "0x0", + "plm_support": "0x40152", + "preset_key": "10", + "spotify_active": "0", + "lbc_support": "0", + "privacy_mode": "0", + "WifiChannel": "11", + "RSSI": "0", + "BSSID": "", + "battery": "0", + "battery_percent": "0", + "securemode": "1", + "auth": "WPAPSKWPA2PSK", + "encry": "AES", + "upnp_uuid": "uuid:FF31F09E-5001-FBDE-0546-2DBFFF31F09E", + "uart_pass_port": "8899", + "communication_port": "8819", + "web_firmware_update_hide": "0", + "ignore_talkstart": "0", + "web_login_result": "-1", + "silenceOTATime": "", + "ignore_silenceOTATime": "1", + "new_tunein_preset_and_alarm": "1", + "iheartradio_new": "1", + "new_iheart_podcast": "1", + "tidal_version": "2.0", + "service_version": "1.0", + "ETH_MAC": "00:22:6C:21:7F:20", + "security": "https/2.0", + "security_version": "2.0" +} diff --git a/tests/components/linkplay/snapshots/test_diagnostics.ambr b/tests/components/linkplay/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..d8c52a2564984 --- /dev/null +++ b/tests/components/linkplay/snapshots/test_diagnostics.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'device_info': dict({ + 'device': dict({ + 'properties': dict({ + 'BSSID': '', + 'CountryCode': 'CN', + 'CountryRegion': '1', + 'DeviceName': 'Smart Zone 1_54B9', + 'ETH_MAC': '00:22:6C:21:7F:20', + 'GroupName': 'Smart Zone 1_54B9', + 'MAC': '00:22:6C:21:7F:1D', + 'NewVer': '0', + 'RSSI': '0', + 'Release': '20220427', + 'SSIDStrategy': '2', + 'STA_MAC': '00:00:00:00:00:00', + 'VersionUpdate': '0', + 'WifiChannel': '11', + 'apcli0': '', + 'auth': 'WPAPSKWPA2PSK', + 'battery': '0', + 'battery_percent': '0', + 'branch': 'A31_stable_4.6', + 'build': 'release', + 'cap1': '0x305200', + 'capability': '0x28e90b80', + 'communication_port': '8819', + 'date': '2024:10:29', + 'dsp_ver': '0', + 'dsp_ver_new': '0', + 'dst_enable': '1', + 'encry': 'AES', + 'essid': '', + 'eth2': '192.168.168.197', + 'eth_dhcp': '1', + 'external': '0x0', + 'firmware': '4.6.415145', + 'group': '0', + 'hardware': 'A31', + 'hideSSID': '1', + 'ignore_silenceOTATime': '1', + 'ignore_talkstart': '0', + 'iheartradio_new': '1', + 'internet': '1', + 'iot_ver': '1.0.0', + 'language': 'en_us', + 'languages': '0x6', + 'lbc_support': '0', + 'mcu_ver': '37', + 'mcu_ver_new': '0', + 'netstat': '0', + 'new_iheart_podcast': '1', + 'new_tunein_preset_and_alarm': '1', + 'plm_support': '0x40152', + 'preset_key': '10', + 'priv_prj': 'SMART_ZONE4_AMP', + 'privacy_mode': '0', + 'project': 'SMART_ZONE4_AMP', + 'project_build_name': 'a31rakoit', + 'prompt_status': '1', + 'ra0': '10.10.10.254', + 'region': 'unknown', + 'securemode': '1', + 'security': 'https/2.0', + 'security_version': '2.0', + 'service_version': '1.0', + 'set_dns_enable': '1', + 'silenceOTATime': '', + 'spotify_active': '0', + 'ssid': 'Smart Zone 1_54B9', + 'streams': '0x7b9831fe', + 'streams_all': '0x7bff7ffe', + 'temp_uuid': '97296CE38DE8CC3D', + 'tidal_version': '2.0', + 'time': '17:13:22', + 'tz': '1.0000', + 'uart_pass_port': '8899', + 'upnp_uuid': 'uuid:FF31F09E-5001-FBDE-0546-2DBFFF31F09E', + 'upnp_version': '1005', + 'uuid': 'FF31F09E5001FBDE05462DBFFF31F09E', + 'web_firmware_update_hide': '0', + 'web_login_result': '-1', + 'wmrm_version': '4.2', + }), + }), + 'endpoint': dict({ + 'endpoint': 'https://10.0.0.150', + }), + 'multiroom': None, + 'player': dict({ + 'properties': dict({ + 'Album': '', + 'Artist': '', + 'Title': '', + 'alarmflag': '0', + 'ch': '0', + 'curpos': '0', + 'eq': '0', + 'loop': '0', + 'mode': '0', + 'mute': '0', + 'offset_pts': '0', + 'plicount': '0', + 'plicurr': '0', + 'status': 'stop', + 'totlen': '0', + 'type': '0', + 'vol': '80', + }), + }), + }), + }) +# --- diff --git a/tests/components/linkplay/test_diagnostics.py b/tests/components/linkplay/test_diagnostics.py new file mode 100644 index 0000000000000..369142978a341 --- /dev/null +++ b/tests/components/linkplay/test_diagnostics.py @@ -0,0 +1,53 @@ +"""Tests for the LinkPlay diagnostics.""" + +from unittest.mock import patch + +from linkplay.bridge import LinkPlayMultiroom +from linkplay.consts import API_ENDPOINT +from linkplay.endpoint import LinkPlayApiEndpoint +from syrupy import SnapshotAssertion + +from homeassistant.components.linkplay.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import HOST, mock_lp_aiohttp_client + +from tests.common import MockConfigEntry, load_fixture +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + with ( + mock_lp_aiohttp_client() as mock_session, + patch.object(LinkPlayMultiroom, "update_status", return_value=None), + ): + endpoints = [ + LinkPlayApiEndpoint(protocol="https", endpoint=HOST, session=None), + LinkPlayApiEndpoint(protocol="http", endpoint=HOST, session=None), + ] + for endpoint in endpoints: + mock_session.get( + API_ENDPOINT.format(str(endpoint), "getPlayerStatusEx"), + text=load_fixture("getPlayerEx.json", DOMAIN), + ) + + mock_session.get( + API_ENDPOINT.format(str(endpoint), "getStatusEx"), + text=load_fixture("getStatusEx.json", DOMAIN), + ) + + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From f7f1830b7e0a13a1de59b9f66bc29c1262bdb551 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 10 Nov 2024 20:34:24 +0100 Subject: [PATCH 0354/1070] Add support for binary sensor states in Google Assistant (#127652) --- .../components/google_assistant/const.py | 11 ++ .../components/google_assistant/trait.py | 117 +++++++++++++----- .../components/google_assistant/test_trait.py | 87 +++++++++++++ 3 files changed, 182 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 04c85639e075b..8132ecaae2c52 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -78,6 +78,7 @@ TYPE_BLINDS = f"{PREFIX_TYPES}BLINDS" TYPE_CAMERA = f"{PREFIX_TYPES}CAMERA" TYPE_CURTAIN = f"{PREFIX_TYPES}CURTAIN" +TYPE_CARBON_MONOXIDE_DETECTOR = f"{PREFIX_TYPES}CARBON_MONOXIDE_DETECTOR" TYPE_DEHUMIDIFIER = f"{PREFIX_TYPES}DEHUMIDIFIER" TYPE_DOOR = f"{PREFIX_TYPES}DOOR" TYPE_DOORBELL = f"{PREFIX_TYPES}DOORBELL" @@ -93,6 +94,7 @@ TYPE_SENSOR = f"{PREFIX_TYPES}SENSOR" TYPE_SETTOP = f"{PREFIX_TYPES}SETTOP" TYPE_SHUTTER = f"{PREFIX_TYPES}SHUTTER" +TYPE_SMOKE_DETECTOR = f"{PREFIX_TYPES}SMOKE_DETECTOR" TYPE_SPEAKER = f"{PREFIX_TYPES}SPEAKER" TYPE_SWITCH = f"{PREFIX_TYPES}SWITCH" TYPE_THERMOSTAT = f"{PREFIX_TYPES}THERMOSTAT" @@ -136,6 +138,7 @@ DOMAIN_TO_GOOGLE_TYPES = { alarm_control_panel.DOMAIN: TYPE_ALARM, + binary_sensor.DOMAIN: TYPE_SENSOR, button.DOMAIN: TYPE_SCENE, camera.DOMAIN: TYPE_CAMERA, climate.DOMAIN: TYPE_THERMOSTAT, @@ -168,6 +171,14 @@ binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR, ): TYPE_GARAGE, + ( + binary_sensor.DOMAIN, + binary_sensor.BinarySensorDeviceClass.SMOKE, + ): TYPE_SMOKE_DETECTOR, + ( + binary_sensor.DOMAIN, + binary_sensor.BinarySensorDeviceClass.CO, + ): TYPE_CARBON_MONOXIDE_DETECTOR, (cover.DOMAIN, cover.CoverDeviceClass.AWNING): TYPE_AWNING, (cover.DOMAIN, cover.CoverDeviceClass.CURTAIN): TYPE_CURTAIN, (cover.DOMAIN, cover.CoverDeviceClass.DOOR): TYPE_DOOR, diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index df56885995a1c..f99f1574038a5 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -2706,6 +2706,21 @@ class SensorStateTrait(_Trait): ), } + binary_sensor_types = { + binary_sensor.BinarySensorDeviceClass.CO: ( + "CarbonMonoxideLevel", + ["carbon monoxide detected", "no carbon monoxide detected", "unknown"], + ), + binary_sensor.BinarySensorDeviceClass.SMOKE: ( + "SmokeLevel", + ["smoke detected", "no smoke detected", "unknown"], + ), + binary_sensor.BinarySensorDeviceClass.MOISTURE: ( + "WaterLeak", + ["leak", "no leak", "unknown"], + ), + } + name = TRAIT_SENSOR_STATE commands: list[str] = [] @@ -2728,24 +2743,37 @@ def _air_quality_description_for_aqi(self, aqi: float | None) -> str: @classmethod def supported(cls, domain, features, device_class, _): """Test if state is supported.""" - return domain == sensor.DOMAIN and device_class in cls.sensor_types + return (domain == sensor.DOMAIN and device_class in cls.sensor_types) or ( + domain == binary_sensor.DOMAIN and device_class in cls.binary_sensor_types + ) def sync_attributes(self) -> dict[str, Any]: """Return attributes for a sync request.""" device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) - data = self.sensor_types.get(device_class) - if device_class is None or data is None: - return {} - - sensor_state = { - "name": data[0], - "numericCapabilities": {"rawValueUnit": data[1]}, - } + def create_sensor_state( + name: str, + raw_value_unit: str | None = None, + available_states: list[str] | None = None, + ) -> dict[str, Any]: + sensor_state: dict[str, Any] = { + "name": name, + } + if raw_value_unit: + sensor_state["numericCapabilities"] = {"rawValueUnit": raw_value_unit} + if available_states: + sensor_state["descriptiveCapabilities"] = { + "availableStates": available_states + } + return {"sensorStatesSupported": [sensor_state]} - if device_class == sensor.SensorDeviceClass.AQI: - sensor_state["descriptiveCapabilities"] = { - "availableStates": [ + if self.state.domain == sensor.DOMAIN: + sensor_data = self.sensor_types.get(device_class) + if device_class is None or sensor_data is None: + return {} + available_states: list[str] | None = None + if device_class == sensor.SensorDeviceClass.AQI: + available_states = [ "healthy", "moderate", "unhealthy for sensitive groups", @@ -2753,30 +2781,53 @@ def sync_attributes(self) -> dict[str, Any]: "very unhealthy", "hazardous", "unknown", - ], - } - - return {"sensorStatesSupported": [sensor_state]} + ] + return create_sensor_state(sensor_data[0], sensor_data[1], available_states) + binary_sensor_data = self.binary_sensor_types.get(device_class) + if device_class is None or binary_sensor_data is None: + return {} + return create_sensor_state( + binary_sensor_data[0], available_states=binary_sensor_data[1] + ) def query_attributes(self) -> dict[str, Any]: """Return the attributes of this trait for this entity.""" device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) - data = self.sensor_types.get(device_class) - - if device_class is None or data is None: - return {} - try: - value = float(self.state.state) - except ValueError: - value = None - if self.state.state == STATE_UNKNOWN: - value = None - sensor_data = {"name": data[0], "rawValue": value} - - if device_class == sensor.SensorDeviceClass.AQI: - sensor_data["currentSensorState"] = self._air_quality_description_for_aqi( - value - ) + def create_sensor_state( + name: str, raw_value: float | None = None, current_state: str | None = None + ) -> dict[str, Any]: + sensor_state: dict[str, Any] = { + "name": name, + "rawValue": raw_value, + } + if current_state: + sensor_state["currentSensorState"] = current_state + return {"currentSensorStateData": [sensor_state]} - return {"currentSensorStateData": [sensor_data]} + if self.state.domain == sensor.DOMAIN: + sensor_data = self.sensor_types.get(device_class) + if device_class is None or sensor_data is None: + return {} + try: + value = float(self.state.state) + except ValueError: + value = None + if self.state.state == STATE_UNKNOWN: + value = None + current_state: str | None = None + if device_class == sensor.SensorDeviceClass.AQI: + current_state = self._air_quality_description_for_aqi(value) + return create_sensor_state(sensor_data[0], value, current_state) + + binary_sensor_data = self.binary_sensor_types.get(device_class) + if device_class is None or binary_sensor_data is None: + return {} + value = { + STATE_ON: 0, + STATE_OFF: 1, + STATE_UNKNOWN: 2, + }[self.state.state] + return create_sensor_state( + binary_sensor_data[0], current_state=binary_sensor_data[1][value] + ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index f5dedc357c146..1e42edf8e7b2e 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -4069,3 +4069,90 @@ async def test_sensorstate( ) is False ) + + +@pytest.mark.parametrize( + ("state", "identifier"), + [ + (STATE_ON, 0), + (STATE_OFF, 1), + (STATE_UNKNOWN, 2), + ], +) +@pytest.mark.parametrize( + ("device_class", "name", "states"), + [ + ( + binary_sensor.BinarySensorDeviceClass.CO, + "CarbonMonoxideLevel", + ["carbon monoxide detected", "no carbon monoxide detected", "unknown"], + ), + ( + binary_sensor.BinarySensorDeviceClass.SMOKE, + "SmokeLevel", + ["smoke detected", "no smoke detected", "unknown"], + ), + ( + binary_sensor.BinarySensorDeviceClass.MOISTURE, + "WaterLeak", + ["leak", "no leak", "unknown"], + ), + ], +) +async def test_binary_sensorstate( + hass: HomeAssistant, + state: str, + identifier: int, + device_class: binary_sensor.BinarySensorDeviceClass, + name: str, + states: list[str], +) -> None: + """Test SensorState trait support for binary sensor domain.""" + + assert helpers.get_google_type(binary_sensor.DOMAIN, None) is not None + assert trait.SensorStateTrait.supported( + binary_sensor.DOMAIN, None, device_class, None + ) + + trt = trait.SensorStateTrait( + hass, + State( + "binary_sensor.test", + state, + { + "device_class": device_class, + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "sensorStatesSupported": [ + { + "name": name, + "descriptiveCapabilities": { + "availableStates": states, + }, + } + ] + } + assert trt.query_attributes() == { + "currentSensorStateData": [ + { + "name": name, + "currentSensorState": states[identifier], + "rawValue": None, + }, + ] + } + + assert helpers.get_google_type(binary_sensor.DOMAIN, None) is not None + assert ( + trait.SensorStateTrait.supported( + binary_sensor.DOMAIN, + None, + binary_sensor.BinarySensorDeviceClass.TAMPER, + None, + ) + is False + ) From c52a893e210cf36f9ae047d7bcdb15b3cc87af20 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 21:10:18 +0100 Subject: [PATCH 0355/1070] Remove YAML import from lcl integration after 6 months deprecation (#130305) --- homeassistant/components/lcn/__init__.py | 25 +----- homeassistant/components/lcn/config_flow.py | 54 +------------ homeassistant/components/lcn/schemas.py | 88 --------------------- tests/components/lcn/test_config_flow.py | 83 +------------------ tests/components/lcn/test_init.py | 27 ------- 5 files changed, 3 insertions(+), 274 deletions(-) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 5995e06efccce..27f911822b552 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -8,7 +8,7 @@ import pypck from pypck.connection import PchkConnectionManager -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, CONF_DOMAIN, @@ -21,7 +21,6 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.typing import ConfigType from .const import ( ADD_ENTITIES_CALLBACKS, @@ -39,37 +38,15 @@ InputType, async_update_config_entry, generate_unique_id, - import_lcn_config, register_lcn_address_devices, register_lcn_host_device, ) -from .schemas import CONFIG_SCHEMA # noqa: F401 from .services import SERVICES from .websocket import register_panel_and_ws_api _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the LCN component.""" - if DOMAIN not in config: - return True - - # initialize a config_flow for all LCN configurations read from - # configuration.yaml - config_entries_data = import_lcn_config(config[DOMAIN]) - - for config_entry_data in config_entries_data: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config_entry_data, - ) - ) - return True - - async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up a connection to PCHK host from a config entry.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index e78378a61b170..008265e62aebf 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import ( CONF_BASE, CONF_DEVICES, @@ -20,14 +19,12 @@ CONF_PORT, CONF_USERNAME, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from . import PchkConnectionManager from .const import CONF_ACKNOWLEDGE, CONF_DIM_MODE, CONF_SK_NUM_TRIES, DIM_MODES, DOMAIN -from .helpers import purge_device_registry, purge_entity_registry _LOGGER = logging.getLogger(__name__) @@ -113,55 +110,6 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 MINOR_VERSION = 1 - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import existing configuration from LCN.""" - # validate the imported connection parameters - if error := await validate_connection(import_data): - async_create_issue( - self.hass, - DOMAIN, - error, - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.ERROR, - translation_key=error, - translation_placeholders={ - "url": "/config/integrations/dashboard/add?domain=lcn" - }, - ) - return self.async_abort(reason=error) - - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.12.0", - is_fixable=False, - is_persistent=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "LCN", - }, - ) - - # check if we already have a host with the same address configured - if entry := get_config_entry(self.hass, import_data): - entry.source = config_entries.SOURCE_IMPORT - # Cleanup entity and device registry, if we imported from configuration.yaml to - # remove orphans when entities were removed from configuration - purge_entity_registry(self.hass, entry.entry_id, import_data) - purge_device_registry(self.hass, entry.entry_id, import_data) - - self.hass.config_entries.async_update_entry(entry, data=import_data) - return self.async_abort(reason="existing_configuration_updated") - - return self.async_create_entry( - title=f"{import_data[CONF_HOST]}", data=import_data - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> config_entries.ConfigFlowResult: diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py index 3b4d233397099..c9c91b9843dd3 100644 --- a/homeassistant/components/lcn/schemas.py +++ b/homeassistant/components/lcn/schemas.py @@ -4,20 +4,9 @@ from homeassistant.components.climate import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP from homeassistant.const import ( - CONF_ADDRESS, - CONF_BINARY_SENSORS, - CONF_COVERS, - CONF_HOST, - CONF_LIGHTS, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, CONF_SCENE, - CONF_SENSORS, CONF_SOURCE, - CONF_SWITCHES, CONF_UNIT_OF_MEASUREMENT, - CONF_USERNAME, UnitOfTemperature, ) import homeassistant.helpers.config_validation as cv @@ -25,9 +14,6 @@ from .const import ( BINSENSOR_PORTS, - CONF_CLIMATES, - CONF_CONNECTIONS, - CONF_DIM_MODE, CONF_DIMMABLE, CONF_LOCKABLE, CONF_MAX_TEMP, @@ -37,12 +23,8 @@ CONF_OUTPUTS, CONF_REGISTER, CONF_REVERSE_TIME, - CONF_SCENES, CONF_SETPOINT, - CONF_SK_NUM_TRIES, CONF_TRANSITION, - DIM_MODES, - DOMAIN, KEYS, LED_PORTS, LOGICOP_PORTS, @@ -56,7 +38,6 @@ VAR_UNITS, VARIABLES, ) -from .helpers import has_unique_host_names, is_address ADDRESS_SCHEMA = vol.Coerce(tuple) @@ -130,72 +111,3 @@ vol.In(OUTPUT_PORTS + RELAY_PORTS + SETPOINTS + KEYS), ), } - - -# -# Configuration -# - -DOMAIN_DATA_BASE: VolDictType = { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_ADDRESS): is_address, -} - -BINARY_SENSORS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_BINARY_SENSOR}) - -CLIMATES_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_CLIMATE}) - -COVERS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_COVER}) - -LIGHTS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_LIGHT}) - -SCENES_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_SCENE}) - -SENSORS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_SENSOR}) - -SWITCHES_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_SWITCH}) - -CONNECTION_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PORT): cv.port, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_SK_NUM_TRIES, default=0): cv.positive_int, - vol.Optional(CONF_DIM_MODE, default="steps50"): vol.All( - vol.Upper, vol.In(DIM_MODES) - ), - vol.Optional(CONF_NAME): cv.string, - } -) - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CONNECTIONS): vol.All( - cv.ensure_list, has_unique_host_names, [CONNECTION_SCHEMA] - ), - vol.Optional(CONF_BINARY_SENSORS): vol.All( - cv.ensure_list, [BINARY_SENSORS_SCHEMA] - ), - vol.Optional(CONF_CLIMATES): vol.All( - cv.ensure_list, [CLIMATES_SCHEMA] - ), - vol.Optional(CONF_COVERS): vol.All(cv.ensure_list, [COVERS_SCHEMA]), - vol.Optional(CONF_LIGHTS): vol.All(cv.ensure_list, [LIGHTS_SCHEMA]), - vol.Optional(CONF_SCENES): vol.All(cv.ensure_list, [SCENES_SCHEMA]), - vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [SENSORS_SCHEMA] - ), - vol.Optional(CONF_SWITCHES): vol.All( - cv.ensure_list, [SWITCHES_SCHEMA] - ), - }, - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) diff --git a/tests/components/lcn/test_config_flow.py b/tests/components/lcn/test_config_flow.py index 4ef83aeaf8a70..b7967c247ec44 100644 --- a/tests/components/lcn/test_config_flow.py +++ b/tests/components/lcn/test_config_flow.py @@ -23,9 +23,7 @@ CONF_PORT, CONF_USERNAME, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -48,83 +46,6 @@ } -async def test_step_import( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test for import step.""" - - with ( - patch("homeassistant.components.lcn.PchkConnectionManager.async_connect"), - patch("homeassistant.components.lcn.async_setup", return_value=True), - patch("homeassistant.components.lcn.async_setup_entry", return_value=True), - ): - data = IMPORT_DATA.copy() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "pchk" - assert result["data"] == IMPORT_DATA - assert issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" - ) - - -async def test_step_import_existing_host( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test for update of config_entry if imported host already exists.""" - - # Create config entry and add it to hass - mock_data = IMPORT_DATA.copy() - mock_data.update({CONF_SK_NUM_TRIES: 3, CONF_DIM_MODE: 50}) - mock_entry = MockConfigEntry(domain=DOMAIN, data=mock_data) - mock_entry.add_to_hass(hass) - # Initialize a config flow with different data but same host address - with patch("homeassistant.components.lcn.PchkConnectionManager.async_connect"): - imported_data = IMPORT_DATA.copy() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=imported_data - ) - - # Check if config entry was updated - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "existing_configuration_updated" - assert mock_entry.source == config_entries.SOURCE_IMPORT - assert mock_entry.data == IMPORT_DATA - assert issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" - ) - - -@pytest.mark.parametrize( - ("error", "reason"), - [ - (PchkAuthenticationError, "authentication_error"), - (PchkLicenseError, "license_error"), - (TimeoutError, "connection_refused"), - ], -) -async def test_step_import_error( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, error, reason -) -> None: - """Test for error in import is handled correctly.""" - with patch( - "homeassistant.components.lcn.PchkConnectionManager.async_connect", - side_effect=error, - ): - data = IMPORT_DATA.copy() - data.update({CONF_HOST: "pchk"}) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason - assert issue_registry.async_get_issue(DOMAIN, reason) - - async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" flow = LcnFlowHandler() @@ -140,7 +61,6 @@ async def test_step_user(hass: HomeAssistant) -> None: """Test for user step.""" with ( patch("homeassistant.components.lcn.PchkConnectionManager.async_connect"), - patch("homeassistant.components.lcn.async_setup", return_value=True), patch("homeassistant.components.lcn.async_setup_entry", return_value=True), ): data = CONNECTION_DATA.copy() @@ -210,7 +130,6 @@ async def test_step_reconfigure(hass: HomeAssistant, entry: MockConfigEntry) -> with ( patch("homeassistant.components.lcn.PchkConnectionManager.async_connect"), - patch("homeassistant.components.lcn.async_setup", return_value=True), patch("homeassistant.components.lcn.async_setup_entry", return_value=True), ): result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index 1bd225c5d47a0..2327635e35669 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -16,7 +16,6 @@ MockPchkConnectionManager, create_config_entry, init_integration, - setup_component, ) @@ -83,18 +82,6 @@ async def test_async_setup_entry_update( assert dummy_entity in entity_registry.entities.values() assert dummy_device in device_registry.devices.values() - # setup new entry with same data via import step (should cleanup dummy device) - with patch( - "homeassistant.components.lcn.config_flow.validate_connection", - return_value=None, - ): - await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=entry.data - ) - - assert dummy_device not in device_registry.devices.values() - assert dummy_entity not in entity_registry.entities.values() - @pytest.mark.parametrize( "exception", [PchkAuthenticationError, PchkLicenseError, TimeoutError] @@ -114,20 +101,6 @@ async def test_async_setup_entry_raises_authentication_error( assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_async_setup_from_configuration_yaml(hass: HomeAssistant) -> None: - """Test a successful setup using data from configuration.yaml.""" - with ( - patch( - "homeassistant.components.lcn.config_flow.validate_connection", - return_value=None, - ), - patch("homeassistant.components.lcn.async_setup_entry") as async_setup_entry, - ): - await setup_component(hass) - - assert async_setup_entry.await_count == 2 - - @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_migrate_1_1(hass: HomeAssistant, entry) -> None: """Test migration config entry.""" From de5437f61ec31a2803b4c551fff1531b8e80c97a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 22:12:31 +0100 Subject: [PATCH 0356/1070] Remove YAML warning for thethingsnetwork after warning for 6 months (#130307) --- .../components/thethingsnetwork/__init__.py | 42 +------------------ .../components/thethingsnetwork/strings.json | 6 --- .../components/thethingsnetwork/test_init.py | 16 ------- 3 files changed, 1 insertion(+), 63 deletions(-) diff --git a/homeassistant/components/thethingsnetwork/__init__.py b/homeassistant/components/thethingsnetwork/__init__.py index 253ce7a052e07..d3c6c8356cb71 100644 --- a/homeassistant/components/thethingsnetwork/__init__.py +++ b/homeassistant/components/thethingsnetwork/__init__.py @@ -2,55 +2,15 @@ import logging -import voluptuous as vol - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType -from .const import CONF_APP_ID, DOMAIN, PLATFORMS, TTN_API_HOST +from .const import DOMAIN, PLATFORMS, TTN_API_HOST from .coordinator import TTNCoordinator _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - { - # Configuration via yaml not longer supported - keeping to warn about migration - DOMAIN: vol.Schema( - { - vol.Required(CONF_APP_ID): cv.string, - vol.Required("access_key"): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Initialize of The Things Network component.""" - - if DOMAIN in config: - ir.async_create_issue( - hass, - DOMAIN, - "manual_migration", - breaks_in_ha_version="2024.12.0", - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key="manual_migration", - translation_placeholders={ - "domain": DOMAIN, - "v2_v3_migration_url": "https://www.thethingsnetwork.org/forum/c/v2-to-v3-upgrade/102", - "v2_deprecation_url": "https://www.thethingsnetwork.org/forum/t/the-things-network-v2-is-permanently-shutting-down-completed/50710", - }, - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with The Things Network.""" diff --git a/homeassistant/components/thethingsnetwork/strings.json b/homeassistant/components/thethingsnetwork/strings.json index 98572cb318c7f..f5a4fcef8fda0 100644 --- a/homeassistant/components/thethingsnetwork/strings.json +++ b/homeassistant/components/thethingsnetwork/strings.json @@ -22,11 +22,5 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } - }, - "issues": { - "manual_migration": { - "description": "Configuring {domain} using YAML was removed as part of migrating to [The Things Network v3]({v2_v3_migration_url}). [The Things Network v2 has shutted down]({v2_deprecation_url}).\n\nPlease remove the {domain} entry from the configuration.yaml and add re-add the integration using the config_flow", - "title": "The {domain} YAML configuration is not supported" - } } } diff --git a/tests/components/thethingsnetwork/test_init.py b/tests/components/thethingsnetwork/test_init.py index 1e0b64c933ddb..e39c764d5f97b 100644 --- a/tests/components/thethingsnetwork/test_init.py +++ b/tests/components/thethingsnetwork/test_init.py @@ -4,22 +4,6 @@ from ttn_client import TTNAuthError from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component - -from .conftest import DOMAIN - - -async def test_error_configuration( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test issue is logged when deprecated configuration is used.""" - await async_setup_component( - hass, DOMAIN, {DOMAIN: {"app_id": "123", "access_key": "42"}} - ) - await hass.async_block_till_done() - assert issue_registry.async_get_issue(DOMAIN, "manual_migration") @pytest.mark.parametrize(("exception_class"), [TTNAuthError, Exception]) From d7f41ff8a9a4a4f55f58e919020c57aea6eccd8e Mon Sep 17 00:00:00 2001 From: Max Shcherbina <17325179+maxshcherbina@users.noreply.github.com> Date: Sun, 10 Nov 2024 16:13:38 -0500 Subject: [PATCH 0357/1070] Update generic thermostat strings for clarity and accuracy (#130243) --- homeassistant/components/generic_thermostat/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json index 1ddd41de73445..51549dc844e2a 100644 --- a/homeassistant/components/generic_thermostat/strings.json +++ b/homeassistant/components/generic_thermostat/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Add generic thermostat helper", + "title": "Add generic thermostat", "description": "Create a climate entity that controls the temperature via a switch and sensor.", "data": { "ac_mode": "Cooling mode", @@ -17,8 +17,8 @@ "data_description": { "ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.", "heater": "Switch entity used to cool or heat depending on A/C mode.", - "target_sensor": "Temperature sensor that reflect the current temperature.", - "min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on. This option will be ignored if the keep alive option is set.", + "target_sensor": "Temperature sensor that reflects the current temperature.", + "min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.", "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.", "hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5." } From e040eb0ff21e7646a793a0697552aff2a7beb975 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 22:26:00 +0100 Subject: [PATCH 0358/1070] Remove extra state attributes from some QNAP sensors (#130310) --- homeassistant/components/qnap/sensor.py | 61 ------------------------- 1 file changed, 61 deletions(-) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 526516bfcdda3..383a4e5f57260 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -13,7 +13,6 @@ SensorStateClass, ) from homeassistant.const import ( - ATTR_NAME, PERCENTAGE, EntityCategory, UnitOfDataRate, @@ -375,17 +374,6 @@ def native_value(self): return None - # Deprecated since Home Assistant 2024.6.0 - # Can be removed completely in 2024.12.0 - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - if self.coordinator.data: - data = self.coordinator.data["system_stats"]["memory"] - size = round(float(data["total"]) / 1024, 2) - return {ATTR_MEMORY_SIZE: f"{size} {UnitOfInformation.GIBIBYTES}"} - return None - class QNAPNetworkSensor(QNAPSensor): """A QNAP sensor that monitors network stats.""" @@ -414,22 +402,6 @@ def native_value(self): return None - # Deprecated since Home Assistant 2024.6.0 - # Can be removed completely in 2024.12.0 - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - if self.coordinator.data: - data = self.coordinator.data["system_stats"]["nics"][self.monitor_device] - return { - ATTR_IP: data["ip"], - ATTR_MASK: data["mask"], - ATTR_MAC: data["mac"], - ATTR_MAX_SPEED: data["max_speed"], - ATTR_PACKETS_ERR: data["err_packets"], - } - return None - class QNAPSystemSensor(QNAPSensor): """A QNAP sensor that monitors overall system health.""" @@ -455,25 +427,6 @@ def native_value(self): return None - # Deprecated since Home Assistant 2024.6.0 - # Can be removed completely in 2024.12.0 - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - if self.coordinator.data: - data = self.coordinator.data["system_stats"] - days = int(data["uptime"]["days"]) - hours = int(data["uptime"]["hours"]) - minutes = int(data["uptime"]["minutes"]) - - return { - ATTR_NAME: data["system"]["name"], - ATTR_MODEL: data["system"]["model"], - ATTR_SERIAL: data["system"]["serial_number"], - ATTR_UPTIME: f"{days:0>2d}d {hours:0>2d}h {minutes:0>2d}m", - } - return None - class QNAPDriveSensor(QNAPSensor): """A QNAP sensor that monitors HDD/SSD drive stats.""" @@ -533,17 +486,3 @@ def native_value(self): return used_gb / total_gb * 100 return None - - # Deprecated since Home Assistant 2024.6.0 - # Can be removed completely in 2024.12.0 - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - if self.coordinator.data: - data = self.coordinator.data["volumes"][self.monitor_device] - total_gb = int(data["total_size"]) / 1024 / 1024 / 1024 - - return { - ATTR_VOLUME_SIZE: f"{round(total_gb, 1)} {UnitOfInformation.GIBIBYTES}" - } - return None From 85bf8d1374343d96a76603784ef28787e333b7e8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 10 Nov 2024 23:40:23 +0100 Subject: [PATCH 0359/1070] Fix Homekit error handling alarm state unknown or unavailable (#130311) --- .../homekit/type_security_systems.py | 12 +++--- .../homekit/test_type_security_systems.py | 37 ++++++++++++++++++- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index 9f3f183f11fd4..8634589cb5f57 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -18,6 +18,8 @@ SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_DISARM, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import State, callback @@ -152,12 +154,12 @@ def set_security_state(self, value: int) -> None: @callback def async_update_state(self, new_state: State) -> None: """Update security state after state changed.""" - hass_state = None - if new_state and new_state.state == "None": - # Bail out early for no state + hass_state: str | AlarmControlPanelState = new_state.state + if hass_state in {"None", STATE_UNKNOWN, STATE_UNAVAILABLE}: + # Bail out early for no state, unknown or unavailable return - if new_state and new_state.state is not None: - hass_state = AlarmControlPanelState(new_state.state) + if hass_state is not None: + hass_state = AlarmControlPanelState(hass_state) if ( hass_state and (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index 8377d847a7acd..94b0e68e76d6e 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -10,7 +10,12 @@ ) from homeassistant.components.homekit.const import ATTR_VALUE from homeassistant.components.homekit.type_security_systems import SecuritySystem -from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_CODE, + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import Event, HomeAssistant from tests.common import async_mock_service @@ -307,3 +312,33 @@ async def test_supported_states(hass: HomeAssistant, hk_driver) -> None: for val in valid_target_values.values(): assert val in test_config.get("target_values") + + +@pytest.mark.parametrize( + ("state"), + [ + (None), + ("None"), + (STATE_UNKNOWN), + (STATE_UNAVAILABLE), + ], +) +async def test_handle_non_alarm_states( + hass: HomeAssistant, hk_driver, events: list[Event], state: str +) -> None: + """Test we can handle states that should not raise.""" + code = "1234" + config = {ATTR_CODE: code} + entity_id = "alarm_control_panel.test" + + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, config) + acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 11 # AlarmSystem + + assert acc.char_current_state.value == 3 + assert acc.char_target_state.value == 3 From c3492bc0ed6d95de9fe00b4d17f2c616263f49fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 08:14:42 +0100 Subject: [PATCH 0360/1070] Bump github/codeql-action from 3.27.0 to 3.27.1 (#130323) --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 176e010c5b976..2c80c32245c06 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.27.0 + uses: github/codeql-action/init@v3.27.1 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.27.0 + uses: github/codeql-action/analyze@v3.27.1 with: category: "/language:python" From 0dd208a4b93f409cbda7bfdf40ae93d7611ce043 Mon Sep 17 00:00:00 2001 From: Nerdix <70015952+N3rdix@users.noreply.github.com> Date: Mon, 11 Nov 2024 09:07:47 +0100 Subject: [PATCH 0361/1070] Add alarm count sensor for Kostal Inverters (#130324) --- homeassistant/components/kostal_plenticore/sensor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index fbbfb03fb3eb3..67de34f2fce77 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -17,6 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -747,6 +748,15 @@ class PlenticoreSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), + PlenticoreSensorEntityDescription( + module_id="scb:event", + key="Event:ActiveErrorCnt", + name="Active Alarms", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + icon="mdi:alert", + formatter="format_round", + ), PlenticoreSensorEntityDescription( module_id="_virt_", key="pv_P", From 1e26cf13d64ea50e904819a296d1a449b5169ede Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Mon, 11 Nov 2024 10:59:50 +0100 Subject: [PATCH 0362/1070] Use runtime data for eq3btsmart (#130334) --- .../components/eq3btsmart/__init__.py | 41 ++++++++++--------- .../components/eq3btsmart/climate.py | 17 +++----- homeassistant/components/eq3btsmart/entity.py | 10 ++--- 3 files changed, 31 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index f63e627ea7dfb..bdba17dcca54c 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -15,7 +15,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN, SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED +from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED from .models import Eq3Config, Eq3ConfigEntryData PLATFORMS = [ @@ -25,7 +25,10 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type Eq3ConfigEntry = ConfigEntry[Eq3ConfigEntryData] + + +async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool: """Handle config entry setup.""" mac_address: str | None = entry.unique_id @@ -53,12 +56,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ble_device=device, ) - eq3_config_entry = Eq3ConfigEntryData(eq3_config=eq3_config, thermostat=thermostat) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = eq3_config_entry - + entry.runtime_data = Eq3ConfigEntryData( + eq3_config=eq3_config, thermostat=thermostat + ) entry.async_on_unload(entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_create_background_task( hass, _async_run_thermostat(hass, entry), entry.entry_id ) @@ -66,29 +68,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool: """Handle config entry unload.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN].pop(entry.entry_id) - await eq3_config_entry.thermostat.async_disconnect() + await entry.runtime_data.thermostat.async_disconnect() return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: Eq3ConfigEntry) -> None: """Handle config entry update.""" await hass.config_entries.async_reload(entry.entry_id) -async def _async_run_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_run_thermostat(hass: HomeAssistant, entry: Eq3ConfigEntry) -> None: """Run the thermostat.""" - eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][entry.entry_id] - thermostat = eq3_config_entry.thermostat - mac_address = eq3_config_entry.eq3_config.mac_address - scan_interval = eq3_config_entry.eq3_config.scan_interval + thermostat = entry.runtime_data.thermostat + mac_address = entry.runtime_data.eq3_config.mac_address + scan_interval = entry.runtime_data.eq3_config.scan_interval await _async_reconnect_thermostat(hass, entry) @@ -117,13 +117,14 @@ async def _async_run_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None await asyncio.sleep(scan_interval) -async def _async_reconnect_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_reconnect_thermostat( + hass: HomeAssistant, entry: Eq3ConfigEntry +) -> None: """Reconnect the thermostat.""" - eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][entry.entry_id] - thermostat = eq3_config_entry.thermostat - mac_address = eq3_config_entry.eq3_config.mac_address - scan_interval = eq3_config_entry.eq3_config.scan_interval + thermostat = entry.runtime_data.thermostat + mac_address = entry.runtime_data.eq3_config.mac_address + scan_interval = entry.runtime_data.eq3_config.scan_interval while True: try: diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index 9984c4f7229cd..9153d0f97cfdc 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -3,7 +3,6 @@ import logging from typing import Any -from eq3btsmart import Thermostat from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Eq3Preset, OperationMode from eq3btsmart.exceptions import Eq3Exception @@ -15,7 +14,6 @@ HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError @@ -25,9 +23,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify +from . import Eq3ConfigEntry from .const import ( DEVICE_MODEL, - DOMAIN, EQ_TO_HA_HVAC, HA_TO_EQ_HVAC, MANUFACTURER, @@ -38,22 +36,19 @@ TargetTemperatureSelector, ) from .entity import Eq3Entity -from .models import Eq3Config, Eq3ConfigEntryData _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: Eq3ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Handle config entry setup.""" - eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - [Eq3Climate(eq3_config_entry.eq3_config, eq3_config_entry.thermostat)], + [Eq3Climate(entry)], ) @@ -80,11 +75,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity): _attr_preset_mode: str | None = None _target_temperature: float | None = None - def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat) -> None: + def __init__(self, entry: Eq3ConfigEntry) -> None: """Initialize the climate entity.""" - super().__init__(eq3_config, thermostat) - self._attr_unique_id = dr.format_mac(eq3_config.mac_address) + super().__init__(entry) + self._attr_unique_id = dr.format_mac(self._eq3_config.mac_address) self._attr_device_info = DeviceInfo( name=slugify(self._eq3_config.mac_address), manufacturer=MANUFACTURER, diff --git a/homeassistant/components/eq3btsmart/entity.py b/homeassistant/components/eq3btsmart/entity.py index e8c00d4e3cf36..020913176fbbd 100644 --- a/homeassistant/components/eq3btsmart/entity.py +++ b/homeassistant/components/eq3btsmart/entity.py @@ -1,10 +1,8 @@ """Base class for all eQ-3 entities.""" -from eq3btsmart.thermostat import Thermostat - from homeassistant.helpers.entity import Entity -from .models import Eq3Config +from . import Eq3ConfigEntry class Eq3Entity(Entity): @@ -12,8 +10,8 @@ class Eq3Entity(Entity): _attr_has_entity_name = True - def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat) -> None: + def __init__(self, entry: Eq3ConfigEntry) -> None: """Initialize the eq3 entity.""" - self._eq3_config = eq3_config - self._thermostat = thermostat + self._eq3_config = entry.runtime_data.eq3_config + self._thermostat = entry.runtime_data.thermostat From 5497c440d90cbfff668908947ed79202520cec84 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Mon, 11 Nov 2024 11:46:11 +0100 Subject: [PATCH 0363/1070] Prepare eq3btsmart base entity for additional platforms (#130340) --- .../components/eq3btsmart/climate.py | 57 +--------------- homeassistant/components/eq3btsmart/const.py | 1 - homeassistant/components/eq3btsmart/entity.py | 68 ++++++++++++++++++- 3 files changed, 69 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index 9153d0f97cfdc..ae01d0fc9a7fc 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -18,19 +18,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify from . import Eq3ConfigEntry from .const import ( - DEVICE_MODEL, EQ_TO_HA_HVAC, HA_TO_EQ_HVAC, - MANUFACTURER, - SIGNAL_THERMOSTAT_CONNECTED, - SIGNAL_THERMOSTAT_DISCONNECTED, CurrentTemperatureSelector, Preset, TargetTemperatureSelector, @@ -75,53 +69,6 @@ class Eq3Climate(Eq3Entity, ClimateEntity): _attr_preset_mode: str | None = None _target_temperature: float | None = None - def __init__(self, entry: Eq3ConfigEntry) -> None: - """Initialize the climate entity.""" - - super().__init__(entry) - self._attr_unique_id = dr.format_mac(self._eq3_config.mac_address) - self._attr_device_info = DeviceInfo( - name=slugify(self._eq3_config.mac_address), - manufacturer=MANUFACTURER, - model=DEVICE_MODEL, - connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, - ) - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - - self._thermostat.register_update_callback(self._async_on_updated) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SIGNAL_THERMOSTAT_DISCONNECTED}_{self._eq3_config.mac_address}", - self._async_on_disconnected, - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SIGNAL_THERMOSTAT_CONNECTED}_{self._eq3_config.mac_address}", - self._async_on_connected, - ) - ) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - - self._thermostat.unregister_update_callback(self._async_on_updated) - - @callback - def _async_on_disconnected(self) -> None: - self._attr_available = False - self.async_write_ha_state() - - @callback - def _async_on_connected(self) -> None: - self._attr_available = True - self.async_write_ha_state() - @callback def _async_on_updated(self) -> None: """Handle updated data from the thermostat.""" @@ -132,7 +79,7 @@ def _async_on_updated(self) -> None: if self._thermostat.device_data is not None: self._async_on_device_updated() - self.async_write_ha_state() + super()._async_on_updated() @callback def _async_on_status_updated(self) -> None: diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py index 111c4d0eba47b..bb3c8b58119c4 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -20,7 +20,6 @@ GET_DEVICE_TIMEOUT = 5 # seconds - EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = { OperationMode.OFF: HVACMode.OFF, OperationMode.ON: HVACMode.HEAT, diff --git a/homeassistant/components/eq3btsmart/entity.py b/homeassistant/components/eq3btsmart/entity.py index 020913176fbbd..5a229c632b221 100644 --- a/homeassistant/components/eq3btsmart/entity.py +++ b/homeassistant/components/eq3btsmart/entity.py @@ -1,8 +1,22 @@ """Base class for all eQ-3 entities.""" +from homeassistant.core import callback +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify from . import Eq3ConfigEntry +from .const import ( + DEVICE_MODEL, + MANUFACTURER, + SIGNAL_THERMOSTAT_CONNECTED, + SIGNAL_THERMOSTAT_DISCONNECTED, +) class Eq3Entity(Entity): @@ -10,8 +24,60 @@ class Eq3Entity(Entity): _attr_has_entity_name = True - def __init__(self, entry: Eq3ConfigEntry) -> None: + def __init__(self, entry: Eq3ConfigEntry, unique_id_key: str | None = None) -> None: """Initialize the eq3 entity.""" self._eq3_config = entry.runtime_data.eq3_config self._thermostat = entry.runtime_data.thermostat + self._attr_device_info = DeviceInfo( + name=slugify(self._eq3_config.mac_address), + manufacturer=MANUFACTURER, + model=DEVICE_MODEL, + connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, + ) + suffix = f"_{unique_id_key}" if unique_id_key else "" + self._attr_unique_id = f"{format_mac(self._eq3_config.mac_address)}{suffix}" + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + self._thermostat.register_update_callback(self._async_on_updated) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SIGNAL_THERMOSTAT_DISCONNECTED}_{self._eq3_config.mac_address}", + self._async_on_disconnected, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SIGNAL_THERMOSTAT_CONNECTED}_{self._eq3_config.mac_address}", + self._async_on_connected, + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + + self._thermostat.unregister_update_callback(self._async_on_updated) + + def _async_on_updated(self) -> None: + """Handle updated data from the thermostat.""" + + self.async_write_ha_state() + + @callback + def _async_on_disconnected(self) -> None: + """Handle disconnection from the thermostat.""" + + self._attr_available = False + self.async_write_ha_state() + + @callback + def _async_on_connected(self) -> None: + """Handle connection to the thermostat.""" + + self._attr_available = True + self.async_write_ha_state() From 88480d154a9a53b7227a67bca2aa5875085548b8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Nov 2024 12:10:49 +0100 Subject: [PATCH 0364/1070] Fix typo in BaseBackupManager.async_restore_backup (#130329) --- homeassistant/components/backup/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index b3cb69861b987..8265dade3aae7 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -126,7 +126,7 @@ async def load_platforms(self) -> None: @abc.abstractmethod async def async_restore_backup(self, slug: str, **kwargs: Any) -> None: - """Restpre a backup.""" + """Restore a backup.""" @abc.abstractmethod async def async_create_backup(self, **kwargs: Any) -> Backup: From 7a4dac1eb1b504ca0359e0db859315c82ba3a74e Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 11 Nov 2024 12:46:02 +0100 Subject: [PATCH 0365/1070] Add Spotify and Tidal to playingmode mapping (#130351) --- homeassistant/components/linkplay/media_player.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index a625412852eef..ab11a47f07e6a 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -69,6 +69,8 @@ PlayingMode.FM: "FM Radio", PlayingMode.RCA: "RCA", PlayingMode.UDISK: "USB", + PlayingMode.SPOTIFY: "Spotify", + PlayingMode.TIDAL: "Tidal", PlayingMode.FOLLOWER: "Follower", } From 870bf388e06903d5ca06585df622efcefe421fc7 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 11 Nov 2024 12:49:56 +0100 Subject: [PATCH 0366/1070] Add seek support to LinkPlay (#130349) --- homeassistant/components/linkplay/media_player.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index ab11a47f07e6a..c29c29785228f 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -298,6 +298,11 @@ async def async_play_preset(self, preset_number: int) -> None: except ValueError as err: raise HomeAssistantError(err) from err + @exception_wrap + async def async_media_seek(self, position: float) -> None: + """Seek to a position.""" + await self._bridge.player.seek(round(position)) + @exception_wrap async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" @@ -383,9 +388,9 @@ def _update_properties(self) -> None: ) self._attr_source = SOURCE_MAP.get(self._bridge.player.play_mode, "other") - self._attr_media_position = self._bridge.player.current_position / 1000 + self._attr_media_position = self._bridge.player.current_position_in_seconds self._attr_media_position_updated_at = utcnow() - self._attr_media_duration = self._bridge.player.total_length / 1000 + self._attr_media_duration = self._bridge.player.total_length_in_seconds self._attr_media_artist = self._bridge.player.artist self._attr_media_title = self._bridge.player.title self._attr_media_album_name = self._bridge.player.album From 5293fc73d80017f63564f6a6503c50df4406dad5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Nov 2024 13:21:16 +0100 Subject: [PATCH 0367/1070] Sort some code in cloud preferences (#130345) Sort some code in cloud prefs --- homeassistant/components/cloud/http_api.py | 8 ++--- homeassistant/components/cloud/prefs.py | 42 +++++++++++----------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 844f0e9f11d87..4f2ad0ddcf7bc 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -440,16 +440,16 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]: @websocket_api.websocket_command( { vol.Required("type"): "cloud/update_prefs", - vol.Optional(PREF_ENABLE_GOOGLE): bool, - vol.Optional(PREF_ENABLE_ALEXA): bool, vol.Optional(PREF_ALEXA_REPORT_STATE): bool, + vol.Optional(PREF_ENABLE_ALEXA): bool, + vol.Optional(PREF_ENABLE_CLOUD_ICE_SERVERS): bool, + vol.Optional(PREF_ENABLE_GOOGLE): bool, vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), + vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool, vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Coerce(tuple), validate_language_voice ), - vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool, - vol.Optional(PREF_ENABLE_CLOUD_ICE_SERVERS): bool, } ) @websocket_api.async_response diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index a08113930972c..ae4b2794e1b24 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -163,21 +163,21 @@ def unsubscribe() -> None: async def async_update( self, *, - google_enabled: bool | UndefinedType = UNDEFINED, alexa_enabled: bool | UndefinedType = UNDEFINED, - remote_enabled: bool | UndefinedType = UNDEFINED, - google_secure_devices_pin: str | None | UndefinedType = UNDEFINED, - cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED, - cloud_user: str | UndefinedType = UNDEFINED, alexa_report_state: bool | UndefinedType = UNDEFINED, - google_report_state: bool | UndefinedType = UNDEFINED, - tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED, - remote_domain: str | None | UndefinedType = UNDEFINED, alexa_settings_version: int | UndefinedType = UNDEFINED, - google_settings_version: int | UndefinedType = UNDEFINED, + cloud_ice_servers_enabled: bool | UndefinedType = UNDEFINED, + cloud_user: str | UndefinedType = UNDEFINED, + cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED, google_connected: bool | UndefinedType = UNDEFINED, + google_enabled: bool | UndefinedType = UNDEFINED, + google_report_state: bool | UndefinedType = UNDEFINED, + google_secure_devices_pin: str | None | UndefinedType = UNDEFINED, + google_settings_version: int | UndefinedType = UNDEFINED, remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, - cloud_ice_servers_enabled: bool | UndefinedType = UNDEFINED, + remote_domain: str | None | UndefinedType = UNDEFINED, + remote_enabled: bool | UndefinedType = UNDEFINED, + tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED, ) -> None: """Update user preferences.""" prefs = {**self._prefs} @@ -186,21 +186,21 @@ async def async_update( { key: value for key, value in ( - (PREF_ENABLE_GOOGLE, google_enabled), + (PREF_ALEXA_REPORT_STATE, alexa_report_state), + (PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version), + (PREF_CLOUD_USER, cloud_user), + (PREF_CLOUDHOOKS, cloudhooks), (PREF_ENABLE_ALEXA, alexa_enabled), + (PREF_ENABLE_CLOUD_ICE_SERVERS, cloud_ice_servers_enabled), + (PREF_ENABLE_GOOGLE, google_enabled), (PREF_ENABLE_REMOTE, remote_enabled), - (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), - (PREF_CLOUDHOOKS, cloudhooks), - (PREF_CLOUD_USER, cloud_user), - (PREF_ALEXA_REPORT_STATE, alexa_report_state), + (PREF_GOOGLE_CONNECTED, google_connected), (PREF_GOOGLE_REPORT_STATE, google_report_state), - (PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version), + (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), - (PREF_TTS_DEFAULT_VOICE, tts_default_voice), - (PREF_REMOTE_DOMAIN, remote_domain), - (PREF_GOOGLE_CONNECTED, google_connected), (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable), - (PREF_ENABLE_CLOUD_ICE_SERVERS, cloud_ice_servers_enabled), + (PREF_REMOTE_DOMAIN, remote_domain), + (PREF_TTS_DEFAULT_VOICE, tts_default_voice), ) if value is not UNDEFINED } @@ -242,6 +242,7 @@ def as_dict(self) -> dict[str, Any]: PREF_ALEXA_REPORT_STATE: self.alexa_report_state, PREF_CLOUDHOOKS: self.cloudhooks, PREF_ENABLE_ALEXA: self.alexa_enabled, + PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled, PREF_ENABLE_GOOGLE: self.google_enabled, PREF_ENABLE_REMOTE: self.remote_enabled, PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, @@ -249,7 +250,6 @@ def as_dict(self) -> dict[str, Any]: PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, - PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled, } @property From 829632b0aff80357d52e20b31efa1d54a535fa7f Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Mon, 11 Nov 2024 14:27:52 +0100 Subject: [PATCH 0368/1070] Add binary sensor platform to eq3btsmart (#130352) --- .../components/eq3btsmart/__init__.py | 1 + .../components/eq3btsmart/binary_sensor.py | 86 +++++++++++++++++++ homeassistant/components/eq3btsmart/const.py | 4 + homeassistant/components/eq3btsmart/entity.py | 12 ++- .../components/eq3btsmart/strings.json | 7 ++ 5 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/eq3btsmart/binary_sensor.py diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index bdba17dcca54c..78296c70cef7d 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -19,6 +19,7 @@ from .models import Eq3Config, Eq3ConfigEntryData PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.CLIMATE, ] diff --git a/homeassistant/components/eq3btsmart/binary_sensor.py b/homeassistant/components/eq3btsmart/binary_sensor.py new file mode 100644 index 0000000000000..27525d47972da --- /dev/null +++ b/homeassistant/components/eq3btsmart/binary_sensor.py @@ -0,0 +1,86 @@ +"""Platform for eq3 binary sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from eq3btsmart.models import Status + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Eq3ConfigEntry +from .const import ENTITY_KEY_BATTERY, ENTITY_KEY_DST, ENTITY_KEY_WINDOW +from .entity import Eq3Entity + + +@dataclass(frozen=True, kw_only=True) +class Eq3BinarySensorEntityDescription(BinarySensorEntityDescription): + """Entity description for eq3 binary sensors.""" + + value_func: Callable[[Status], bool] + + +BINARY_SENSOR_ENTITY_DESCRIPTIONS = [ + Eq3BinarySensorEntityDescription( + value_func=lambda status: status.is_low_battery, + key=ENTITY_KEY_BATTERY, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + Eq3BinarySensorEntityDescription( + value_func=lambda status: status.is_window_open, + key=ENTITY_KEY_WINDOW, + device_class=BinarySensorDeviceClass.WINDOW, + ), + Eq3BinarySensorEntityDescription( + value_func=lambda status: status.is_dst, + key=ENTITY_KEY_DST, + translation_key=ENTITY_KEY_DST, + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: Eq3ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the entry.""" + + async_add_entities( + Eq3BinarySensorEntity(entry, entity_description) + for entity_description in BINARY_SENSOR_ENTITY_DESCRIPTIONS + ) + + +class Eq3BinarySensorEntity(Eq3Entity, BinarySensorEntity): + """Base class for eQ-3 binary sensor entities.""" + + entity_description: Eq3BinarySensorEntityDescription + + def __init__( + self, + entry: Eq3ConfigEntry, + entity_description: Eq3BinarySensorEntityDescription, + ) -> None: + """Initialize the entity.""" + + super().__init__(entry, entity_description.key) + self.entity_description = entity_description + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + + if TYPE_CHECKING: + assert self._thermostat.status is not None + + return self.entity_description.value_func(self._thermostat.status) diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py index bb3c8b58119c4..33d8e6b3ceedc 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -18,6 +18,10 @@ MANUFACTURER = "eQ-3 AG" DEVICE_MODEL = "CC-RT-BLE-EQ" +ENTITY_KEY_DST = "dst" +ENTITY_KEY_BATTERY = "battery" +ENTITY_KEY_WINDOW = "window" + GET_DEVICE_TIMEOUT = 5 # seconds EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = { diff --git a/homeassistant/components/eq3btsmart/entity.py b/homeassistant/components/eq3btsmart/entity.py index 5a229c632b221..e68545c08c7e5 100644 --- a/homeassistant/components/eq3btsmart/entity.py +++ b/homeassistant/components/eq3btsmart/entity.py @@ -24,7 +24,11 @@ class Eq3Entity(Entity): _attr_has_entity_name = True - def __init__(self, entry: Eq3ConfigEntry, unique_id_key: str | None = None) -> None: + def __init__( + self, + entry: Eq3ConfigEntry, + unique_id_key: str | None = None, + ) -> None: """Initialize the eq3 entity.""" self._eq3_config = entry.runtime_data.eq3_config @@ -81,3 +85,9 @@ def _async_on_connected(self) -> None: self._attr_available = True self.async_write_ha_state() + + @property + def available(self) -> bool: + """Whether the entity is available.""" + + return self._thermostat.status is not None and self._attr_available diff --git a/homeassistant/components/eq3btsmart/strings.json b/homeassistant/components/eq3btsmart/strings.json index 5108baa1bcfcf..c911be099d5e5 100644 --- a/homeassistant/components/eq3btsmart/strings.json +++ b/homeassistant/components/eq3btsmart/strings.json @@ -18,5 +18,12 @@ "error": { "invalid_mac_address": "Invalid MAC address" } + }, + "entity": { + "binary_sensor": { + "dst": { + "name": "Daylight saving time" + } + } } } From 41c6eeedca66a2bdb98257746db5b6e94f0a5588 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 11 Nov 2024 15:41:18 +0100 Subject: [PATCH 0369/1070] Bump deebot-client to 8.4.1 (#130357) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 33977b3b0ded5..0ab9f9a461271 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==8.4.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==8.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7cf0190a6aa4b..ff2e42fe779c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -735,7 +735,7 @@ debugpy==1.8.6 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==8.4.0 +deebot-client==8.4.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9332c74adc31b..7e0be99a6827d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -625,7 +625,7 @@ dbus-fast==2.24.3 debugpy==1.8.6 # homeassistant.components.ecovacs -deebot-client==8.4.0 +deebot-client==8.4.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 388c5807ea3339d51aea5aac01bd325f4c2ead67 Mon Sep 17 00:00:00 2001 From: Erik Elkins Date: Mon, 11 Nov 2024 09:10:52 -0600 Subject: [PATCH 0370/1070] Add Switchbot Hub 2, Switchbot Meter Pro and Switchbot Meter Pro (CO2) devices to Switchbot Cloud integration. (#130295) --- .../components/switchbot_cloud/__init__.py | 3 +++ .../components/switchbot_cloud/sensor.py | 23 +++++++++++++++++-- tests/components/switchbot_cloud/test_init.py | 12 ++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index a2738ed446fa8..625b4698301e3 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -85,6 +85,9 @@ def make_device_data( "Meter", "MeterPlus", "WoIOSensor", + "Hub 2", + "MeterPro", + "MeterPro(CO2)", ]: devices_data.sensors.append( prepare_device(hass, api, device, coordinators_by_id) diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index ac612aea1194a..90135ad96b349 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -9,7 +9,11 @@ SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -21,6 +25,7 @@ SENSOR_TYPE_TEMPERATURE = "temperature" SENSOR_TYPE_HUMIDITY = "humidity" SENSOR_TYPE_BATTERY = "battery" +SENSOR_TYPE_CO2 = "CO2" METER_PLUS_SENSOR_DESCRIPTIONS = ( SensorEntityDescription( @@ -43,6 +48,16 @@ ), ) +METER_PRO_CO2_SENSOR_DESCRIPTIONS = ( + *METER_PLUS_SENSOR_DESCRIPTIONS, + SensorEntityDescription( + key=SENSOR_TYPE_CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CO2, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -55,7 +70,11 @@ async def async_setup_entry( async_add_entities( SwitchBotCloudSensor(data.api, device, coordinator, description) for device, coordinator in data.devices.sensors - for description in METER_PLUS_SENSOR_DESCRIPTIONS + for description in ( + METER_PRO_CO2_SENSOR_DESCRIPTIONS + if device.device_type == "MeterPro(CO2)" + else METER_PLUS_SENSOR_DESCRIPTIONS + ) ) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 25ea370efe54e..43431ae04c0ef 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -50,6 +50,18 @@ async def test_setup_entry_success( remoteType="DIY Plug", hubDeviceId="test-hub-id", ), + Remote( + deviceId="meter-pro-1", + deviceName="meter-pro-name-1", + deviceType="MeterPro(CO2)", + hubDeviceId="test-hub-id", + ), + Remote( + deviceId="hub2-1", + deviceName="hub2-name-1", + deviceType="Hub 2", + hubDeviceId="test-hub-id", + ), ] mock_get_status.return_value = {"power": PowerState.ON.value} entry = configure_integration(hass) From c96f1c87a627efec413a8d140f373bcd8153df8a Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:30:27 +0100 Subject: [PATCH 0371/1070] Bump python-linkplay to 0.0.20 (#130348) --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index 9ddb6abf09398..e74d22b8207e8 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.0.18"], + "requirements": ["python-linkplay==0.0.20"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ff2e42fe779c7..4582dc3f50dc4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2362,7 +2362,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay -python-linkplay==0.0.18 +python-linkplay==0.0.20 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e0be99a6827d..4495e8a2c219a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1889,7 +1889,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay -python-linkplay==0.0.18 +python-linkplay==0.0.20 # homeassistant.components.matter python-matter-server==6.6.0 From e797149a168e81ae8af18bb1ebb3da7f60de7afb Mon Sep 17 00:00:00 2001 From: Olivier Corradi <1655848+corradio@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:34:29 +0100 Subject: [PATCH 0372/1070] Rename "CO2 Signal" display name to Electricity Maps for consistency (#130242) * Update strings.json for Electricity Maps * Update strings.json * Update config_flow.py * Update test_config_flow.py * Fix test --- homeassistant/components/co2signal/config_flow.py | 2 +- tests/components/co2signal/test_config_flow.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 622c09f0d3826..0d357cce1993c 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -168,7 +168,7 @@ async def _validate_and_create( ) return self.async_create_entry( - title=get_extra_name(data) or "CO2 Signal", + title=get_extra_name(data) or "Electricity Maps", data=data, ) diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index 92d9450b6703e..f8f94d4412648 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -44,7 +44,7 @@ async def test_form_home(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "CO2 Signal" + assert result2["title"] == "Electricity Maps" assert result2["data"] == { "api_key": "api_key", } @@ -185,7 +185,7 @@ async def test_form_error_handling( await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "CO2 Signal" + assert result["title"] == "Electricity Maps" assert result["data"] == { "api_key": "api_key", } From e56dec2c8efd8786e6e9fc1ab19670602174c8e0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Nov 2024 17:35:54 +0100 Subject: [PATCH 0373/1070] Bump spotifyaio to 0.8.8 (#130372) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index afe352904cebd..8f8f7e0d5882a 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.7"], + "requirements": ["spotifyaio==0.8.8"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 4582dc3f50dc4..fe737af17e701 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2713,7 +2713,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.7 +spotifyaio==0.8.8 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4495e8a2c219a..ae4d027dc8f3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2165,7 +2165,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.7 +spotifyaio==0.8.8 # homeassistant.components.sql sqlparse==0.5.0 From 0cc50bc7bc267407bb9ab5296365391d56739b54 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Nov 2024 18:09:06 +0100 Subject: [PATCH 0374/1070] Fix copy-paste error in STATISTIC_UNIT_TO_UNIT_CONVERTER (#130375) --- homeassistant/components/recorder/statistics.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 9a66c4542b5e0..e5fbfe0e8c58b 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -134,7 +134,6 @@ for unit in BloodGlugoseConcentrationConverter.VALID_UNITS }, **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, - **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, **{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS}, **{unit: DurationConverter for unit in DurationConverter.VALID_UNITS}, From b19c44b4a54ac6b29cf4d7f8c3b416ca9451e289 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 11 Nov 2024 19:01:47 +0100 Subject: [PATCH 0375/1070] Update pydantic to 1.10.19 (#130373) --- homeassistant/package_constraints.txt | 2 +- requirements_test.txt | 2 +- script/gen_requirements_all.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3b3c50b3326fb..285de399e5d5f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -127,7 +127,7 @@ backoff>=2.0 # Required to avoid breaking (#101042). # v2 has breaking changes (#99218). -pydantic==1.10.18 +pydantic==1.10.19 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/requirements_test.txt b/requirements_test.txt index 241fff89ac3d4..166fd965e2c64 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ license-expression==30.4.0 mock-open==1.4.0 mypy-dev==1.14.0a2 pre-commit==4.0.0 -pydantic==1.10.18 +pydantic==1.10.19 pylint==3.3.1 pylint-per-file-ignores==1.3.2 pipdeptree==2.23.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 37d0ea1d105c6..c5611069bf5b8 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -160,7 +160,7 @@ # Required to avoid breaking (#101042). # v2 has breaking changes (#99218). -pydantic==1.10.18 +pydantic==1.10.19 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 From 3f34ddd74fc0e4a50382cad2b840f6e1cb854cb0 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Mon, 11 Nov 2024 20:07:12 +0100 Subject: [PATCH 0376/1070] Bump lcn-frontend to 0.2.2 (#130383) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 6ce41a2d08d59..695a35df871ea 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.7.24", "lcn-frontend==0.2.1"] + "requirements": ["pypck==0.7.24", "lcn-frontend==0.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index fe737af17e701..526fa853ffcbe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1268,7 +1268,7 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.1 +lcn-frontend==0.2.2 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae4d027dc8f3b..c19e6bb241d1a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1064,7 +1064,7 @@ lacrosse-view==1.0.3 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.1 +lcn-frontend==0.2.2 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 From f3708549f018c1a99c0f482d676b1e4b72603aaa Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Mon, 11 Nov 2024 20:08:38 +0100 Subject: [PATCH 0377/1070] Code cleanup for LCN integration (#130385) --- homeassistant/components/lcn/helpers.py | 136 ---------------------- homeassistant/components/lcn/strings.json | 12 -- 2 files changed, 148 deletions(-) diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 7da047682ac27..6a9c63ea212d5 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -9,7 +9,6 @@ from typing import cast import pypck -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -19,17 +18,12 @@ CONF_DEVICES, CONF_DOMAIN, CONF_ENTITIES, - CONF_HOST, - CONF_IP_ADDRESS, CONF_LIGHTS, CONF_NAME, - CONF_PASSWORD, - CONF_PORT, CONF_RESOURCE, CONF_SENSORS, CONF_SOURCE, CONF_SWITCHES, - CONF_USERNAME, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -37,19 +31,13 @@ from .const import ( BINSENSOR_PORTS, - CONF_ACKNOWLEDGE, CONF_CLIMATES, - CONF_CONNECTIONS, - CONF_DIM_MODE, - CONF_DOMAIN_DATA, CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, CONF_OUTPUT, CONF_SCENES, - CONF_SK_NUM_TRIES, CONF_SOFTWARE_SERIAL, CONNECTION, - DEFAULT_NAME, DOMAIN, LED_PORTS, LOGICOP_PORTS, @@ -146,110 +134,6 @@ def generate_unique_id( return unique_id -def import_lcn_config(lcn_config: ConfigType) -> list[ConfigType]: - """Convert lcn settings from configuration.yaml to config_entries data. - - Create a list of config_entry data structures like: - - "data": { - "host": "pchk", - "ip_address": "192.168.2.41", - "port": 4114, - "username": "lcn", - "password": "lcn, - "sk_num_tries: 0, - "dim_mode: "STEPS200", - "acknowledge": False, - "devices": [ - { - "address": (0, 7, False) - "name": "", - "hardware_serial": -1, - "software_serial": -1, - "hardware_type": -1 - }, ... - ], - "entities": [ - { - "address": (0, 7, False) - "name": "Light_Output1", - "resource": "output1", - "domain": "light", - "domain_data": { - "output": "OUTPUT1", - "dimmable": True, - "transition": 5000.0 - } - }, ... - ] - } - """ - data = {} - for connection in lcn_config[CONF_CONNECTIONS]: - host = { - CONF_HOST: connection[CONF_NAME], - CONF_IP_ADDRESS: connection[CONF_HOST], - CONF_PORT: connection[CONF_PORT], - CONF_USERNAME: connection[CONF_USERNAME], - CONF_PASSWORD: connection[CONF_PASSWORD], - CONF_SK_NUM_TRIES: connection[CONF_SK_NUM_TRIES], - CONF_DIM_MODE: connection[CONF_DIM_MODE], - CONF_ACKNOWLEDGE: False, - CONF_DEVICES: [], - CONF_ENTITIES: [], - } - data[connection[CONF_NAME]] = host - - for confkey, domain_config in lcn_config.items(): - if confkey == CONF_CONNECTIONS: - continue - domain = DOMAIN_LOOKUP[confkey] - # loop over entities in configuration.yaml - for domain_data in domain_config: - # remove name and address from domain_data - entity_name = domain_data.pop(CONF_NAME) - address, host_name = domain_data.pop(CONF_ADDRESS) - - if host_name is None: - host_name = DEFAULT_NAME - - # check if we have a new device config - for device_config in data[host_name][CONF_DEVICES]: - if address == device_config[CONF_ADDRESS]: - break - else: # create new device_config - device_config = { - CONF_ADDRESS: address, - CONF_NAME: "", - CONF_HARDWARE_SERIAL: -1, - CONF_SOFTWARE_SERIAL: -1, - CONF_HARDWARE_TYPE: -1, - } - - data[host_name][CONF_DEVICES].append(device_config) - - # insert entity config - resource = get_resource(domain, domain_data).lower() - for entity_config in data[host_name][CONF_ENTITIES]: - if ( - address == entity_config[CONF_ADDRESS] - and resource == entity_config[CONF_RESOURCE] - and domain == entity_config[CONF_DOMAIN] - ): - break - else: # create new entity_config - entity_config = { - CONF_ADDRESS: address, - CONF_NAME: entity_name, - CONF_RESOURCE: resource, - CONF_DOMAIN: domain, - CONF_DOMAIN_DATA: domain_data.copy(), - } - data[host_name][CONF_ENTITIES].append(entity_config) - - return list(data.values()) - - def purge_entity_registry( hass: HomeAssistant, entry_id: str, imported_entry_data: ConfigType ) -> None: @@ -436,26 +320,6 @@ def get_device_config( return None -def has_unique_host_names(hosts: list[ConfigType]) -> list[ConfigType]: - """Validate that all connection names are unique. - - Use 'pchk' as default connection_name (or add a numeric suffix if - pchk' is already in use. - """ - suffix = 0 - for host in hosts: - if host.get(CONF_NAME) is None: - if suffix == 0: - host[CONF_NAME] = DEFAULT_NAME - else: - host[CONF_NAME] = f"{DEFAULT_NAME}{suffix:d}" - suffix += 1 - - schema = vol.Schema(vol.Unique()) - schema([host.get(CONF_NAME) for host in hosts]) - return hosts - - def is_address(value: str) -> tuple[AddressType, str]: """Validate the given address string. diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index ae0b1b01f9a1e..088a365450040 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -63,18 +63,6 @@ } }, "issues": { - "authentication_error": { - "title": "Authentication failed.", - "description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure username and password are correct.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - }, - "license_error": { - "title": "Maximum number of connections was reached.", - "description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure sufficient PCHK licenses are registered and restart Home Assistant.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - }, - "connection_refused": { - "title": "Unable to connect to PCHK.", - "description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure the connection (IP and port) to the LCN bus coupler is correct.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - }, "deprecated_regulatorlock_sensor": { "title": "Deprecated LCN regulator lock binary sensor", "description": "Your LCN regulator lock binary sensor entity `{entity}` is beeing used in automations or scripts. A regulator lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." From 906bdda6fac574c2dd7959628afb019afa4f3bd4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:09:26 +0100 Subject: [PATCH 0378/1070] Use report_usage in integrations (#130366) --- homeassistant/components/media_source/__init__.py | 4 ++-- homeassistant/components/recorder/pool.py | 6 +++--- homeassistant/components/zeroconf/usage.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 604f9b7cc8814..3ea8f581245a2 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -18,7 +18,7 @@ from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.frame import report +from homeassistant.helpers.frame import report_usage from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) @@ -156,7 +156,7 @@ async def async_resolve_media( raise Unresolvable("Media Source not loaded") if target_media_player is UNDEFINED: - report( + report_usage( "calls media_source.async_resolve_media without passing an entity_id", exclude_integrations={DOMAIN}, ) diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 30f8fa8d07a47..fc2a8ccb1ccf7 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -16,7 +16,7 @@ StaticPool, ) -from homeassistant.helpers.frame import report +from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.util.loop import raise_for_blocking_call _LOGGER = logging.getLogger(__name__) @@ -108,14 +108,14 @@ def _do_get(self) -> ConnectionPoolEntry: # type: ignore[return] # raise_for_blocking_call will raise an exception def _do_get_db_connection_protected(self) -> ConnectionPoolEntry: - report( + report_usage( ( "accesses the database without the database executor; " f"{ADVISE_MSG} " "for faster database operations" ), exclude_integrations={"recorder"}, - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) return NullPool._create_connection(self) # noqa: SLF001 diff --git a/homeassistant/components/zeroconf/usage.py b/homeassistant/components/zeroconf/usage.py index b9d51cd3c367b..8ddfdbd592d1a 100644 --- a/homeassistant/components/zeroconf/usage.py +++ b/homeassistant/components/zeroconf/usage.py @@ -4,7 +4,7 @@ import zeroconf -from homeassistant.helpers.frame import report +from homeassistant.helpers.frame import ReportBehavior, report_usage from .models import HaZeroconf @@ -16,14 +16,14 @@ def install_multiple_zeroconf_catcher(hass_zc: HaZeroconf) -> None: """ def new_zeroconf_new(self: zeroconf.Zeroconf, *k: Any, **kw: Any) -> HaZeroconf: - report( + report_usage( ( "attempted to create another Zeroconf instance. Please use the shared" " Zeroconf via await" " homeassistant.components.zeroconf.async_get_instance(hass)" ), exclude_integrations={"zeroconf"}, - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) return hass_zc From c89bf6a9aa6334b8bdd5b05db0fdab550cb10c18 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:12:32 +0100 Subject: [PATCH 0379/1070] Update pillow to 11.0.0 (#130194) --- homeassistant/components/doods/manifest.json | 2 +- homeassistant/components/generic/manifest.json | 2 +- homeassistant/components/image_upload/manifest.json | 2 +- homeassistant/components/matrix/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/seven_segments/manifest.json | 2 +- homeassistant/components/sighthound/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/licenses.py | 1 + 15 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index fabb2c301904e..7c85ca634670a 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "Pillow==10.4.0"] + "requirements": ["pydoods==1.0.2", "Pillow==11.0.0"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index b02a8fa25203c..c1fbc16d9be31 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["av==13.1.0", "Pillow==10.4.0"] + "requirements": ["av==13.1.0", "Pillow==11.0.0"] } diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 963721a047612..bb8c33ba74979 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==10.4.0"] + "requirements": ["Pillow==11.0.0"] } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 520bd0550ccec..43c151c7c2368 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-nio==0.25.2", "Pillow==10.4.0"] + "requirements": ["matrix-nio==0.25.2", "Pillow==11.0.0"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 1e70c4d3e103f..f13799422dfb4 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["Pillow==10.4.0"] + "requirements": ["Pillow==11.0.0"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 14f2d093f3794..3fcc895c2b9cc 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["Pillow==10.4.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==11.0.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 2f39644d6d31f..af00a1fdfed6b 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", - "requirements": ["Pillow==10.4.0"] + "requirements": ["Pillow==11.0.0"] } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 875c98acb6dbb..7d08367cf7d19 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], - "requirements": ["Pillow==10.4.0", "simplehound==0.3"] + "requirements": ["Pillow==11.0.0", "simplehound==0.3"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 91ce27badd3a7..86fd83ad0881e 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,6 +10,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==2.1.3", - "Pillow==10.4.0" + "Pillow==11.0.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 285de399e5d5f..ec2dc977989d2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,7 +44,7 @@ mutagen==1.47.0 orjson==3.10.11 packaging>=23.1 paho-mqtt==1.6.1 -Pillow==10.4.0 +Pillow==11.0.0 propcache==0.2.0 psutil-home-assistant==0.0.1 PyJWT==2.9.0 diff --git a/pyproject.toml b/pyproject.toml index 143330f5adb52..4a9192d7767b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ dependencies = [ "PyJWT==2.9.0", # PyJWT has loose dependency. We want the latest one. "cryptography==43.0.1", - "Pillow==10.4.0", + "Pillow==11.0.0", "propcache==0.2.0", "pyOpenSSL==24.2.1", "orjson==3.10.11", diff --git a/requirements.txt b/requirements.txt index aa72a7d23ebed..19f8ac9ee22ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ Jinja2==3.1.4 lru-dict==1.3.0 PyJWT==2.9.0 cryptography==43.0.1 -Pillow==10.4.0 +Pillow==11.0.0 propcache==0.2.0 pyOpenSSL==24.2.1 orjson==3.10.11 diff --git a/requirements_all.txt b/requirements_all.txt index 526fa853ffcbe..83bf653e424dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -33,7 +33,7 @@ Mastodon.py==1.8.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.4.0 +Pillow==11.0.0 # homeassistant.components.plex PlexAPI==4.15.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c19e6bb241d1a..db4fea6aa0ea4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -33,7 +33,7 @@ Mastodon.py==1.8.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.4.0 +Pillow==11.0.0 # homeassistant.components.plex PlexAPI==4.15.16 diff --git a/script/licenses.py b/script/licenses.py index f4d534365bcb4..464a2fc456b4b 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -84,6 +84,7 @@ def from_dict(cls, data: PackageMetadata) -> PackageDefinition: "LGPL-3.0-only", "LGPL-3.0-or-later", "MIT", + "MIT-CMU", "MPL-1.1", "MPL-2.0", "PSF-2.0", From c54369fe93d28eebd25000ba6b22180c5cbc9fcb Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 11 Nov 2024 20:13:20 +0100 Subject: [PATCH 0380/1070] Add go2rtc to devcontainer (#130380) --- Dockerfile.dev | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile.dev b/Dockerfile.dev index d05c6df425cf5..48f582a15810b 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -35,6 +35,9 @@ RUN \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +# Add go2rtc binary +COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc + # Install uv RUN pip3 install uv From ebe62501d660c6fcfa8c96ae9076ad2c68cbff23 Mon Sep 17 00:00:00 2001 From: "Barry vd. Heuvel" Date: Mon, 11 Nov 2024 20:14:12 +0100 Subject: [PATCH 0381/1070] Bump Weheat wh-python to 2024.11.02 (#130337) --- homeassistant/components/weheat/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index d32e0ce404750..ef89a2f1acba3 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2024.09.23"] + "requirements": ["weheat==2024.11.02"] } diff --git a/requirements_all.txt b/requirements_all.txt index 83bf653e424dc..608b025f5eb7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2987,7 +2987,7 @@ webio-api==0.1.8 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.09.23 +weheat==2024.11.02 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db4fea6aa0ea4..631cc0b034398 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2382,7 +2382,7 @@ webio-api==0.1.8 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.09.23 +weheat==2024.11.02 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 From 313309a7e04f98f4e39006a839006d2eb2338a7f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:24:51 +0100 Subject: [PATCH 0382/1070] Remove deprecated YAML loaders (#130364) --- homeassistant/util/yaml/loader.py | 63 ------------------------------- tests/util/yaml/test_init.py | 25 ------------ 2 files changed, 88 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 39ac17d94f997..39d38a8f47d5c 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -25,7 +25,6 @@ from propcache import cached_property from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.frame import report from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -144,37 +143,6 @@ def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: self.secrets = secrets -class SafeLoader(FastSafeLoader): - """Provided for backwards compatibility. Logs when instantiated.""" - - def __init__(*args: Any, **kwargs: Any) -> None: - """Log a warning and call super.""" - SafeLoader.__report_deprecated() - FastSafeLoader.__init__(*args, **kwargs) - - @classmethod - def add_constructor(cls, tag: str, constructor: Callable) -> None: - """Log a warning and call super.""" - SafeLoader.__report_deprecated() - FastSafeLoader.add_constructor(tag, constructor) - - @classmethod - def add_multi_constructor( - cls, tag_prefix: str, multi_constructor: Callable - ) -> None: - """Log a warning and call super.""" - SafeLoader.__report_deprecated() - FastSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) - - @staticmethod - def __report_deprecated() -> None: - """Log deprecation warning.""" - report( - "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " - "which will stop working in HA Core 2024.6," - ) - - class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -184,37 +152,6 @@ def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: self.secrets = secrets -class SafeLineLoader(PythonSafeLoader): - """Provided for backwards compatibility. Logs when instantiated.""" - - def __init__(*args: Any, **kwargs: Any) -> None: - """Log a warning and call super.""" - SafeLineLoader.__report_deprecated() - PythonSafeLoader.__init__(*args, **kwargs) - - @classmethod - def add_constructor(cls, tag: str, constructor: Callable) -> None: - """Log a warning and call super.""" - SafeLineLoader.__report_deprecated() - PythonSafeLoader.add_constructor(tag, constructor) - - @classmethod - def add_multi_constructor( - cls, tag_prefix: str, multi_constructor: Callable - ) -> None: - """Log a warning and call super.""" - SafeLineLoader.__report_deprecated() - PythonSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) - - @staticmethod - def __report_deprecated() -> None: - """Log deprecation warning.""" - report( - "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " - "which will stop working in HA Core 2024.6," - ) - - type LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 8db3f49ab8ec8..12a7eca5f9d82 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -494,31 +494,6 @@ def mock_integration_frame() -> Generator[Mock]: yield correct_frame -@pytest.mark.parametrize( - ("loader_class", "message"), - [ - (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), - ( - yaml.loader.SafeLineLoader, - "'SafeLineLoader' instead of 'PythonSafeLoader'", - ), - ], -) -@pytest.mark.usefixtures("mock_integration_frame") -async def test_deprecated_loaders( - caplog: pytest.LogCaptureFixture, - loader_class: type, - message: str, -) -> None: - """Test instantiating the deprecated yaml loaders logs a warning.""" - with ( - pytest.raises(TypeError), - patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()), - ): - loader_class() - assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text - - @pytest.mark.usefixtures("try_both_loaders") def test_string_annotated() -> None: """Test strings are annotated with file + line.""" From e97a5f927c552855bd5f145c3382c469eecd487b Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 11 Nov 2024 14:26:45 -0500 Subject: [PATCH 0383/1070] Bump aiorussound to 4.1.0 (#130382) --- .../components/russound_rio/const.py | 2 +- .../components/russound_rio/manifest.json | 2 +- .../components/russound_rio/media_player.py | 28 +++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../russound_rio/test_media_player.py | 24 ++++++++-------- 6 files changed, 29 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/russound_rio/const.py b/homeassistant/components/russound_rio/const.py index 1b38dc8ce5c0b..af52e89d39944 100644 --- a/homeassistant/components/russound_rio/const.py +++ b/homeassistant/components/russound_rio/const.py @@ -17,7 +17,7 @@ ) -CONNECT_TIMEOUT = 5 +CONNECT_TIMEOUT = 15 MP_FEATURES_BY_FLAG = { FeatureFlag.COMMANDS_ZONE_MUTE_OFF_ON: MediaPlayerEntityFeature.VOLUME_MUTE diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 96fc0fb53dbb2..ab77ca3ab6af3 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.0.5"] + "requirements": ["aiorussound==4.1.0"] } diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 561f3b008c789..45818d3e25bb6 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -5,7 +5,7 @@ import logging from aiorussound import Controller -from aiorussound.models import Source +from aiorussound.models import PlayStatus, Source from aiorussound.rio import ZoneControlSurface from homeassistant.components.media_player import ( @@ -132,20 +132,18 @@ def _source(self) -> Source: def state(self) -> MediaPlayerState | None: """Return the state of the device.""" status = self._zone.status - mode = self._source.mode - if status == "ON": - if mode == "playing": - return MediaPlayerState.PLAYING - if mode == "paused": - return MediaPlayerState.PAUSED - if mode == "transitioning": - return MediaPlayerState.BUFFERING - if mode == "stopped": - return MediaPlayerState.IDLE - return MediaPlayerState.ON - if status == "OFF": + play_status = self._source.play_status + if not status: return MediaPlayerState.OFF - return None + if play_status == PlayStatus.PLAYING: + return MediaPlayerState.PLAYING + if play_status == PlayStatus.PAUSED: + return MediaPlayerState.PAUSED + if play_status == PlayStatus.TRANSITIONING: + return MediaPlayerState.BUFFERING + if play_status == PlayStatus.STOPPED: + return MediaPlayerState.IDLE + return MediaPlayerState.ON @property def source(self): @@ -184,7 +182,7 @@ def volume_level(self): Value is returned based on a range (0..50). Therefore float divide by 50 to get to the required range. """ - return float(self._zone.volume or "0") / 50.0 + return self._zone.volume / 50.0 @command async def async_turn_off(self) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index 608b025f5eb7c..b46c6dbfef47f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -357,7 +357,7 @@ aioridwell==2024.01.0 aioruckus==0.41 # homeassistant.components.russound_rio -aiorussound==4.0.5 +aiorussound==4.1.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 631cc0b034398..c4ae704eca651 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -339,7 +339,7 @@ aioridwell==2024.01.0 aioruckus==0.41 # homeassistant.components.russound_rio -aiorussound==4.0.5 +aiorussound==4.1.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py index 38ef603c21d09..e720e2c7f657a 100644 --- a/tests/components/russound_rio/test_media_player.py +++ b/tests/components/russound_rio/test_media_player.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiorussound.models import CallbackType +from aiorussound.models import CallbackType, PlayStatus import pytest from homeassistant.const import ( @@ -28,29 +28,29 @@ async def mock_state_update(client: AsyncMock) -> None: @pytest.mark.parametrize( - ("zone_status", "source_mode", "media_player_state"), + ("zone_status", "source_play_status", "media_player_state"), [ - ("ON", None, STATE_ON), - ("ON", "playing", STATE_PLAYING), - ("ON", "paused", STATE_PAUSED), - ("ON", "transitioning", STATE_BUFFERING), - ("ON", "stopped", STATE_IDLE), - ("OFF", None, STATE_OFF), - ("OFF", "stopped", STATE_OFF), + (True, None, STATE_ON), + (True, PlayStatus.PLAYING, STATE_PLAYING), + (True, PlayStatus.PAUSED, STATE_PAUSED), + (True, PlayStatus.TRANSITIONING, STATE_BUFFERING), + (True, PlayStatus.STOPPED, STATE_IDLE), + (False, None, STATE_OFF), + (False, PlayStatus.STOPPED, STATE_OFF), ], ) async def test_entity_state( hass: HomeAssistant, mock_russound_client: AsyncMock, mock_config_entry: MockConfigEntry, - zone_status: str, - source_mode: str | None, + zone_status: bool, + source_play_status: PlayStatus | None, media_player_state: str, ) -> None: """Test media player state.""" await setup_integration(hass, mock_config_entry) mock_russound_client.controllers[1].zones[1].status = zone_status - mock_russound_client.sources[1].mode = source_mode + mock_russound_client.sources[1].play_status = source_play_status await mock_state_update(mock_russound_client) await hass.async_block_till_done() From 96c12fdd10e4be6d88195fa4800a1dc6f7c32a6c Mon Sep 17 00:00:00 2001 From: Markus Lanthaler Date: Mon, 11 Nov 2024 20:40:37 +0100 Subject: [PATCH 0384/1070] Update tuya-device-sharing-sdk to version 0.2.1 (#130333) --- homeassistant/components/tuya/__init__.py | 13 ++++++++++--- homeassistant/components/tuya/entity.py | 7 ++++++- homeassistant/components/tuya/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 47143f3595c68..c8a639cd23929 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -146,14 +146,21 @@ def __init__( self.hass = hass self.manager = manager - def update_device(self, device: CustomerDevice) -> None: + def update_device( + self, device: CustomerDevice, updated_status_properties: list[str] | None + ) -> None: """Update device status.""" LOGGER.debug( - "Received update for device %s: %s", + "Received update for device %s: %s (updated properties: %s)", device.id, self.manager.device_map[device.id].status, + updated_status_properties, + ) + dispatcher_send( + self.hass, + f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}", + updated_status_properties, ) - dispatcher_send(self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}") def add_device(self, device: CustomerDevice) -> None: """Add device added listener.""" diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 4d3710f7570c0..cc258560067fd 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -283,10 +283,15 @@ async def async_added_to_hass(self) -> None: async_dispatcher_connect( self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{self.device.id}", - self.async_write_ha_state, + self._handle_state_update, ) ) + async def _handle_state_update( + self, updated_status_properties: list[str] | None + ) -> None: + self.async_write_ha_state() + def _send_command(self, commands: list[dict[str, Any]]) -> None: """Send command to the device.""" LOGGER.debug("Sending commands for device %s: %s", self.device.id, commands) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 305a74160de21..b53e6fa27d836 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -43,5 +43,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["tuya_iot"], - "requirements": ["tuya-device-sharing-sdk==0.1.9"] + "requirements": ["tuya-device-sharing-sdk==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index b46c6dbfef47f..45c7b6f46b53f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2873,7 +2873,7 @@ ttls==1.8.3 ttn_client==1.2.0 # homeassistant.components.tuya -tuya-device-sharing-sdk==0.1.9 +tuya-device-sharing-sdk==0.2.1 # homeassistant.components.twentemilieu twentemilieu==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4ae704eca651..80d3d806eb7b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2286,7 +2286,7 @@ ttls==1.8.3 ttn_client==1.2.0 # homeassistant.components.tuya -tuya-device-sharing-sdk==0.1.9 +tuya-device-sharing-sdk==0.2.1 # homeassistant.components.twentemilieu twentemilieu==2.0.1 From e388e9f3964ee763c73aef37a3a035daf8c4350d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Nov 2024 13:48:49 -0600 Subject: [PATCH 0385/1070] Fix missing title placeholders in powerwall reauth (#130389) --- homeassistant/components/powerwall/config_flow.py | 6 +++++- tests/components/powerwall/test_config_flow.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index bacbff6321117..0c39392ca19b7 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -251,8 +251,8 @@ async def async_step_reauth_confirm( """Handle reauth confirmation.""" errors: dict[str, str] | None = {} description_placeholders: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() if user_input is not None: - reauth_entry = self._get_reauth_entry() errors, _, description_placeholders = await self._async_try_connect( {CONF_IP_ADDRESS: reauth_entry.data[CONF_IP_ADDRESS], **user_input} ) @@ -261,6 +261,10 @@ async def async_step_reauth_confirm( reauth_entry, data_updates=user_input ) + self.context["title_placeholders"] = { + "name": reauth_entry.title, + "ip_address": reauth_entry.data[CONF_IP_ADDRESS], + } return self.async_show_form( step_id="reauth_confirm", data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}), diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index 5074a289d191e..1ff1470f81c03 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -339,6 +339,11 @@ async def test_form_reauth(hass: HomeAssistant) -> None: result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} + flow = hass.config_entries.flow.async_get(result["flow_id"]) + assert flow["context"]["title_placeholders"] == { + "ip_address": VALID_CONFIG[CONF_IP_ADDRESS], + "name": entry.title, + } mock_powerwall = await _mock_powerwall_site_name(hass, "My site") From f1ce7ee8cefb3f2e78808b92f04dbb327f75700b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 11 Nov 2024 21:02:09 +0100 Subject: [PATCH 0386/1070] Adjust logging for OptionsFlow deprecation (#130360) --- .../silabs_multiprotocol_addon.py | 1 - homeassistant/config_entries.py | 7 ++++--- tests/test_config_entries.py | 16 ++++++++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 14ae57391ef45..2b08031405fc9 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -318,7 +318,6 @@ def __init__(self, config_entry: ConfigEntry) -> None: self.start_task: asyncio.Task | None = None self.stop_task: asyncio.Task | None = None self._zha_migration_mgr: ZhaMultiPANMigrationHelper | None = None - self.config_entry = config_entry self.original_addon_config: dict[str, Any] | None = None self.revert_reason: str | None = None diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 64eadeb0d7ebd..f1748c6b7fb89 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3158,11 +3158,12 @@ def config_entry(self) -> ConfigEntry: @config_entry.setter def config_entry(self, value: ConfigEntry) -> None: """Set the config entry value.""" - report( + report_usage( "sets option flow config_entry explicitly, which is deprecated " "and will stop working in 2025.12", - error_if_integration=False, - error_if_core=True, + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.LOG, ) self._config_entry = value diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index eb2a719eab898..41af8af3f21d2 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -7501,6 +7501,7 @@ async def async_step_init(self, user_input=None): assert result["reason"] == "abort" +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_deprecated_config_entry_setter( @@ -7509,13 +7510,15 @@ async def test_options_flow_deprecated_config_entry_setter( caplog: pytest.LogCaptureFixture, ) -> None: """Test that setting config_entry explicitly still works.""" - original_entry = MockConfigEntry(domain="hue", data={}) + original_entry = MockConfigEntry(domain="my_integration", data={}) original_entry.add_to_hass(hass) mock_setup_entry = AsyncMock(return_value=True) - mock_integration(hass, MockModule("hue", async_setup_entry=mock_setup_entry)) - mock_platform(hass, "hue.config_flow", None) + mock_integration( + hass, MockModule("my_integration", async_setup_entry=mock_setup_entry) + ) + mock_platform(hass, "my_integration.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -7549,15 +7552,16 @@ async def async_step_init(self, user_input=None): return _OptionsFlow(config_entry) - with mock_config_flow("hue", TestFlow): + with mock_config_flow("my_integration", TestFlow): result = await hass.config_entries.options.async_init(original_entry.entry_id) options_flow = hass.config_entries.options._progress.get(result["flow_id"]) assert options_flow.config_entry is original_entry assert ( - "Detected that integration 'hue' sets option flow config_entry explicitly, " - "which is deprecated and will stop working in 2025.12" in caplog.text + "Detected that custom integration 'my_integration' sets option flow " + "config_entry explicitly, which is deprecated and will stop working " + "in 2025.12" in caplog.text ) From 8b547551e27ad6962b084f25d7cc277b22f9b003 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Mon, 11 Nov 2024 21:05:41 +0100 Subject: [PATCH 0387/1070] Bump ruff to 0.7.3 (#130390) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f89dadda43df5..519674b9894ec 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.2 + rev: v0.7.3 hooks: - id: ruff args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index bab89d20584f4..23f584dd0dec0 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.3.0 -ruff==0.7.2 +ruff==0.7.3 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 745159d61d3da..9bad1e8aecc04 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -22,7 +22,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.2 \ + stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.3 \ PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.2 hassil==1.7.4 home-assistant-intents==2024.11.6 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" From d1c3e1caa9a27a40025e3031d92c0408553deb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 11 Nov 2024 21:05:52 +0100 Subject: [PATCH 0388/1070] Bump Tibber 0.30.8 (#130388) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index d1bfefec48481..bc9304ab59d37 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.30.7"] + "requirements": ["pyTibber==0.30.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 45c7b6f46b53f..67c7c99114698 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1738,7 +1738,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.30.7 +pyTibber==0.30.8 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80d3d806eb7b0..048f0ac7d7601 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1415,7 +1415,7 @@ pyElectra==1.2.4 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.30.7 +pyTibber==0.30.8 # homeassistant.components.dlink pyW215==0.7.0 From 3eab72b2aab4d8184e351953322f4a1c300d331e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 11 Nov 2024 23:02:48 +0100 Subject: [PATCH 0389/1070] Improve exception handling in Nord Pool (#130386) * Improve exception handling in Nord Pool * Improve auth string * Remove auth --- .../components/nordpool/config_flow.py | 14 +++--- .../components/nordpool/coordinator.py | 12 ++--- tests/components/nordpool/test_config_flow.py | 45 ++----------------- tests/components/nordpool/test_coordinator.py | 30 +++++-------- 4 files changed, 27 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/nordpool/config_flow.py b/homeassistant/components/nordpool/config_flow.py index a9a834d8225ed..1d75d825e4767 100644 --- a/homeassistant/components/nordpool/config_flow.py +++ b/homeassistant/components/nordpool/config_flow.py @@ -4,7 +4,12 @@ from typing import Any -from pynordpool import Currency, NordPoolClient, NordPoolError +from pynordpool import ( + Currency, + NordPoolClient, + NordPoolEmptyResponseError, + NordPoolError, +) from pynordpool.const import AREAS import voluptuous as vol @@ -53,17 +58,16 @@ async def test_api(hass: HomeAssistant, user_input: dict[str, Any]) -> dict[str, """Test fetch data from Nord Pool.""" client = NordPoolClient(async_get_clientsession(hass)) try: - data = await client.async_get_delivery_period( + await client.async_get_delivery_period( dt_util.now(), Currency(user_input[CONF_CURRENCY]), user_input[CONF_AREAS], ) + except NordPoolEmptyResponseError: + return {"base": "no_data"} except NordPoolError: return {"base": "cannot_connect"} - if not data.raw: - return {"base": "no_data"} - return {} diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index 27016ae2b4b19..fa4e9ca254810 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -9,8 +9,8 @@ from pynordpool import ( Currency, DeliveryPeriodData, - NordPoolAuthenticationError, NordPoolClient, + NordPoolEmptyResponseError, NordPoolError, NordPoolResponseError, ) @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from .const import CONF_AREAS, DOMAIN, LOGGER @@ -75,8 +75,8 @@ async def fetch_data(self, now: datetime) -> None: Currency(self.config_entry.data[CONF_CURRENCY]), self.config_entry.data[CONF_AREAS], ) - except NordPoolAuthenticationError as error: - LOGGER.error("Authentication error: %s", error) + except NordPoolEmptyResponseError as error: + LOGGER.debug("Empty response error: %s", error) self.async_set_update_error(error) return except NordPoolResponseError as error: @@ -88,8 +88,4 @@ async def fetch_data(self, now: datetime) -> None: self.async_set_update_error(error) return - if not data.raw: - self.async_set_update_error(UpdateFailed("No data")) - return - self.async_set_updated_data(data) diff --git a/tests/components/nordpool/test_config_flow.py b/tests/components/nordpool/test_config_flow.py index d17db619b02a5..cfdfc63aca769 100644 --- a/tests/components/nordpool/test_config_flow.py +++ b/tests/components/nordpool/test_config_flow.py @@ -2,13 +2,12 @@ from __future__ import annotations -from dataclasses import replace from unittest.mock import patch from pynordpool import ( DeliveryPeriodData, - NordPoolAuthenticationError, NordPoolConnectionError, + NordPoolEmptyResponseError, NordPoolError, NordPoolResponseError, ) @@ -71,7 +70,7 @@ async def test_single_config_entry( ("error_message", "p_error"), [ (NordPoolConnectionError, "cannot_connect"), - (NordPoolAuthenticationError, "cannot_connect"), + (NordPoolEmptyResponseError, "no_data"), (NordPoolError, "cannot_connect"), (NordPoolResponseError, "cannot_connect"), ], @@ -116,44 +115,6 @@ async def test_cannot_connect( assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") -async def test_empty_data(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: - """Test empty data error.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER - - invalid_data = replace(get_data, raw={}) - - with patch( - "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", - return_value=invalid_data, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=ENTRY_CONFIG, - ) - - assert result["errors"] == {"base": "no_data"} - - with patch( - "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", - return_value=get_data, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=ENTRY_CONFIG, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Nord Pool" - assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} - - @pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") async def test_reconfigure( hass: HomeAssistant, @@ -193,7 +154,7 @@ async def test_reconfigure( ("error_message", "p_error"), [ (NordPoolConnectionError, "cannot_connect"), - (NordPoolAuthenticationError, "cannot_connect"), + (NordPoolEmptyResponseError, "no_data"), (NordPoolError, "cannot_connect"), (NordPoolResponseError, "cannot_connect"), ], diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index 9cff34adb1f28..d2d912b1b9907 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -9,6 +9,7 @@ from pynordpool import ( DeliveryPeriodData, NordPoolAuthenticationError, + NordPoolEmptyResponseError, NordPoolError, NordPoolResponseError, ) @@ -18,14 +19,13 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from . import ENTRY_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.mark.freeze_time("2024-11-05T12:00:00+00:00") +@pytest.mark.freeze_time("2024-11-05T10:00:00+00:00") async def test_coordinator( hass: HomeAssistant, get_data: DeliveryPeriodData, @@ -51,7 +51,7 @@ async def test_coordinator( await hass.async_block_till_done() mock_data.assert_called_once() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "0.94949" + assert state.state == "0.92737" mock_data.reset_mock() mock_data.side_effect = NordPoolError("error") @@ -74,34 +74,26 @@ async def test_coordinator( assert "Authentication error" in caplog.text mock_data.reset_mock() - assert "Response error" not in caplog.text - mock_data.side_effect = NordPoolResponseError("Response error") + assert "Empty response" not in caplog.text + mock_data.side_effect = NordPoolEmptyResponseError("Empty response") freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) mock_data.assert_called_once() state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE - assert "Response error" in caplog.text + assert "Empty response" in caplog.text mock_data.reset_mock() - mock_data.return_value = DeliveryPeriodData( - raw={}, - requested_date="2024-11-05", - updated_at=dt_util.utcnow(), - entries=[], - block_prices=[], - currency="SEK", - exchange_rate=1, - area_average={}, - ) - mock_data.side_effect = None + assert "Response error" not in caplog.text + mock_data.side_effect = NordPoolResponseError("Response error") freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) mock_data.assert_called_once() state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE + assert "Response error" in caplog.text mock_data.reset_mock() mock_data.return_value = get_data @@ -111,4 +103,4 @@ async def test_coordinator( await hass.async_block_till_done() mock_data.assert_called_once() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "1.81983" + assert state.state == "1.81645" From 60bf0f6b06b7c9901a02f74ac8869378f3df4409 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 12 Nov 2024 16:26:28 +0900 Subject: [PATCH 0390/1070] Fix fan's warning TURN_ON, TURN_OFF (#130327) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/fan.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lg_thinq/fan.py b/homeassistant/components/lg_thinq/fan.py index 187cc74b3eb32..edcadf2598ab9 100644 --- a/homeassistant/components/lg_thinq/fan.py +++ b/homeassistant/components/lg_thinq/fan.py @@ -72,8 +72,11 @@ def __init__( super().__init__(coordinator, entity_description, property_id) self._ordered_named_fan_speeds = [] - self._attr_supported_features |= FanEntityFeature.SET_SPEED - + self._attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + ) if (fan_modes := self.data.fan_modes) is not None: self._attr_speed_count = len(fan_modes) if self.speed_count == 4: @@ -98,7 +101,7 @@ def _update_status(self) -> None: self._attr_percentage = 0 _LOGGER.debug( - "[%s:%s] update status: %s -> %s (percntage=%s)", + "[%s:%s] update status: %s -> %s (percentage=%s)", self.coordinator.device_name, self.property_id, self.data.is_on, @@ -120,7 +123,7 @@ async def async_set_percentage(self, percentage: int) -> None: return _LOGGER.debug( - "[%s:%s] async_set_percentage. percntage=%s, value=%s", + "[%s:%s] async_set_percentage. percentage=%s, value=%s", self.coordinator.device_name, self.property_id, percentage, From 22aed924618f2c9d63736985f57d2af2cb8468fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Nov 2024 01:29:01 -0600 Subject: [PATCH 0391/1070] Bump aiohttp to 3.11.0rc1 (#130320) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ec2dc977989d2..a40c874587735 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.11.0rc0 +aiohttp==3.11.0rc1 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 4a9192d7767b0..adc85c0f4f721 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.0rc0", + "aiohttp==3.11.0rc1", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index 19f8ac9ee22ad..53d6b13a4ab3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.0rc0 +aiohttp==3.11.0rc1 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From 7045b776b6cd47ee06548f4687b7a34ec1c1c4b8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 12 Nov 2024 09:25:13 +0100 Subject: [PATCH 0392/1070] Use report_usage in helpers (#130365) --- homeassistant/helpers/config_validation.py | 12 ++++++------ homeassistant/helpers/event.py | 6 +++--- homeassistant/helpers/service.py | 6 +++--- homeassistant/helpers/template.py | 6 +++--- homeassistant/helpers/update_coordinator.py | 12 ++++-------- 5 files changed, 19 insertions(+), 23 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 81ac10f86cc83..2b35ebade761b 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -719,14 +719,14 @@ def template(value: Any | None) -> template_helper.Template: raise vol.Invalid("template value should be a string") if not (hass := _async_get_hass_or_none()): # pylint: disable-next=import-outside-toplevel - from .frame import report + from .frame import ReportBehavior, report_usage - report( + report_usage( ( "validates schema outside the event loop, " "which will stop working in HA Core 2025.10" ), - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) template_value = template_helper.Template(str(value), hass) @@ -748,14 +748,14 @@ def dynamic_template(value: Any | None) -> template_helper.Template: raise vol.Invalid("template value does not contain a dynamic template") if not (hass := _async_get_hass_or_none()): # pylint: disable-next=import-outside-toplevel - from .frame import report + from .frame import ReportBehavior, report_usage - report( + report_usage( ( "validates schema outside the event loop, " "which will stop working in HA Core 2025.10" ), - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) template_value = template_helper.Template(str(value), hass) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 02ea81031926c..61a798dbd7511 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -997,14 +997,14 @@ def __init__( continue # pylint: disable-next=import-outside-toplevel - from .frame import report + from .frame import ReportBehavior, report_usage - report( + report_usage( ( "calls async_track_template_result with template without hass, " "which will stop working in HA Core 2025.10" ), - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) track_template_.template.hass = hass diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 33e8f3d3d6e3d..e3da52604cb7e 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1277,14 +1277,14 @@ def async_register_entity_service( schema = cv.make_entity_service_schema(schema) elif not cv.is_entity_service_schema(schema): # pylint: disable-next=import-outside-toplevel - from .frame import report + from .frame import ReportBehavior, report_usage - report( + report_usage( ( "registers an entity service with a non entity service schema " "which will stop working in HA Core 2025.9" ), - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) service_func: str | HassJob[..., Any] diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 753464c35d521..2eab666bbd4de 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -515,18 +515,18 @@ def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: will be non optional in Home Assistant Core 2025.10. """ # pylint: disable-next=import-outside-toplevel - from .frame import report + from .frame import ReportBehavior, report_usage if not isinstance(template, str): raise TypeError("Expected template to be a string") if not hass: - report( + report_usage( ( "creates a template object without passing hass, " "which will stop working in HA Core 2025.10" ), - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) self.template: str = template.strip() diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index f5c2a2a1288ef..87d55891e90d6 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -29,7 +29,7 @@ from . import entity, event from .debounce import Debouncer -from .frame import report +from .frame import report_usage from .typing import UNDEFINED, UndefinedType REQUEST_REFRESH_DEFAULT_COOLDOWN = 10 @@ -286,24 +286,20 @@ async def async_config_entry_first_refresh(self) -> None: to ensure that multiple retries do not cause log spam. """ if self.config_entry is None: - report( + report_usage( "uses `async_config_entry_first_refresh`, which is only supported " "for coordinators with a config entry and will stop working in " - "Home Assistant 2025.11", - error_if_core=True, - error_if_integration=False, + "Home Assistant 2025.11" ) elif ( self.config_entry.state is not config_entries.ConfigEntryState.SETUP_IN_PROGRESS ): - report( + report_usage( "uses `async_config_entry_first_refresh`, which is only supported " f"when entry state is {config_entries.ConfigEntryState.SETUP_IN_PROGRESS}, " f"but it is in state {self.config_entry.state}, " "This will stop working in Home Assistant 2025.11", - error_if_core=True, - error_if_integration=False, ) if await self.__wrap_async_setup(): await self._async_refresh( From 7758d8ba48e8d19674a39b10c48a58ef31f5281b Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Tue, 12 Nov 2024 11:42:25 +0100 Subject: [PATCH 0393/1070] Add switch platform to eq3btsmart (#130363) --- .../components/eq3btsmart/__init__.py | 1 + homeassistant/components/eq3btsmart/const.py | 3 + .../components/eq3btsmart/icons.json | 32 +++++++ .../components/eq3btsmart/strings.json | 11 +++ homeassistant/components/eq3btsmart/switch.py | 94 +++++++++++++++++++ 5 files changed, 141 insertions(+) create mode 100644 homeassistant/components/eq3btsmart/icons.json create mode 100644 homeassistant/components/eq3btsmart/switch.py diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index 78296c70cef7d..86c555ec15119 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -21,6 +21,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.SWITCH, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py index 33d8e6b3ceedc..64bc1cf497c44 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -21,6 +21,9 @@ ENTITY_KEY_DST = "dst" ENTITY_KEY_BATTERY = "battery" ENTITY_KEY_WINDOW = "window" +ENTITY_KEY_LOCK = "lock" +ENTITY_KEY_BOOST = "boost" +ENTITY_KEY_AWAY = "away" GET_DEVICE_TIMEOUT = 5 # seconds diff --git a/homeassistant/components/eq3btsmart/icons.json b/homeassistant/components/eq3btsmart/icons.json new file mode 100644 index 0000000000000..fb0862f14bcdb --- /dev/null +++ b/homeassistant/components/eq3btsmart/icons.json @@ -0,0 +1,32 @@ +{ + "entity": { + "binary_sensor": { + "dst": { + "default": "mdi:sun-clock", + "state": { + "off": "mdi:sun-clock-outline" + } + } + }, + "switch": { + "away": { + "default": "mdi:home-account", + "state": { + "on": "mdi:home-export" + } + }, + "lock": { + "default": "mdi:lock", + "state": { + "off": "mdi:lock-off" + } + }, + "boost": { + "default": "mdi:fire", + "state": { + "off": "mdi:fire-off" + } + } + } + } +} diff --git a/homeassistant/components/eq3btsmart/strings.json b/homeassistant/components/eq3btsmart/strings.json index c911be099d5e5..03c3b21b964fc 100644 --- a/homeassistant/components/eq3btsmart/strings.json +++ b/homeassistant/components/eq3btsmart/strings.json @@ -24,6 +24,17 @@ "dst": { "name": "Daylight saving time" } + }, + "switch": { + "lock": { + "name": "Lock" + }, + "boost": { + "name": "Boost" + }, + "away": { + "name": "Away" + } } } } diff --git a/homeassistant/components/eq3btsmart/switch.py b/homeassistant/components/eq3btsmart/switch.py new file mode 100644 index 0000000000000..7525d8ca494c2 --- /dev/null +++ b/homeassistant/components/eq3btsmart/switch.py @@ -0,0 +1,94 @@ +"""Platform for eq3 switch entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from eq3btsmart import Thermostat +from eq3btsmart.models import Status + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Eq3ConfigEntry +from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK +from .entity import Eq3Entity + + +@dataclass(frozen=True, kw_only=True) +class Eq3SwitchEntityDescription(SwitchEntityDescription): + """Entity description for eq3 switch entities.""" + + toggle_func: Callable[[Thermostat], Callable[[bool], Awaitable[None]]] + value_func: Callable[[Status], bool] + + +SWITCH_ENTITY_DESCRIPTIONS = [ + Eq3SwitchEntityDescription( + key=ENTITY_KEY_LOCK, + translation_key=ENTITY_KEY_LOCK, + toggle_func=lambda thermostat: thermostat.async_set_locked, + value_func=lambda status: status.is_locked, + ), + Eq3SwitchEntityDescription( + key=ENTITY_KEY_BOOST, + translation_key=ENTITY_KEY_BOOST, + toggle_func=lambda thermostat: thermostat.async_set_boost, + value_func=lambda status: status.is_boost, + ), + Eq3SwitchEntityDescription( + key=ENTITY_KEY_AWAY, + translation_key=ENTITY_KEY_AWAY, + toggle_func=lambda thermostat: thermostat.async_set_away, + value_func=lambda status: status.is_away, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: Eq3ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the entry.""" + + async_add_entities( + Eq3SwitchEntity(entry, entity_description) + for entity_description in SWITCH_ENTITY_DESCRIPTIONS + ) + + +class Eq3SwitchEntity(Eq3Entity, SwitchEntity): + """Base class for eq3 switch entities.""" + + entity_description: Eq3SwitchEntityDescription + + def __init__( + self, + entry: Eq3ConfigEntry, + entity_description: Eq3SwitchEntityDescription, + ) -> None: + """Initialize the entity.""" + + super().__init__(entry, entity_description.key) + self.entity_description = entity_description + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + + await self.entity_description.toggle_func(self._thermostat)(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + + await self.entity_description.toggle_func(self._thermostat)(False) + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + + if TYPE_CHECKING: + assert self._thermostat.status is not None + + return self.entity_description.value_func(self._thermostat.status) From cb9cc0f801118ae73e2cef959fdec274cd645293 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 12 Nov 2024 11:53:14 +0100 Subject: [PATCH 0394/1070] Go2rtc bump and set ffmpeg logs to debug (#130371) --- Dockerfile | 2 +- homeassistant/components/go2rtc/__init__.py | 83 ++------ homeassistant/components/go2rtc/const.py | 1 - homeassistant/components/go2rtc/server.py | 8 +- script/hassfest/docker.py | 2 +- tests/components/go2rtc/test_init.py | 223 +++----------------- 6 files changed, 51 insertions(+), 268 deletions(-) diff --git a/Dockerfile b/Dockerfile index 903a121c032d4..1557419209390 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,7 +55,7 @@ RUN \ "armv7") go2rtc_suffix='arm' ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \ esac \ - && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.6/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ + && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && chmod +x /bin/go2rtc \ # Verify go2rtc can be executed && go2rtc --version diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 04b5b9f931732..fc91ef5e546e7 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -1,8 +1,5 @@ """The go2rtc component.""" -from __future__ import annotations - -from dataclasses import dataclass import logging import shutil @@ -41,13 +38,7 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import ( - CONF_DEBUG_UI, - DEBUG_UI_URL_MESSAGE, - DOMAIN, - HA_MANAGED_RTSP_PORT, - HA_MANAGED_URL, -) +from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, HA_MANAGED_URL from .server import Server _LOGGER = logging.getLogger(__name__) @@ -94,22 +85,13 @@ extra=vol.ALLOW_EXTRA, ) -_DATA_GO2RTC: HassKey[Go2RtcData] = HassKey(DOMAIN) +_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN) _RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError) -@dataclass(frozen=True) -class Go2RtcData: - """Data for go2rtc.""" - - url: str - managed: bool - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up WebRTC.""" url: str | None = None - managed = False if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config: await _remove_go2rtc_entries(hass) return True @@ -144,9 +126,8 @@ async def on_stop(event: Event) -> None: hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) url = HA_MANAGED_URL - managed = True - hass.data[_DATA_GO2RTC] = Go2RtcData(url, managed) + hass.data[_DATA_GO2RTC] = url discovery_flow.async_create_flow( hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} ) @@ -161,32 +142,28 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up go2rtc from a config entry.""" - data = hass.data[_DATA_GO2RTC] + url = hass.data[_DATA_GO2RTC] # Validate the server URL try: - client = Go2RtcRestClient(async_get_clientsession(hass), data.url) + client = Go2RtcRestClient(async_get_clientsession(hass), url) await client.validate_server_version() except Go2RtcClientError as err: if isinstance(err.__cause__, _RETRYABLE_ERRORS): raise ConfigEntryNotReady( - f"Could not connect to go2rtc instance on {data.url}" + f"Could not connect to go2rtc instance on {url}" ) from err - _LOGGER.warning( - "Could not connect to go2rtc instance on %s (%s)", data.url, err - ) + _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False except Go2RtcVersionError as err: raise ConfigEntryNotReady( f"The go2rtc server version is not supported, {err}" ) from err except Exception as err: # noqa: BLE001 - _LOGGER.warning( - "Could not connect to go2rtc instance on %s (%s)", data.url, err - ) + _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False - provider = WebRTCProvider(hass, data) + provider = WebRTCProvider(hass, url) async_register_webrtc_provider(hass, provider) return True @@ -204,12 +181,12 @@ async def _get_binary(hass: HomeAssistant) -> str | None: class WebRTCProvider(CameraWebRTCProvider): """WebRTC provider.""" - def __init__(self, hass: HomeAssistant, data: Go2RtcData) -> None: + def __init__(self, hass: HomeAssistant, url: str) -> None: """Initialize the WebRTC provider.""" self._hass = hass - self._data = data + self._url = url self._session = async_get_clientsession(hass) - self._rest_client = Go2RtcRestClient(self._session, data.url) + self._rest_client = Go2RtcRestClient(self._session, url) self._sessions: dict[str, Go2RtcWsClient] = {} @property @@ -231,7 +208,7 @@ async def async_handle_async_webrtc_offer( ) -> None: """Handle the WebRTC offer and return the answer via the provided callback.""" self._sessions[session_id] = ws_client = Go2RtcWsClient( - self._session, self._data.url, source=camera.entity_id + self._session, self._url, source=camera.entity_id ) if not (stream_source := await camera.stream_source()): @@ -242,34 +219,18 @@ async def async_handle_async_webrtc_offer( streams = await self._rest_client.streams.list() - if self._data.managed: - # HA manages the go2rtc instance - stream_original_name = f"{camera.entity_id}_original" - stream_redirect_sources = [ - f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_original_name}", - f"ffmpeg:{stream_original_name}#audio=opus", - ] - - if ( - (stream_org := streams.get(stream_original_name)) is None - or not any( - stream_source == producer.url for producer in stream_org.producers - ) - or (stream_redirect := streams.get(camera.entity_id)) is None - or stream_redirect_sources != [p.url for p in stream_redirect.producers] - ): - await self._rest_client.streams.add(stream_original_name, stream_source) - await self._rest_client.streams.add( - camera.entity_id, stream_redirect_sources - ) - - # go2rtc instance is managed outside HA - elif (stream_org := streams.get(camera.entity_id)) is None or not any( - stream_source == producer.url for producer in stream_org.producers + if (stream := streams.get(camera.entity_id)) is None or not any( + stream_source == producer.url for producer in stream.producers ): await self._rest_client.streams.add( camera.entity_id, - [stream_source, f"ffmpeg:{camera.entity_id}#audio=opus"], + [ + stream_source, + # We are setting any ffmpeg rtsp related logs to debug + # Connection problems to the camera will be logged by the first stream + # Therefore setting it to debug will not hide any important logs + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + ], ) @callback diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index 3c4dc9a950096..d33ae3e389759 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,4 +6,3 @@ DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" -HA_MANAGED_RTSP_PORT = 18554 diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index 91f4433546caf..6699ee4d8a29f 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import HA_MANAGED_API_PORT, HA_MANAGED_RTSP_PORT, HA_MANAGED_URL +from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL _LOGGER = logging.getLogger(__name__) _TERMINATE_TIMEOUT = 5 @@ -33,7 +33,7 @@ listen: "{api_ip}:{api_port}" rtsp: - listen: "127.0.0.1:{rtsp_port}" + listen: "127.0.0.1:18554" webrtc: listen: ":18555/tcp" @@ -68,9 +68,7 @@ def _create_temp_file(api_ip: str) -> str: with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: file.write( _GO2RTC_CONFIG_FORMAT.format( - api_ip=api_ip, - api_port=HA_MANAGED_API_PORT, - rtsp_port=HA_MANAGED_RTSP_PORT, + api_ip=api_ip, api_port=HA_MANAGED_API_PORT ).encode() ) return file.name diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 083cdaba1a90e..9d38d8f71282b 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -112,7 +112,7 @@ LABEL "com.github.actions.color"="gray-dark" """ -_GO2RTC_VERSION = "1.9.6" +_GO2RTC_VERSION = "1.9.7" def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]: diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index ec5867761428a..9388110366ec1 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator import logging from typing import NamedTuple -from unittest.mock import AsyncMock, Mock, call, patch +from unittest.mock import AsyncMock, Mock, patch from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from go2rtc_client import Stream @@ -238,7 +238,11 @@ async def test() -> None: await test() rest_client.streams.add.assert_called_once_with( - entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + entity_id, + [ + "rtsp://stream", + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + ], ) # Stream exists but the source is different @@ -252,7 +256,11 @@ async def test() -> None: await test() rest_client.streams.add.assert_called_once_with( - entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + entity_id, + [ + "rtsp://stream", + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + ], ) # If the stream is already added, the stream should not be added again. @@ -296,7 +304,7 @@ async def test() -> None: ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) -async def test_setup_managed( +async def test_setup_go_binary( hass: HomeAssistant, rest_client: AsyncMock, ws_client: Mock, @@ -308,131 +316,15 @@ async def test_setup_managed( config: ConfigType, ui_enabled: bool, ) -> None: - """Test the go2rtc setup with managed go2rtc instance.""" + """Test the go2rtc config entry with binary.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry - camera = init_test_integration - - entity_id = camera.entity_id - stream_name_original = f"{camera.entity_id}_original" - assert camera.frontend_stream_type == StreamType.HLS - - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done(wait_background_tasks=True) - config_entries = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries) == 1 - assert config_entries[0].state == ConfigEntryState.LOADED - server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled) - server_start.assert_called_once() - - receive_message_callback = Mock(spec_set=WebRTCSendMessage) - - async def test() -> None: - await camera.async_handle_async_webrtc_offer( - OFFER_SDP, "session_id", receive_message_callback - ) - ws_client.send.assert_called_once_with( - WebRTCOffer( - OFFER_SDP, - camera.async_get_webrtc_client_configuration().configuration.ice_servers, - ) - ) - ws_client.subscribe.assert_called_once() - - # Simulate the answer from the go2rtc server - callback = ws_client.subscribe.call_args[0][0] - callback(WebRTCAnswer(ANSWER_SDP)) - receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP)) - - await test() - - stream_added_calls = [ - call(stream_name_original, "rtsp://stream"), - call( - entity_id, - [ - f"rtsp://127.0.0.1:18554/{stream_name_original}", - f"ffmpeg:{stream_name_original}#audio=opus", - ], - ), - ] - assert rest_client.streams.add.call_args_list == stream_added_calls - - # Stream original missing - rest_client.streams.add.reset_mock() - rest_client.streams.list.return_value = { - entity_id: Stream( - [ - Producer(f"rtsp://127.0.0.1:18554/{stream_name_original}"), - Producer(f"ffmpeg:{stream_name_original}#audio=opus"), - ] - ) - } - receive_message_callback.reset_mock() - ws_client.reset_mock() - await test() - - assert rest_client.streams.add.call_args_list == stream_added_calls - - # Stream original source different - rest_client.streams.add.reset_mock() - rest_client.streams.list.return_value = { - stream_name_original: Stream([Producer("rtsp://different")]), - entity_id: Stream( - [ - Producer(f"rtsp://127.0.0.1:18554/{stream_name_original}"), - Producer(f"ffmpeg:{stream_name_original}#audio=opus"), - ] - ), - } - - receive_message_callback.reset_mock() - ws_client.reset_mock() - await test() - - assert rest_client.streams.add.call_args_list == stream_added_calls - - # Stream source different - rest_client.streams.add.reset_mock() - rest_client.streams.list.return_value = { - stream_name_original: Stream([Producer("rtsp://stream")]), - entity_id: Stream([Producer("rtsp://different")]), - } - - receive_message_callback.reset_mock() - ws_client.reset_mock() - await test() - - assert rest_client.streams.add.call_args_list == stream_added_calls - - # If the stream is already added, the stream should not be added again. - rest_client.streams.add.reset_mock() - rest_client.streams.list.return_value = { - stream_name_original: Stream([Producer("rtsp://stream")]), - entity_id: Stream( - [ - Producer(f"rtsp://127.0.0.1:18554/{stream_name_original}"), - Producer(f"ffmpeg:{stream_name_original}#audio=opus"), - ] - ), - } + def after_setup() -> None: + server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled) + server_start.assert_called_once() - receive_message_callback.reset_mock() - ws_client.reset_mock() - await test() - - rest_client.streams.add.assert_not_called() - assert isinstance(camera._webrtc_provider, WebRTCProvider) - - # Set stream source to None and provider should be skipped - rest_client.streams.list.return_value = {} - receive_message_callback.reset_mock() - camera.set_stream_source(None) - await camera.async_handle_async_webrtc_offer( - OFFER_SDP, "session_id", receive_message_callback - ) - receive_message_callback.assert_called_once_with( - WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") + await _test_setup_and_signaling( + hass, rest_client, ws_client, config, after_setup, init_test_integration ) await hass.async_stop() @@ -448,7 +340,7 @@ async def test() -> None: ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) -async def test_setup_self_hosted( +async def test_setup_go( hass: HomeAssistant, rest_client: AsyncMock, ws_client: Mock, @@ -458,83 +350,16 @@ async def test_setup_self_hosted( mock_is_docker_env: Mock, has_go2rtc_entry: bool, ) -> None: - """Test the go2rtc with selfhosted go2rtc instance.""" + """Test the go2rtc config entry without binary.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry config = {DOMAIN: {CONF_URL: "http://localhost:1984/"}} - camera = init_test_integration - - entity_id = camera.entity_id - assert camera.frontend_stream_type == StreamType.HLS - - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done(wait_background_tasks=True) - config_entries = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries) == 1 - assert config_entries[0].state == ConfigEntryState.LOADED - server.assert_not_called() - - receive_message_callback = Mock(spec_set=WebRTCSendMessage) - - async def test() -> None: - await camera.async_handle_async_webrtc_offer( - OFFER_SDP, "session_id", receive_message_callback - ) - ws_client.send.assert_called_once_with( - WebRTCOffer( - OFFER_SDP, - camera.async_get_webrtc_client_configuration().configuration.ice_servers, - ) - ) - ws_client.subscribe.assert_called_once() - - # Simulate the answer from the go2rtc server - callback = ws_client.subscribe.call_args[0][0] - callback(WebRTCAnswer(ANSWER_SDP)) - receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP)) - - await test() - - rest_client.streams.add.assert_called_once_with( - entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] - ) - - # Stream exists but the source is different - rest_client.streams.add.reset_mock() - rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://different")]) - } - - receive_message_callback.reset_mock() - ws_client.reset_mock() - await test() - - rest_client.streams.add.assert_called_once_with( - entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] - ) - - # If the stream is already added, the stream should not be added again. - rest_client.streams.add.reset_mock() - rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://stream")]) - } - - receive_message_callback.reset_mock() - ws_client.reset_mock() - await test() - rest_client.streams.add.assert_not_called() - assert isinstance(camera._webrtc_provider, WebRTCProvider) + def after_setup() -> None: + server.assert_not_called() - # Set stream source to None and provider should be skipped - rest_client.streams.list.return_value = {} - receive_message_callback.reset_mock() - camera.set_stream_source(None) - await camera.async_handle_async_webrtc_offer( - OFFER_SDP, "session_id", receive_message_callback - ) - receive_message_callback.assert_called_once_with( - WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") + await _test_setup_and_signaling( + hass, rest_client, ws_client, config, after_setup, init_test_integration ) mock_get_binary.assert_not_called() From ac0c75a598e4e7ee2c27b37e19a9ec5cefb8cd5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 12 Nov 2024 15:27:53 +0100 Subject: [PATCH 0395/1070] Add upload capability to the backup integration (#128546) * Add upload capability to the backup integration * Limit context switch * rename * coverage for http * Test receiving a backup file * Update test_manager.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/http.py | 37 ++++++++++-- homeassistant/components/backup/manager.py | 70 ++++++++++++++++++++++ tests/components/backup/test_http.py | 57 +++++++++++++++++- tests/components/backup/test_manager.py | 38 +++++++++++- 4 files changed, 195 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 4cc4e61c9e4cc..42693035bd351 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -2,23 +2,26 @@ from __future__ import annotations +import asyncio from http import HTTPStatus +from typing import cast +from aiohttp import BodyPartReader from aiohttp.hdrs import CONTENT_DISPOSITION from aiohttp.web import FileResponse, Request, Response -from homeassistant.components.http import KEY_HASS, HomeAssistantView +from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify -from .const import DOMAIN -from .manager import BaseBackupManager +from .const import DATA_MANAGER @callback def async_register_http_views(hass: HomeAssistant) -> None: """Register the http views.""" hass.http.register_view(DownloadBackupView) + hass.http.register_view(UploadBackupView) class DownloadBackupView(HomeAssistantView): @@ -36,7 +39,7 @@ async def get( if not request["hass_user"].is_admin: return Response(status=HTTPStatus.UNAUTHORIZED) - manager: BaseBackupManager = request.app[KEY_HASS].data[DOMAIN] + manager = request.app[KEY_HASS].data[DATA_MANAGER] backup = await manager.async_get_backup(slug=slug) if backup is None or not backup.path.exists(): @@ -48,3 +51,29 @@ async def get( CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" }, ) + + +class UploadBackupView(HomeAssistantView): + """Generate backup view.""" + + url = "/api/backup/upload" + name = "api:backup:upload" + + @require_admin + async def post(self, request: Request) -> Response: + """Upload a backup file.""" + manager = request.app[KEY_HASS].data[DATA_MANAGER] + reader = await request.multipart() + contents = cast(BodyPartReader, await reader.next()) + + try: + await manager.async_receive_backup(contents=contents) + except OSError as err: + return Response( + body=f"Can't write backup file {err}", + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + except asyncio.CancelledError: + return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) + + return Response(status=HTTPStatus.CREATED) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 8265dade3aae7..4300f75eed0be 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -9,11 +9,15 @@ import io import json from pathlib import Path +from queue import SimpleQueue +import shutil import tarfile from tarfile import TarError +from tempfile import TemporaryDirectory import time from typing import Any, Protocol, cast +import aiohttp from securetar import SecureTarFile, atomic_contents_add from homeassistant.backup_restore import RESTORE_BACKUP_FILE @@ -147,6 +151,15 @@ async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None: async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None: """Remove a backup.""" + @abc.abstractmethod + async def async_receive_backup( + self, + *, + contents: aiohttp.BodyPartReader, + **kwargs: Any, + ) -> None: + """Receive and store a backup file from upload.""" + class BackupManager(BaseBackupManager): """Backup manager for the Backup integration.""" @@ -222,6 +235,63 @@ async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None: LOGGER.debug("Removed backup located at %s", backup.path) self.backups.pop(slug) + async def async_receive_backup( + self, + *, + contents: aiohttp.BodyPartReader, + **kwargs: Any, + ) -> None: + """Receive and store a backup file from upload.""" + queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = ( + SimpleQueue() + ) + temp_dir_handler = await self.hass.async_add_executor_job(TemporaryDirectory) + target_temp_file = Path( + temp_dir_handler.name, contents.filename or "backup.tar" + ) + + def _sync_queue_consumer() -> None: + with target_temp_file.open("wb") as file_handle: + while True: + if (_chunk_future := queue.get()) is None: + break + _chunk, _future = _chunk_future + if _future is not None: + self.hass.loop.call_soon_threadsafe(_future.set_result, None) + file_handle.write(_chunk) + + fut: asyncio.Future[None] | None = None + try: + fut = self.hass.async_add_executor_job(_sync_queue_consumer) + megabytes_sending = 0 + while chunk := await contents.read_chunk(BUF_SIZE): + megabytes_sending += 1 + if megabytes_sending % 5 != 0: + queue.put_nowait((chunk, None)) + continue + + chunk_future = self.hass.loop.create_future() + queue.put_nowait((chunk, chunk_future)) + await asyncio.wait( + (fut, chunk_future), + return_when=asyncio.FIRST_COMPLETED, + ) + if fut.done(): + # The executor job failed + break + + queue.put_nowait(None) # terminate queue consumer + finally: + if fut is not None: + await fut + + def _move_and_cleanup() -> None: + shutil.move(target_temp_file, self.backup_dir / target_temp_file.name) + temp_dir_handler.cleanup() + + await self.hass.async_add_executor_job(_move_and_cleanup) + await self.load_backups() + async def async_create_backup(self, **kwargs: Any) -> Backup: """Generate a backup.""" if self.backing_up: diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index 93ecb27bc9770..76b1f76b55b60 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -1,8 +1,11 @@ """Tests for the Backup integration.""" +import asyncio +from io import StringIO from unittest.mock import patch from aiohttp import web +import pytest from homeassistant.core import HomeAssistant @@ -49,12 +52,12 @@ async def test_downloading_backup_not_found( assert resp.status == 404 -async def test_non_admin( +async def test_downloading_as_non_admin( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser, ) -> None: - """Test downloading a backup file that does not exist.""" + """Test downloading a backup file when you are not an admin.""" hass_admin_user.groups = [] await setup_backup_integration(hass) @@ -62,3 +65,53 @@ async def test_non_admin( resp = await client.get("/api/backup/download/abc123") assert resp.status == 401 + + +async def test_uploading_a_backup_file( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test uploading a backup file.""" + await setup_backup_integration(hass) + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + ) as async_receive_backup_mock: + resp = await client.post( + "/api/backup/upload", + data={"file": StringIO("test")}, + ) + assert resp.status == 201 + assert async_receive_backup_mock.called + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (OSError("Boom!"), "Can't write backup file Boom!"), + (asyncio.CancelledError("Boom!"), ""), + ], +) +async def test_error_handling_uploading_a_backup_file( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + error: Exception, + message: str, +) -> None: + """Test error handling when uploading a backup file.""" + await setup_backup_integration(hass) + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + side_effect=error, + ): + resp = await client.post( + "/api/backup/upload", + data={"file": StringIO("test")}, + ) + assert resp.status == 500 + assert await resp.text() == message diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index a4dba5c6936d6..a3f70267643b0 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -3,8 +3,10 @@ from __future__ import annotations from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch +import aiohttp +from multidict import CIMultiDict, CIMultiDictProxy import pytest from homeassistant.components.backup import BackupManager @@ -335,6 +337,40 @@ async def test_loading_platforms_when_running_async_post_backup_actions( assert "Loaded 1 platforms" in caplog.text +async def test_async_receive_backup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test receiving a backup file.""" + manager = BackupManager(hass) + + size = 2 * 2**16 + protocol = Mock(_reading_paused=False) + stream = aiohttp.StreamReader(protocol, 2**16) + stream.feed_data(b"0" * size + b"\r\n--:--") + stream.feed_eof() + + open_mock = mock_open() + + with patch("pathlib.Path.open", open_mock), patch("shutil.move") as mover_mock: + await manager.async_receive_backup( + contents=aiohttp.BodyPartReader( + b"--:", + CIMultiDictProxy( + CIMultiDict( + { + aiohttp.hdrs.CONTENT_DISPOSITION: "attachment; filename=abc123.tar" + } + ) + ), + stream, + ) + ) + assert open_mock.call_count == 1 + assert mover_mock.call_count == 1 + assert mover_mock.mock_calls[0].args[1].name == "abc123.tar" + + async def test_async_trigger_restore( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, From 167025a18c032998517e4a7762bf1a10997b49bb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 12 Nov 2024 18:03:37 +0100 Subject: [PATCH 0396/1070] Simplify modern_forms config flow (#130441) * Simplify modern_forms config flow * Rename variable * Drop CONF_NAME --- .../components/modern_forms/config_flow.py | 49 ++++++++----------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/modern_forms/config_flow.py b/homeassistant/components/modern_forms/config_flow.py index dee08736234c5..33e814efb5143 100644 --- a/homeassistant/components/modern_forms/config_flow.py +++ b/homeassistant/components/modern_forms/config_flow.py @@ -9,11 +9,13 @@ from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +USER_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a ModernForms config flow.""" @@ -55,17 +57,21 @@ async def _handle_config_flow( self, user_input: dict[str, Any] | None = None, prepare: bool = False ) -> ConfigFlowResult: """Config flow handler for ModernForms.""" - source = self.context["source"] - # Request user input, unless we are preparing discovery flow if user_input is None: user_input = {} if not prepare: - if source == SOURCE_ZEROCONF: - return self._show_confirm_dialog() - return self._show_setup_form() - - if source == SOURCE_ZEROCONF: + if self.source == SOURCE_ZEROCONF: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self.name}, + ) + return self.async_show_form( + step_id="user", + data_schema=USER_SCHEMA, + ) + + if self.source == SOURCE_ZEROCONF: user_input[CONF_HOST] = self.host user_input[CONF_MAC] = self.mac @@ -75,18 +81,21 @@ async def _handle_config_flow( try: device = await device.update() except ModernFormsConnectionError: - if source == SOURCE_ZEROCONF: + if self.source == SOURCE_ZEROCONF: return self.async_abort(reason="cannot_connect") - return self._show_setup_form({"base": "cannot_connect"}) + return self.async_show_form( + step_id="user", + data_schema=USER_SCHEMA, + errors={"base": "cannot_connect"}, + ) user_input[CONF_MAC] = device.info.mac_address - user_input[CONF_NAME] = device.info.device_name # Check if already configured await self.async_set_unique_id(user_input[CONF_MAC]) self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) title = device.info.device_name - if source == SOURCE_ZEROCONF: + if self.source == SOURCE_ZEROCONF: title = self.name if prepare: @@ -96,19 +105,3 @@ async def _handle_config_flow( title=title, data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: user_input[CONF_MAC]}, ) - - def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult: - """Show the setup form to the user.""" - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({vol.Required(CONF_HOST): str}), - errors=errors or {}, - ) - - def _show_confirm_dialog(self, errors: dict | None = None) -> ConfigFlowResult: - """Show the confirm dialog to the user.""" - return self.async_show_form( - step_id="zeroconf_confirm", - description_placeholders={"name": self.name}, - errors=errors or {}, - ) From 285468d85f7911b55a0450981ddb669d50009ffc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 12 Nov 2024 18:44:32 +0100 Subject: [PATCH 0397/1070] Fix translation in statistics (#130455) * Fix translation in statistics * Update homeassistant/components/statistics/strings.json --- homeassistant/components/statistics/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/statistics/strings.json b/homeassistant/components/statistics/strings.json index a060c88da249c..3e6fec9d986fb 100644 --- a/homeassistant/components/statistics/strings.json +++ b/homeassistant/components/statistics/strings.json @@ -23,10 +23,10 @@ "state_characteristic": { "description": "Read the documention for further details on available options and how to use them.", "data": { - "state_characteristic": "State_characteristic" + "state_characteristic": "Statistic characteristic" }, "data_description": { - "state_characteristic": "The characteristic that should be used as the state of the statistics sensor." + "state_characteristic": "The statistic characteristic that should be used as the state of the sensor." } }, "options": { From 388473ecd7adaec1658caac9f05208ee9c319223 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 12 Nov 2024 19:55:27 +0100 Subject: [PATCH 0398/1070] Add diagnostics to Nord Pool (#130461) --- .../components/nordpool/diagnostics.py | 16 + .../nordpool/snapshots/test_diagnostics.ambr | 283 ++++++++++++++++++ tests/components/nordpool/test_diagnostics.py | 23 ++ 3 files changed, 322 insertions(+) create mode 100644 homeassistant/components/nordpool/diagnostics.py create mode 100644 tests/components/nordpool/snapshots/test_diagnostics.ambr create mode 100644 tests/components/nordpool/test_diagnostics.py diff --git a/homeassistant/components/nordpool/diagnostics.py b/homeassistant/components/nordpool/diagnostics.py new file mode 100644 index 0000000000000..3160c2bfa6d56 --- /dev/null +++ b/homeassistant/components/nordpool/diagnostics.py @@ -0,0 +1,16 @@ +"""Diagnostics support for Nord Pool.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import NordPoolConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: NordPoolConfigEntry +) -> dict[str, Any]: + """Return diagnostics for Nord Pool config entry.""" + return {"raw": entry.runtime_data.data.raw} diff --git a/tests/components/nordpool/snapshots/test_diagnostics.ambr b/tests/components/nordpool/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..dde2eca0022e0 --- /dev/null +++ b/tests/components/nordpool/snapshots/test_diagnostics.ambr @@ -0,0 +1,283 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'raw': dict({ + 'areaAverages': list([ + dict({ + 'areaCode': 'SE3', + 'price': 900.74, + }), + dict({ + 'areaCode': 'SE4', + 'price': 1166.12, + }), + ]), + 'areaStates': list([ + dict({ + 'areas': list([ + 'SE3', + 'SE4', + ]), + 'state': 'Final', + }), + ]), + 'blockPriceAggregates': list([ + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 422.87, + 'max': 1406.14, + 'min': 61.69, + }), + 'SE4': dict({ + 'average': 497.97, + 'max': 1648.25, + 'min': 65.19, + }), + }), + 'blockName': 'Off-peak 1', + 'deliveryEnd': '2024-11-05T07:00:00Z', + 'deliveryStart': '2024-11-04T23:00:00Z', + }), + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 1315.97, + 'max': 2512.65, + 'min': 925.05, + }), + 'SE4': dict({ + 'average': 1735.59, + 'max': 3533.03, + 'min': 1081.72, + }), + }), + 'blockName': 'Peak', + 'deliveryEnd': '2024-11-05T19:00:00Z', + 'deliveryStart': '2024-11-05T07:00:00Z', + }), + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 610.79, + 'max': 835.53, + 'min': 289.14, + }), + 'SE4': dict({ + 'average': 793.98, + 'max': 1112.57, + 'min': 349.21, + }), + }), + 'blockName': 'Off-peak 2', + 'deliveryEnd': '2024-11-05T23:00:00Z', + 'deliveryStart': '2024-11-05T19:00:00Z', + }), + ]), + 'currency': 'SEK', + 'deliveryAreas': list([ + 'SE3', + 'SE4', + ]), + 'deliveryDateCET': '2024-11-05', + 'exchangeRate': 11.6402, + 'market': 'DayAhead', + 'multiAreaEntries': list([ + dict({ + 'deliveryEnd': '2024-11-05T00:00:00Z', + 'deliveryStart': '2024-11-04T23:00:00Z', + 'entryPerArea': dict({ + 'SE3': 250.73, + 'SE4': 283.79, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T01:00:00Z', + 'deliveryStart': '2024-11-05T00:00:00Z', + 'entryPerArea': dict({ + 'SE3': 76.36, + 'SE4': 81.36, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T02:00:00Z', + 'deliveryStart': '2024-11-05T01:00:00Z', + 'entryPerArea': dict({ + 'SE3': 73.92, + 'SE4': 79.15, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T03:00:00Z', + 'deliveryStart': '2024-11-05T02:00:00Z', + 'entryPerArea': dict({ + 'SE3': 61.69, + 'SE4': 65.19, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T04:00:00Z', + 'deliveryStart': '2024-11-05T03:00:00Z', + 'entryPerArea': dict({ + 'SE3': 64.6, + 'SE4': 68.44, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T05:00:00Z', + 'deliveryStart': '2024-11-05T04:00:00Z', + 'entryPerArea': dict({ + 'SE3': 453.27, + 'SE4': 516.71, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T06:00:00Z', + 'deliveryStart': '2024-11-05T05:00:00Z', + 'entryPerArea': dict({ + 'SE3': 996.28, + 'SE4': 1240.85, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T07:00:00Z', + 'deliveryStart': '2024-11-05T06:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1406.14, + 'SE4': 1648.25, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T08:00:00Z', + 'deliveryStart': '2024-11-05T07:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1346.54, + 'SE4': 1570.5, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T09:00:00Z', + 'deliveryStart': '2024-11-05T08:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1150.28, + 'SE4': 1345.37, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T10:00:00Z', + 'deliveryStart': '2024-11-05T09:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1031.32, + 'SE4': 1206.51, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T11:00:00Z', + 'deliveryStart': '2024-11-05T10:00:00Z', + 'entryPerArea': dict({ + 'SE3': 927.37, + 'SE4': 1085.8, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T12:00:00Z', + 'deliveryStart': '2024-11-05T11:00:00Z', + 'entryPerArea': dict({ + 'SE3': 925.05, + 'SE4': 1081.72, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T13:00:00Z', + 'deliveryStart': '2024-11-05T12:00:00Z', + 'entryPerArea': dict({ + 'SE3': 949.49, + 'SE4': 1130.38, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T14:00:00Z', + 'deliveryStart': '2024-11-05T13:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1042.03, + 'SE4': 1256.91, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T15:00:00Z', + 'deliveryStart': '2024-11-05T14:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1258.89, + 'SE4': 1765.82, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T16:00:00Z', + 'deliveryStart': '2024-11-05T15:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1816.45, + 'SE4': 2522.55, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T17:00:00Z', + 'deliveryStart': '2024-11-05T16:00:00Z', + 'entryPerArea': dict({ + 'SE3': 2512.65, + 'SE4': 3533.03, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T18:00:00Z', + 'deliveryStart': '2024-11-05T17:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1819.83, + 'SE4': 2524.06, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T19:00:00Z', + 'deliveryStart': '2024-11-05T18:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1011.77, + 'SE4': 1804.46, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T20:00:00Z', + 'deliveryStart': '2024-11-05T19:00:00Z', + 'entryPerArea': dict({ + 'SE3': 835.53, + 'SE4': 1112.57, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T21:00:00Z', + 'deliveryStart': '2024-11-05T20:00:00Z', + 'entryPerArea': dict({ + 'SE3': 796.19, + 'SE4': 1051.69, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T22:00:00Z', + 'deliveryStart': '2024-11-05T21:00:00Z', + 'entryPerArea': dict({ + 'SE3': 522.3, + 'SE4': 662.44, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T23:00:00Z', + 'deliveryStart': '2024-11-05T22:00:00Z', + 'entryPerArea': dict({ + 'SE3': 289.14, + 'SE4': 349.21, + }), + }), + ]), + 'updatedAt': '2024-11-04T12:15:03.9456464Z', + 'version': 3, + }), + }) +# --- diff --git a/tests/components/nordpool/test_diagnostics.py b/tests/components/nordpool/test_diagnostics.py new file mode 100644 index 0000000000000..4639186ecf1e8 --- /dev/null +++ b/tests/components/nordpool/test_diagnostics.py @@ -0,0 +1,23 @@ +"""Test Nord Pool diagnostics.""" + +from __future__ import annotations + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + load_int: ConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, load_int) == snapshot + ) From 6bfc0cbb0c1db6ade27290bf86cd29487af30ece Mon Sep 17 00:00:00 2001 From: Kelvin Dekker <143089625+KelvinDekker@users.noreply.github.com> Date: Tue, 12 Nov 2024 21:33:52 +0100 Subject: [PATCH 0399/1070] Fix typo in file strings (#130465) --- homeassistant/components/file/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json index 60ebf451f78d7..8806c67cd9670 100644 --- a/homeassistant/components/file/strings.json +++ b/homeassistant/components/file/strings.json @@ -18,7 +18,7 @@ }, "data_description": { "file_path": "The local file path to retrieve the sensor value from", - "value_template": "A template to render the the sensors value based on the file content", + "value_template": "A template to render the sensors value based on the file content", "unit_of_measurement": "Unit of measurement for the sensor" } }, From 5c52e865a0e95a83a94162e21424cd0be2d372c9 Mon Sep 17 00:00:00 2001 From: mrspouse <55619185+mrspouse@users.noreply.github.com> Date: Tue, 12 Nov 2024 20:48:42 +0000 Subject: [PATCH 0400/1070] Correct spelling of BloodGlucoseConcentrationConverter (#130449) * Correct spelling of BloodGlucoseConcentrationConverter * Correct spelling of BloodGlucoseConcentrationConverter --- homeassistant/components/recorder/statistics.py | 6 +++--- homeassistant/components/recorder/websocket_api.py | 4 ++-- homeassistant/components/sensor/const.py | 4 ++-- homeassistant/util/unit_conversion.py | 2 +- tests/util/test_unit_conversion.py | 8 ++++---- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index e5fbfe0e8c58b..7243af9d4d548 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -28,7 +28,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, - BloodGlugoseConcentrationConverter, + BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -130,8 +130,8 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **{ - unit: BloodGlugoseConcentrationConverter - for unit in BloodGlugoseConcentrationConverter.VALID_UNITS + unit: BloodGlucoseConcentrationConverter + for unit in BloodGlucoseConcentrationConverter.VALID_UNITS }, **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 8b8d1cfb0c657..f4dce73fa4739 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -16,7 +16,7 @@ from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( - BloodGlugoseConcentrationConverter, + BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -56,7 +56,7 @@ UNIT_SCHEMA = vol.Schema( { vol.Optional("blood_glucose_concentration"): vol.In( - BloodGlugoseConcentrationConverter.VALID_UNITS + BloodGlucoseConcentrationConverter.VALID_UNITS ), vol.Optional("conductivity"): vol.In(ConductivityConverter.VALID_UNITS), vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index ee6167a564336..f4573f873a280 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -48,7 +48,7 @@ ) from homeassistant.util.unit_conversion import ( BaseUnitConverter, - BloodGlugoseConcentrationConverter, + BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -501,7 +501,7 @@ class SensorStateClass(StrEnum): UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, - SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlugoseConcentrationConverter, + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter, SensorDeviceClass.CONDUCTIVITY: ConductivityConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, SensorDeviceClass.DATA_RATE: DataRateConverter, diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 95d8fbc9df120..1bf3561e66a35 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -174,7 +174,7 @@ class DistanceConverter(BaseUnitConverter): } -class BloodGlugoseConcentrationConverter(BaseUnitConverter): +class BloodGlucoseConcentrationConverter(BaseUnitConverter): """Utility to convert blood glucose concentration values.""" UNIT_CLASS = "blood_glucose_concentration" diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index a57cdde821fc8..609809a96e8ba 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -33,7 +33,7 @@ from homeassistant.util import unit_conversion from homeassistant.util.unit_conversion import ( BaseUnitConverter, - BloodGlugoseConcentrationConverter, + BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -61,7 +61,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { converter: sorted(converter.VALID_UNITS, key=lambda x: (x is None, x)) for converter in ( - BloodGlugoseConcentrationConverter, + BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -83,7 +83,7 @@ # Dict containing all converters with a corresponding unit ratio. _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, float]] = { - BloodGlugoseConcentrationConverter: ( + BloodGlucoseConcentrationConverter: ( UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, 18, @@ -138,7 +138,7 @@ _CONVERTED_VALUE: dict[ type[BaseUnitConverter], list[tuple[float, str | None, float, str | None]] ] = { - BloodGlugoseConcentrationConverter: [ + BloodGlucoseConcentrationConverter: [ ( 90, UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, From 4ff8b8015cdb5450f26707230194049a0af682ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Nov 2024 22:07:26 -0600 Subject: [PATCH 0401/1070] Bump aiohttp to 3.11.0rc2 (#130484) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a40c874587735..956ea032fe7ff 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.11.0rc1 +aiohttp==3.11.0rc2 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index adc85c0f4f721..8e588ce0b0e53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.0rc1", + "aiohttp==3.11.0rc2", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index 53d6b13a4ab3a..ac7c00b805091 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.0rc1 +aiohttp==3.11.0rc2 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From a9f468509b7660737c79337aa11f815b6a0744ff Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 13 Nov 2024 01:14:39 -0500 Subject: [PATCH 0402/1070] Bump zwave-js-server-python to 0.59.1 (#130468) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index e3f643486a007..3631bf1163b97 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -10,7 +10,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.59.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.59.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 67c7c99114698..b7a979050bf6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3087,7 +3087,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.59.0 +zwave-js-server-python==0.59.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 048f0ac7d7601..ec6be67d4b48e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2461,7 +2461,7 @@ zeversolar==0.3.2 zha==0.0.37 # homeassistant.components.zwave_js -zwave-js-server-python==0.59.0 +zwave-js-server-python==0.59.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 8b505a2273aeab31dd89ac86ce2cbb1b78f99e74 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 13 Nov 2024 07:35:51 +0100 Subject: [PATCH 0403/1070] Bump reolink_aio to 0.11.0 (#130481) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 23a46c5e1c992..22fd625770fa1 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.10.4"] + "requirements": ["reolink-aio==0.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b7a979050bf6b..0009c93f673d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2553,7 +2553,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.4 +reolink-aio==0.11.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec6be67d4b48e..7ad45aae83286 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2044,7 +2044,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.4 +reolink-aio==0.11.0 # homeassistant.components.rflink rflink==0.0.66 From fdb773c9216be11a342ca8a4aa3dd9749e065622 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Nov 2024 07:55:13 +0100 Subject: [PATCH 0404/1070] Add title to water heater component (#130446) --- homeassistant/components/water_heater/strings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 741b277d84df6..07e132a0b5bb5 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -1,4 +1,5 @@ { + "title": "Water heater", "device_automation": { "action_type": { "turn_on": "[%key:common::device_automation::action_type::turn_on%]", @@ -7,7 +8,7 @@ }, "entity_component": { "_": { - "name": "Water heater", + "name": "[%key:component::water_heater::title%]", "state": { "off": "[%key:common::state::off%]", "eco": "Eco", From 5cce369ce82a4ece9a2ec3888751974626eb16de Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Nov 2024 07:55:33 +0100 Subject: [PATCH 0405/1070] Bump aiowithings to 3.1.2 (#130469) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index a0a86be5da380..c24bdb743bfef 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==3.1.1"] + "requirements": ["aiowithings==3.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0009c93f673d5..a5898c91708e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -417,7 +417,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.1 +aiowithings==3.1.2 # homeassistant.components.yandex_transport aioymaps==1.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ad45aae83286..a7f382e02511a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -399,7 +399,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.1 +aiowithings==3.1.2 # homeassistant.components.yandex_transport aioymaps==1.2.5 From 827875473bb133451005d4987aa07edc2a984a36 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 13 Nov 2024 09:54:37 +0100 Subject: [PATCH 0406/1070] Fix RecursionError in Husqvarna Automower coordinator (#123085) * reach maximum recursion depth exceeded in tests * second background task * Update homeassistant/components/husqvarna_automower/coordinator.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/husqvarna_automower/coordinator.py Co-authored-by: Martin Hjelmare * test * modify test * tests * use correct exception * reset mock * use recursion_limit * remove unneeded ticks * test TimeoutException * set lower recursionlimit * remove not that important comment and move the other * test that we connect and listen successfully * Simulate hass shutting down * skip testing against the recursion limit * Update homeassistant/components/husqvarna_automower/coordinator.py Co-authored-by: Martin Hjelmare * mock * Remove comment * Revert "mock" This reverts commit e8ddaea3d79ed1aceb696a055cc42ad08b4febca. * Move patch to decorator * Make execution of patched methods predictable * Parametrize test, make mocked start_listening block * Apply suggestions from code review --------- Co-authored-by: Martin Hjelmare Co-authored-by: Erik --- .../husqvarna_automower/coordinator.py | 30 ++++--- .../husqvarna_automower/conftest.py | 8 ++ .../husqvarna_automower/test_init.py | 81 +++++++++++++++---- 3 files changed, 92 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 458ff50dac9dd..c19f37a040d9c 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -8,6 +8,7 @@ ApiException, AuthException, HusqvarnaWSServerHandshakeError, + TimeoutException, ) from aioautomower.model import MowerAttributes from aioautomower.session import AutomowerSession @@ -22,6 +23,7 @@ _LOGGER = logging.getLogger(__name__) MAX_WS_RECONNECT_TIME = 600 SCAN_INTERVAL = timedelta(minutes=8) +DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): @@ -40,8 +42,8 @@ def __init__( update_interval=SCAN_INTERVAL, ) self.api = api - self.ws_connected: bool = False + self.reconnect_time = DEFAULT_RECONNECT_TIME async def _async_update_data(self) -> dict[str, MowerAttributes]: """Subscribe for websocket and poll data from the API.""" @@ -66,24 +68,28 @@ async def client_listen( hass: HomeAssistant, entry: ConfigEntry, automower_client: AutomowerSession, - reconnect_time: int = 2, ) -> None: """Listen with the client.""" try: await automower_client.auth.websocket_connect() - reconnect_time = 2 + # Reset reconnect time after successful connection + self.reconnect_time = DEFAULT_RECONNECT_TIME await automower_client.start_listening() except HusqvarnaWSServerHandshakeError as err: _LOGGER.debug( - "Failed to connect to websocket. Trying to reconnect: %s", err + "Failed to connect to websocket. Trying to reconnect: %s", + err, + ) + except TimeoutException as err: + _LOGGER.debug( + "Failed to listen to websocket. Trying to reconnect: %s", + err, ) - if not hass.is_stopping: - await asyncio.sleep(reconnect_time) - reconnect_time = min(reconnect_time * 2, MAX_WS_RECONNECT_TIME) - await self.client_listen( - hass=hass, - entry=entry, - automower_client=automower_client, - reconnect_time=reconnect_time, + await asyncio.sleep(self.reconnect_time) + self.reconnect_time = min(self.reconnect_time * 2, MAX_WS_RECONNECT_TIME) + entry.async_create_background_task( + hass, + self.client_listen(hass, entry, automower_client), + "reconnect_task", ) diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 2814e1558d137..0202cec05b971 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -1,5 +1,6 @@ """Test helpers for Husqvarna Automower.""" +import asyncio from collections.abc import Generator import time from unittest.mock import AsyncMock, patch @@ -101,10 +102,17 @@ async def setup_credentials(hass: HomeAssistant) -> None: def mock_automower_client(values) -> Generator[AsyncMock]: """Mock a Husqvarna Automower client.""" + async def listen() -> None: + """Mock listen.""" + listen_block = asyncio.Event() + await listen_block.wait() + pytest.fail("Listen was not cancelled!") + mock = AsyncMock(spec=AutomowerSession) mock.auth = AsyncMock(side_effect=ClientWebSocketResponse) mock.commands = AsyncMock(spec_set=_MowerCommands) mock.get_status.return_value = values + mock.start_listening = AsyncMock(side_effect=listen) with patch( "homeassistant.components.husqvarna_automower.AutomowerSession", diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index ca0c2a04af1a7..ae688571d2c64 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -1,14 +1,16 @@ """Tests for init module.""" -from datetime import datetime, timedelta +from asyncio import Event +from datetime import datetime import http import time -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from aioautomower.exceptions import ( ApiException, AuthException, HusqvarnaWSServerHandshakeError, + TimeoutException, ) from aioautomower.model import MowerAttributes, WorkArea from freezegun.api import FrozenDateTimeFactory @@ -127,28 +129,77 @@ async def test_update_failed( assert entry.state is entry_state +@patch( + "homeassistant.components.husqvarna_automower.coordinator.DEFAULT_RECONNECT_TIME", 0 +) +@pytest.mark.parametrize( + ("method_path", "exception", "error_msg"), + [ + ( + ["auth", "websocket_connect"], + HusqvarnaWSServerHandshakeError, + "Failed to connect to websocket.", + ), + ( + ["start_listening"], + TimeoutException, + "Failed to listen to websocket.", + ), + ], +) async def test_websocket_not_available( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, + method_path: list[str], + exception: type[Exception], + error_msg: str, ) -> None: - """Test trying reload the websocket.""" - mock_automower_client.start_listening.side_effect = HusqvarnaWSServerHandshakeError( - "Boom" - ) + """Test trying to reload the websocket.""" + calls = [] + mock_called = Event() + mock_stall = Event() + + async def mock_function(): + mock_called.set() + await mock_stall.wait() + # Raise the first time the method is awaited + if not calls: + calls.append(None) + raise exception("Boom") + if mock_side_effect: + await mock_side_effect() + + # Find the method to mock + mock = mock_automower_client + for itm in method_path: + mock = getattr(mock, itm) + mock_side_effect = mock.side_effect + mock.side_effect = mock_function + + # Setup integration and verify log error message await setup_integration(hass, mock_config_entry) - assert "Failed to connect to websocket. Trying to reconnect: Boom" in caplog.text - assert mock_automower_client.auth.websocket_connect.call_count == 1 - assert mock_automower_client.start_listening.call_count == 1 - assert mock_config_entry.state is ConfigEntryState.LOADED - freezer.tick(timedelta(seconds=2)) - async_fire_time_changed(hass) + await mock_called.wait() + mock_called.clear() + # Allow the exception to be raised + mock_stall.set() + assert mock.call_count == 1 await hass.async_block_till_done() - assert mock_automower_client.auth.websocket_connect.call_count == 2 - assert mock_automower_client.start_listening.call_count == 2 - assert mock_config_entry.state is ConfigEntryState.LOADED + assert f"{error_msg} Trying to reconnect: Boom" in caplog.text + + # Simulate a successful connection + caplog.clear() + await mock_called.wait() + mock_called.clear() + await hass.async_block_till_done() + assert mock.call_count == 2 + assert "Trying to reconnect: Boom" not in caplog.text + + # Simulate hass shutting down + await hass.async_stop() + assert mock.call_count == 2 async def test_device_info( From 3092297979cd11c176f85bd1129a8f801577daae Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 13 Nov 2024 09:55:52 +0100 Subject: [PATCH 0407/1070] Bump go2rtc-client to 0.1.1 (#130498) --- homeassistant/components/go2rtc/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index ea9308e5e182a..201b7168847ae 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", - "requirements": ["go2rtc-client==0.1.0"], + "requirements": ["go2rtc-client==0.1.1"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 956ea032fe7ff..7a0e43b299e5e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ ciso8601==2.3.1 cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 -go2rtc-client==0.1.0 +go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 habluetooth==3.6.0 hass-nabucasa==0.84.0 diff --git a/requirements_all.txt b/requirements_all.txt index a5898c91708e2..9a27f4d3b040b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -993,7 +993,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.0 +go2rtc-client==0.1.1 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7f382e02511a..38704005179dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -843,7 +843,7 @@ gios==5.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.0 +go2rtc-client==0.1.1 # homeassistant.components.goalzero goalzero==0.2.2 From 0ac00ef0920067d241265393eb89ddd11e9ce65c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 13 Nov 2024 10:55:28 +0100 Subject: [PATCH 0408/1070] Fix legacy _attr_state handling in AlarmControlPanel (#130479) --- .../alarm_control_panel/__init__.py | 14 ++- .../alarm_control_panel/test_init.py | 93 +++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 2946fc64941e1..a9e433a365050 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta from functools import partial import logging -from typing import Any, Final, final +from typing import TYPE_CHECKING, Any, Final, final from propcache import cached_property import voluptuous as vol @@ -221,9 +221,15 @@ def _report_deprecated_alarm_state_handling(self) -> None: @property def state(self) -> str | None: """Return the current state.""" - if (alarm_state := self.alarm_state) is None: - return None - return alarm_state + if (alarm_state := self.alarm_state) is not None: + return alarm_state + if self._attr_state is not None: + # Backwards compatibility for integrations that set state directly + # Should be removed in 2025.11 + if TYPE_CHECKING: + assert isinstance(self._attr_state, str) + return self._attr_state + return None @cached_property def alarm_state(self) -> AlarmControlPanelState | None: diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 90b23f87ab127..89a2a2a2b1af2 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -489,3 +489,96 @@ async def async_setup_entry_platform( ) # Test we only log once assert "Entities should implement the 'alarm_state' property and" not in caplog.text + + +async def test_alarm_control_panel_deprecated_state_does_not_break_state( + hass: HomeAssistant, + code_format: CodeFormat | None, + supported_features: AlarmControlPanelEntityFeature, + code_arm_required: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test using _attr_state attribute does not break state.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [ALARM_CONTROL_PANEL_DOMAIN] + ) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + class MockLegacyAlarmControlPanel(MockAlarmControlPanel): + """Mocked alarm control entity.""" + + def __init__( + self, + supported_features: AlarmControlPanelEntityFeature = AlarmControlPanelEntityFeature( + 0 + ), + code_format: CodeFormat | None = None, + code_arm_required: bool = True, + ) -> None: + """Initialize the alarm control.""" + self._attr_state = "armed_away" + super().__init__(supported_features, code_format, code_arm_required) + + def alarm_disarm(self, code: str | None = None) -> None: + """Mock alarm disarm calls.""" + self._attr_state = "disarmed" + + entity = MockLegacyAlarmControlPanel( + supported_features=supported_features, + code_format=code_format, + code_arm_required=code_arm_required, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test alarm control panel platform via config entry.""" + async_add_entities([entity]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + with patch.object( + MockLegacyAlarmControlPanel, + "__module__", + "tests.custom_components.test.alarm_control_panel", + ): + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "armed_away" + + with patch.object( + MockLegacyAlarmControlPanel, + "__module__", + "tests.custom_components.test.alarm_control_panel", + ): + await help_test_async_alarm_control_panel_service( + hass, entity.entity_id, SERVICE_ALARM_DISARM + ) + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "disarmed" From 2eaaadd736e73ca4b90611ed13297572d990bf63 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 13 Nov 2024 11:01:05 +0100 Subject: [PATCH 0409/1070] Add go2rtc recommended version (#130508) --- .pre-commit-config.yaml | 2 +- homeassistant/components/go2rtc/__init__.py | 31 ++++++++++-- homeassistant/components/go2rtc/const.py | 1 + homeassistant/components/go2rtc/strings.json | 8 +++ script/hassfest/docker.py | 5 +- tests/components/go2rtc/conftest.py | 6 ++- tests/components/go2rtc/test_init.py | 52 ++++++++++++++++++-- 7 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/go2rtc/strings.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 519674b9894ec..56fbabe808780 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -90,7 +90,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml)$ + files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml|homeassistant/components/go2rtc/const\.py)$ - id: hassfest-mypy-config name: hassfest-mypy-config entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index fc91ef5e546e7..f1f6e44abc198 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -4,6 +4,7 @@ import shutil from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError +from awesomeversion import AwesomeVersion from go2rtc_client import Go2RtcRestClient from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError from go2rtc_client.ws import ( @@ -32,13 +33,23 @@ from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, discovery_flow +from homeassistant.helpers import ( + config_validation as cv, + discovery_flow, + issue_registry as ir, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, HA_MANAGED_URL +from .const import ( + CONF_DEBUG_UI, + DEBUG_UI_URL_MESSAGE, + DOMAIN, + HA_MANAGED_URL, + RECOMMENDED_VERSION, +) from .server import Server _LOGGER = logging.getLogger(__name__) @@ -147,7 +158,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Validate the server URL try: client = Go2RtcRestClient(async_get_clientsession(hass), url) - await client.validate_server_version() + version = await client.validate_server_version() + if version < AwesomeVersion(RECOMMENDED_VERSION): + ir.async_create_issue( + hass, + DOMAIN, + "recommended_version", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="recommended_version", + translation_placeholders={ + "recommended_version": RECOMMENDED_VERSION, + "current_version": str(version), + }, + ) except Go2RtcClientError as err: if isinstance(err.__cause__, _RETRYABLE_ERRORS): raise ConfigEntryNotReady( diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index d33ae3e389759..3c1c84c42b567 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,3 +6,4 @@ DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" +RECOMMENDED_VERSION = "1.9.7" diff --git a/homeassistant/components/go2rtc/strings.json b/homeassistant/components/go2rtc/strings.json new file mode 100644 index 0000000000000..e350c19af96cb --- /dev/null +++ b/homeassistant/components/go2rtc/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "recommended_version": { + "title": "Outdated go2rtc server detected", + "description": "We detected that you are using an outdated go2rtc server version. For the best experience, we recommend updating the go2rtc server to version `{recommended_version}`.\nCurrently you are using version `{current_version}`." + } + } +} diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 9d38d8f71282b..137bbc7ff66ca 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -4,6 +4,7 @@ from pathlib import Path from homeassistant import core +from homeassistant.components.go2rtc.const import RECOMMENDED_VERSION as GO2RTC_VERSION from homeassistant.const import Platform from homeassistant.util import executor, thread from script.gen_requirements_all import gather_recursive_requirements @@ -112,8 +113,6 @@ LABEL "com.github.actions.color"="gray-dark" """ -_GO2RTC_VERSION = "1.9.7" - def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]: package_versions: dict[str, str] = {} @@ -197,7 +196,7 @@ def _generate_files(config: Config) -> list[File]: DOCKERFILE_TEMPLATE.format( timeout=timeout, **package_versions, - go2rtc=_GO2RTC_VERSION, + go2rtc=GO2RTC_VERSION, ), config.root / "Dockerfile", ), diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 42b363b232440..abb139b89bfa5 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -3,9 +3,11 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from awesomeversion import AwesomeVersion from go2rtc_client.rest import _StreamClient, _WebRTCClient import pytest +from homeassistant.components.go2rtc.const import RECOMMENDED_VERSION from homeassistant.components.go2rtc.server import Server GO2RTC_PATH = "homeassistant.components.go2rtc" @@ -23,7 +25,9 @@ def rest_client() -> Generator[AsyncMock]: client = mock_client.return_value client.streams = streams = Mock(spec_set=_StreamClient) streams.list.return_value = {} - client.validate_server_version = AsyncMock() + client.validate_server_version = AsyncMock( + return_value=AwesomeVersion(RECOMMENDED_VERSION) + ) client.webrtc = Mock(spec_set=_WebRTCClient) yield client diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 9388110366ec1..0f1cac6942da1 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError +from awesomeversion import AwesomeVersion from go2rtc_client import Stream from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError from go2rtc_client.models import Producer @@ -36,10 +37,12 @@ CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, + RECOMMENDED_VERSION, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component @@ -199,6 +202,7 @@ async def async_unload_entry_init( async def _test_setup_and_signaling( hass: HomeAssistant, + issue_registry: ir.IssueRegistry, rest_client: AsyncMock, ws_client: Mock, config: ConfigType, @@ -211,6 +215,7 @@ async def _test_setup_and_signaling( assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done(wait_background_tasks=True) + assert issue_registry.async_get_issue(DOMAIN, "recommended_version") is None config_entries = hass.config_entries.async_entries(DOMAIN) assert len(config_entries) == 1 assert config_entries[0].state == ConfigEntryState.LOADED @@ -306,6 +311,7 @@ async def test() -> None: @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) async def test_setup_go_binary( hass: HomeAssistant, + issue_registry: ir.IssueRegistry, rest_client: AsyncMock, ws_client: Mock, server: AsyncMock, @@ -324,7 +330,13 @@ def after_setup() -> None: server_start.assert_called_once() await _test_setup_and_signaling( - hass, rest_client, ws_client, config, after_setup, init_test_integration + hass, + issue_registry, + rest_client, + ws_client, + config, + after_setup, + init_test_integration, ) await hass.async_stop() @@ -340,8 +352,9 @@ def after_setup() -> None: ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) -async def test_setup_go( +async def test_setup( hass: HomeAssistant, + issue_registry: ir.IssueRegistry, rest_client: AsyncMock, ws_client: Mock, server: Mock, @@ -359,7 +372,13 @@ def after_setup() -> None: server.assert_not_called() await _test_setup_and_signaling( - hass, rest_client, ws_client, config, after_setup, init_test_integration + hass, + issue_registry, + rest_client, + ws_client, + config, + after_setup, + init_test_integration, ) mock_get_binary.assert_not_called() @@ -711,3 +730,30 @@ async def test_config_entry_remove(hass: HomeAssistant) -> None: assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert not await hass.config_entries.async_setup(config_entry.entry_id) assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + +@pytest.mark.parametrize("config", [{DOMAIN: {CONF_URL: "http://localhost:1984"}}]) +@pytest.mark.usefixtures("server") +async def test_setup_with_recommended_version_repair( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + rest_client: AsyncMock, + config: ConfigType, +) -> None: + """Test setup integration entry fails.""" + rest_client.validate_server_version.return_value = AwesomeVersion("1.9.5") + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + + # Verify the issue is created + issue = issue_registry.async_get_issue(DOMAIN, "recommended_version") + assert issue + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.issue_id == "recommended_version" + assert issue.translation_key == "recommended_version" + assert issue.translation_placeholders == { + "recommended_version": RECOMMENDED_VERSION, + "current_version": "1.9.5", + } From a06e7e31b9fb7629fe654515eb85e6722eb19807 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Nov 2024 11:06:38 +0100 Subject: [PATCH 0410/1070] Bump github/codeql-action from 3.27.1 to 3.27.3 (#130489) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.27.1 to 3.27.3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3.27.1...v3.27.3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2c80c32245c06..48e37717232ac 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.27.1 + uses: github/codeql-action/init@v3.27.3 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.27.1 + uses: github/codeql-action/analyze@v3.27.3 with: category: "/language:python" From e90893e2bc25e4f1c08ad699b4b17d985ffba394 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Nov 2024 11:43:31 +0100 Subject: [PATCH 0411/1070] Fix Music Assistant manifest (#130515) --- homeassistant/components/music_assistant/manifest.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 23401f30abc3e..65e6652407f76 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -4,9 +4,8 @@ "after_dependencies": ["media_source", "media_player"], "codeowners": ["@music-assistant"], "config_flow": true, - "documentation": "https://music-assistant.io", + "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", - "issue_tracker": "https://github.com/music-assistant/hass-music-assistant/issues", "loggers": ["music_assistant"], "requirements": ["music-assistant-client==1.0.5"], "zeroconf": ["_mass._tcp.local."] From b270e4556c395af63b325d3a0681d12e4f904e0f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Nov 2024 12:16:07 +0100 Subject: [PATCH 0412/1070] Avoid core manifest to have an issue tracker (#130514) --- script/hassfest/manifest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 6d2f4087f59af..4013c8a6c195a 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -268,7 +268,6 @@ def verify_wildcard(value: str) -> str: ) ], vol.Required("documentation"): vol.All(vol.Url(), documentation_url), - vol.Optional("issue_tracker"): vol.Url(), vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES), vol.Optional("requirements"): [str], vol.Optional("dependencies"): [str], @@ -304,6 +303,7 @@ def manifest_schema(value: dict[str, Any]) -> vol.Schema: CUSTOM_INTEGRATION_MANIFEST_SCHEMA = INTEGRATION_MANIFEST_SCHEMA.extend( { vol.Optional("version"): vol.All(str, verify_version), + vol.Optional("issue_tracker"): vol.Url(), vol.Optional("import_executor"): bool, } ) From b78453b85b524ff422774fff2b549ac7cde23f55 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Nov 2024 12:21:15 +0100 Subject: [PATCH 0413/1070] Bump aiowithings to 3.1.3 (#130504) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index c24bdb743bfef..f9e8328ae53c9 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==3.1.2"] + "requirements": ["aiowithings==3.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9a27f4d3b040b..334d36f084088 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -417,7 +417,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.2 +aiowithings==3.1.3 # homeassistant.components.yandex_transport aioymaps==1.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38704005179dd..c8d4fb1588343 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -399,7 +399,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.2 +aiowithings==3.1.3 # homeassistant.components.yandex_transport aioymaps==1.2.5 From ab11b8467808831a53318b8eb42cd2c1f7e3eb00 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 13 Nov 2024 13:01:54 +0100 Subject: [PATCH 0414/1070] Improve type hints in fritzbox config flow (#130509) --- homeassistant/components/fritzbox/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index 76754fc5082ec..ffec4a9ea29de 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -43,10 +43,11 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _name: str + def __init__(self) -> None: """Initialize flow.""" self._host: str | None = None - self._name: str | None = None self._password: str | None = None self._username: str | None = None @@ -158,7 +159,6 @@ async def async_step_confirm( result = await self.async_try_connect() if result == RESULT_SUCCESS: - assert self._name is not None return self._get_entry(self._name) if result != RESULT_INVALID_AUTH: return self.async_abort(reason=result) From 8300afc00d434dc53e172e7b3f2270915593b3fd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 13 Nov 2024 13:45:52 +0100 Subject: [PATCH 0415/1070] Improve type hints in fritz config flow (#130511) * Improve type hints in fritz config flow * Improve coverage * Apply suggestions from code review Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --------- Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --- homeassistant/components/fritz/config_flow.py | 14 ++++++----- tests/components/fritz/test_config_flow.py | 24 +++++++++++++++++-- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index ec9ffdd755481..920ecda1c52d3 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -57,6 +57,8 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + _host: str + @staticmethod @callback def async_get_options_flow( @@ -67,7 +69,6 @@ def async_get_options_flow( def __init__(self) -> None: """Initialize FRITZ!Box Tools flow.""" - self._host: str | None = None self._name: str = "" self._password: str = "" self._use_tls: bool = False @@ -112,7 +113,6 @@ def fritz_tools_init(self) -> str | None: async def async_check_configured_entry(self) -> ConfigEntry | None: """Check if entry is configured.""" - assert self._host current_host = await self.hass.async_add_executor_job( socket.gethostbyname, self._host ) @@ -154,15 +154,17 @@ async def async_step_ssdp( ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "") - self._host = ssdp_location.hostname + host = ssdp_location.hostname + if not host or ipaddress.ip_address(host).is_link_local: + return self.async_abort(reason="ignore_ip6_link_local") + + self._host = host self._name = ( discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] ) - if not self._host or ipaddress.ip_address(self._host).is_link_local: - return self.async_abort(reason="ignore_ip6_link_local") - + uuid: str | None if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): if uuid.startswith("uuid:"): uuid = uuid[5:] diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index e3fae8c083e91..84f1b240b88f4 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -10,6 +10,7 @@ ) import pytest +from homeassistant.components import ssdp from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -22,7 +23,6 @@ ERROR_UNKNOWN, FRITZ_AUTH_EXCEPTIONS, ) -from homeassistant.components.ssdp import ATTR_UPNP_UDN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import ( CONF_HOST, @@ -644,7 +644,7 @@ async def test_ssdp_already_in_progress_host( MOCK_NO_UNIQUE_ID = dataclasses.replace(MOCK_SSDP_DATA) MOCK_NO_UNIQUE_ID.upnp = MOCK_NO_UNIQUE_ID.upnp.copy() - del MOCK_NO_UNIQUE_ID.upnp[ATTR_UPNP_UDN] + del MOCK_NO_UNIQUE_ID.upnp[ssdp.ATTR_UPNP_UDN] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_UNIQUE_ID ) @@ -737,3 +737,23 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_OLD_DISCOVERY: False, CONF_CONSIDER_HOME: 37, } + + +async def test_ssdp_ipv6_link_local(hass: HomeAssistant) -> None: + """Test ignoring ipv6-link-local while ssdp discovery.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="https://[fe80::1ff:fe23:4567:890a]:12345/test", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "fake_name", + ssdp.ATTR_UPNP_UDN: "uuid:only-a-test", + }, + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "ignore_ip6_link_local" From f6bc5f050ec92cac140013b76e025d8ff94f24ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 13 Nov 2024 14:28:19 +0100 Subject: [PATCH 0416/1070] Bump millheater to 0.12.2 (#130454) --- homeassistant/components/mill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 16e7bf552baa4..6316eb7209673 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.11.8", "mill-local==0.3.0"] + "requirements": ["millheater==0.12.2", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 334d36f084088..e562f218f83e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1373,7 +1373,7 @@ microBeesPy==0.3.2 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.8 +millheater==0.12.2 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8d4fb1588343..d74f9f8ba9579 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1142,7 +1142,7 @@ microBeesPy==0.3.2 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.8 +millheater==0.12.2 # homeassistant.components.minio minio==7.1.12 From 72b976f8322ad867aafe15eaa103f58f71d06a56 Mon Sep 17 00:00:00 2001 From: dunnmj Date: Wed, 13 Nov 2024 13:29:04 +0000 Subject: [PATCH 0417/1070] Add Sky remote integration (#124507) Co-authored-by: Kyle Cooke Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/brands/sky.json | 5 + .../components/sky_remote/__init__.py | 39 ++++++ .../components/sky_remote/config_flow.py | 64 +++++++++ homeassistant/components/sky_remote/const.py | 6 + .../components/sky_remote/manifest.json | 10 ++ homeassistant/components/sky_remote/remote.py | 70 ++++++++++ .../components/sky_remote/strings.json | 21 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 21 ++- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/sky_remote/__init__.py | 13 ++ tests/components/sky_remote/conftest.py | 47 +++++++ .../components/sky_remote/test_config_flow.py | 125 ++++++++++++++++++ tests/components/sky_remote/test_init.py | 59 +++++++++ tests/components/sky_remote/test_remote.py | 46 +++++++ 17 files changed, 530 insertions(+), 5 deletions(-) create mode 100644 homeassistant/brands/sky.json create mode 100644 homeassistant/components/sky_remote/__init__.py create mode 100644 homeassistant/components/sky_remote/config_flow.py create mode 100644 homeassistant/components/sky_remote/const.py create mode 100644 homeassistant/components/sky_remote/manifest.json create mode 100644 homeassistant/components/sky_remote/remote.py create mode 100644 homeassistant/components/sky_remote/strings.json create mode 100644 tests/components/sky_remote/__init__.py create mode 100644 tests/components/sky_remote/conftest.py create mode 100644 tests/components/sky_remote/test_config_flow.py create mode 100644 tests/components/sky_remote/test_init.py create mode 100644 tests/components/sky_remote/test_remote.py diff --git a/CODEOWNERS b/CODEOWNERS index 022eda001233e..76422734c921a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1344,6 +1344,8 @@ build.json @home-assistant/supervisor /tests/components/siren/ @home-assistant/core @raman325 /homeassistant/components/sisyphus/ @jkeljo /homeassistant/components/sky_hub/ @rogerselwyn +/homeassistant/components/sky_remote/ @dunnmj @saty9 +/tests/components/sky_remote/ @dunnmj @saty9 /homeassistant/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob /homeassistant/components/slack/ @tkdrob @fletcherau diff --git a/homeassistant/brands/sky.json b/homeassistant/brands/sky.json new file mode 100644 index 0000000000000..3ab0cbbe5bd2d --- /dev/null +++ b/homeassistant/brands/sky.json @@ -0,0 +1,5 @@ +{ + "domain": "sky", + "name": "Sky", + "integrations": ["sky_hub", "sky_remote"] +} diff --git a/homeassistant/components/sky_remote/__init__.py b/homeassistant/components/sky_remote/__init__.py new file mode 100644 index 0000000000000..4daad78c558ee --- /dev/null +++ b/homeassistant/components/sky_remote/__init__.py @@ -0,0 +1,39 @@ +"""The Sky Remote Control integration.""" + +import logging + +from skyboxremote import RemoteControl, SkyBoxConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +PLATFORMS = [Platform.REMOTE] + +_LOGGER = logging.getLogger(__name__) + + +type SkyRemoteConfigEntry = ConfigEntry[RemoteControl] + + +async def async_setup_entry(hass: HomeAssistant, entry: SkyRemoteConfigEntry) -> bool: + """Set up Sky remote.""" + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + + _LOGGER.debug("Setting up Host: %s, Port: %s", host, port) + remote = RemoteControl(host, port) + try: + await remote.check_connectable() + except SkyBoxConnectionError as e: + raise ConfigEntryNotReady from e + + entry.runtime_data = remote + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sky_remote/config_flow.py b/homeassistant/components/sky_remote/config_flow.py new file mode 100644 index 0000000000000..a55dfb2a52bf7 --- /dev/null +++ b/homeassistant/components/sky_remote/config_flow.py @@ -0,0 +1,64 @@ +"""Config flow for sky_remote.""" + +import logging +from typing import Any + +from skyboxremote import RemoteControl, SkyBoxConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT +import homeassistant.helpers.config_validation as cv + +from .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + } +) + + +async def async_find_box_port(host: str) -> int: + """Find port box uses for communication.""" + logging.debug("Attempting to find port to connect to %s on", host) + remote = RemoteControl(host, DEFAULT_PORT) + try: + await remote.check_connectable() + except SkyBoxConnectionError: + # Try legacy port if the default one failed + remote = RemoteControl(host, LEGACY_PORT) + await remote.check_connectable() + return LEGACY_PORT + return DEFAULT_PORT + + +class SkyRemoteConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sky Remote.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + + errors: dict[str, str] = {} + if user_input is not None: + logging.debug("user_input: %s", user_input) + self._async_abort_entries_match(user_input) + try: + port = await async_find_box_port(user_input[CONF_HOST]) + except SkyBoxConnectionError: + logging.exception("while finding port of skybox") + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=user_input[CONF_HOST], + data={**user_input, CONF_PORT: port}, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/sky_remote/const.py b/homeassistant/components/sky_remote/const.py new file mode 100644 index 0000000000000..e67744a741bb0 --- /dev/null +++ b/homeassistant/components/sky_remote/const.py @@ -0,0 +1,6 @@ +"""Constants.""" + +DOMAIN = "sky_remote" + +DEFAULT_PORT = 49160 +LEGACY_PORT = 5900 diff --git a/homeassistant/components/sky_remote/manifest.json b/homeassistant/components/sky_remote/manifest.json new file mode 100644 index 0000000000000..b00ff309b1081 --- /dev/null +++ b/homeassistant/components/sky_remote/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sky_remote", + "name": "Sky Remote Control", + "codeowners": ["@dunnmj", "@saty9"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sky_remote", + "integration_type": "device", + "iot_class": "assumed_state", + "requirements": ["skyboxremote==0.0.6"] +} diff --git a/homeassistant/components/sky_remote/remote.py b/homeassistant/components/sky_remote/remote.py new file mode 100644 index 0000000000000..05a464f73a62d --- /dev/null +++ b/homeassistant/components/sky_remote/remote.py @@ -0,0 +1,70 @@ +"""Home Assistant integration to control a sky box using the remote platform.""" + +from collections.abc import Iterable +import logging +from typing import Any + +from skyboxremote import VALID_KEYS, RemoteControl + +from homeassistant.components.remote import RemoteEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SkyRemoteConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config: SkyRemoteConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Sky remote platform.""" + async_add_entities( + [SkyRemote(config.runtime_data, config.entry_id)], + True, + ) + + +class SkyRemote(RemoteEntity): + """Representation of a Sky Remote.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, remote: RemoteControl, unique_id: str) -> None: + """Initialize the Sky Remote.""" + self._remote = remote + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="SKY", + model="Sky Box", + name=remote.host, + ) + + def turn_on(self, activity: str | None = None, **kwargs: Any) -> None: + """Send the power on command.""" + self.send_command(["sky"]) + + def turn_off(self, activity: str | None = None, **kwargs: Any) -> None: + """Send the power command.""" + self.send_command(["power"]) + + def send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a list of commands to the device.""" + for cmd in command: + if cmd not in VALID_KEYS: + raise ServiceValidationError( + f"{cmd} is not in Valid Keys: {VALID_KEYS}" + ) + try: + self._remote.send_keys(command) + except ValueError as err: + _LOGGER.error("Invalid command: %s. Error: %s", command, err) + return + _LOGGER.debug("Successfully sent command %s", command) diff --git a/homeassistant/components/sky_remote/strings.json b/homeassistant/components/sky_remote/strings.json new file mode 100644 index 0000000000000..af794490c434e --- /dev/null +++ b/homeassistant/components/sky_remote/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "user": { + "title": "Add Sky Remote", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Sky device" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cbd30b560ce75..78e1612654299 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -537,6 +537,7 @@ "simplefin", "simplepush", "simplisafe", + "sky_remote", "skybell", "slack", "sleepiq", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a1fdb9478f3ab..33a7d02776f54 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5608,11 +5608,22 @@ "config_flow": false, "iot_class": "local_push" }, - "sky_hub": { - "name": "Sky Hub", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" + "sky": { + "name": "Sky", + "integrations": { + "sky_hub": { + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_polling", + "name": "Sky Hub" + }, + "sky_remote": { + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state", + "name": "Sky Remote Control" + } + } }, "skybeacon": { "name": "Skybeacon", diff --git a/requirements_all.txt b/requirements_all.txt index e562f218f83e5..97416c7ea3983 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2673,6 +2673,9 @@ simplisafe-python==2024.01.0 # homeassistant.components.sisyphus sisyphus-control==3.1.4 +# homeassistant.components.sky_remote +skyboxremote==0.0.6 + # homeassistant.components.slack slackclient==2.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d74f9f8ba9579..3ffc154772212 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2131,6 +2131,9 @@ simplepush==2.2.3 # homeassistant.components.simplisafe simplisafe-python==2024.01.0 +# homeassistant.components.sky_remote +skyboxremote==0.0.6 + # homeassistant.components.slack slackclient==2.5.0 diff --git a/tests/components/sky_remote/__init__.py b/tests/components/sky_remote/__init__.py new file mode 100644 index 0000000000000..83d68330d5b12 --- /dev/null +++ b/tests/components/sky_remote/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Sky Remote component.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_mock_entry(hass: HomeAssistant, entry: MockConfigEntry): + """Initialize a mock config entry.""" + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() diff --git a/tests/components/sky_remote/conftest.py b/tests/components/sky_remote/conftest.py new file mode 100644 index 0000000000000..d6c453d81f747 --- /dev/null +++ b/tests/components/sky_remote/conftest.py @@ -0,0 +1,47 @@ +"""Test mocks and fixtures.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.sky_remote.const import DEFAULT_PORT, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + +SAMPLE_CONFIG = {CONF_HOST: "example.com", CONF_PORT: DEFAULT_PORT} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry(domain=DOMAIN, data=SAMPLE_CONFIG) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Stub out setup function.""" + with patch( + "homeassistant.components.sky_remote.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_remote_control(request: pytest.FixtureRequest) -> Generator[MagicMock]: + """Mock skyboxremote library.""" + with ( + patch( + "homeassistant.components.sky_remote.RemoteControl" + ) as mock_remote_control, + patch( + "homeassistant.components.sky_remote.config_flow.RemoteControl", + mock_remote_control, + ), + ): + mock_remote_control._instance_mock = MagicMock(host="example.com") + mock_remote_control._instance_mock.check_connectable = AsyncMock(True) + mock_remote_control.return_value = mock_remote_control._instance_mock + yield mock_remote_control diff --git a/tests/components/sky_remote/test_config_flow.py b/tests/components/sky_remote/test_config_flow.py new file mode 100644 index 0000000000000..aaeda20788cd9 --- /dev/null +++ b/tests/components/sky_remote/test_config_flow.py @@ -0,0 +1,125 @@ +"""Test the Sky Remote config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from skyboxremote import LEGACY_PORT, SkyBoxConnectionError + +from homeassistant.components.sky_remote.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import SAMPLE_CONFIG + + +async def test_user_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_remote_control +) -> None: + """Test we can setup an entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: SAMPLE_CONFIG[CONF_HOST]}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == SAMPLE_CONFIG + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_device_exists_abort( + hass: HomeAssistant, mock_config_entry, mock_remote_control +) -> None: + """Test we abort flow if device already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: mock_config_entry.data[CONF_HOST]}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("mock_remote_control", [LEGACY_PORT], indirect=True) +async def test_user_flow_legacy_device( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_remote_control, +) -> None: + """Test we can setup an entry with a legacy port.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + async def mock_check_connectable(): + if mock_remote_control.call_args[0][1] == LEGACY_PORT: + return True + raise SkyBoxConnectionError("Wrong port") + + mock_remote_control._instance_mock.check_connectable = mock_check_connectable + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: SAMPLE_CONFIG[CONF_HOST]}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {**SAMPLE_CONFIG, CONF_PORT: LEGACY_PORT} + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("mock_remote_control", [6], indirect=True) +async def test_user_flow_unconnectable( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_remote_control, +) -> None: + """Test we can setup an entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + mock_remote_control._instance_mock.check_connectable = AsyncMock( + side_effect=SkyBoxConnectionError("Example") + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: SAMPLE_CONFIG[CONF_HOST]}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + assert len(mock_setup_entry.mock_calls) == 0 + + mock_remote_control._instance_mock.check_connectable = AsyncMock(True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: SAMPLE_CONFIG[CONF_HOST]}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == SAMPLE_CONFIG + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/sky_remote/test_init.py b/tests/components/sky_remote/test_init.py new file mode 100644 index 0000000000000..fe316baa6bf63 --- /dev/null +++ b/tests/components/sky_remote/test_init.py @@ -0,0 +1,59 @@ +"""Tests for the Sky Remote component.""" + +from unittest.mock import AsyncMock + +from skyboxremote import SkyBoxConnectionError + +from homeassistant.components.sky_remote.const import DEFAULT_PORT, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_mock_entry + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_remote_control, + device_registry: dr.DeviceRegistry, +) -> None: + """Test successful setup of entry.""" + await setup_mock_entry(hass, mock_config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + mock_remote_control.assert_called_once_with("example.com", DEFAULT_PORT) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device_entry is not None + assert device_entry.name == "example.com" + + +async def test_setup_unconnectable_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_remote_control, +) -> None: + """Test unsuccessful setup of entry.""" + mock_remote_control._instance_mock.check_connectable = AsyncMock( + side_effect=SkyBoxConnectionError() + ) + + await setup_mock_entry(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_remote_control +) -> None: + """Test unload an entry.""" + await setup_mock_entry(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/sky_remote/test_remote.py b/tests/components/sky_remote/test_remote.py new file mode 100644 index 0000000000000..301375bc0392e --- /dev/null +++ b/tests/components/sky_remote/test_remote.py @@ -0,0 +1,46 @@ +"""Test sky_remote remote.""" + +import pytest + +from homeassistant.components.remote import ( + ATTR_COMMAND, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import setup_mock_entry + +ENTITY_ID = "remote.example_com" + + +async def test_send_command( + hass: HomeAssistant, mock_config_entry, mock_remote_control +) -> None: + """Test "send_command" method.""" + await setup_mock_entry(hass, mock_config_entry) + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["sky"]}, + blocking=True, + ) + mock_remote_control._instance_mock.send_keys.assert_called_once_with(["sky"]) + + +async def test_send_invalid_command( + hass: HomeAssistant, mock_config_entry, mock_remote_control +) -> None: + """Test "send_command" method.""" + await setup_mock_entry(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["apple"]}, + blocking=True, + ) + mock_remote_control._instance_mock.send_keys.assert_not_called() From ac4cb52dbbda03307a938a2c561a2afcbb2365a8 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 13 Nov 2024 14:04:23 +0000 Subject: [PATCH 0418/1070] Bump ring-doorbell to 0.9.12 (#130419) --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 63c47cb2979b6..e431c68008132 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], "quality_scale": "silver", - "requirements": ["ring-doorbell==0.9.9"] + "requirements": ["ring-doorbell==0.9.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 97416c7ea3983..3de766e93c7d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2562,7 +2562,7 @@ rfk101py==0.0.1 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell==0.9.9 +ring-doorbell==0.9.12 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ffc154772212..b492a6f7020f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2050,7 +2050,7 @@ reolink-aio==0.11.0 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell==0.9.9 +ring-doorbell==0.9.12 # homeassistant.components.roku rokuecp==0.19.3 From 093b16c7235a0ee69d88ff102e2838a747a96692 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Nov 2024 16:16:49 +0100 Subject: [PATCH 0419/1070] Make WS command backup/generate send events (#130524) * Make WS command backup/generate send events * Update backup.create service --- homeassistant/components/backup/__init__.py | 4 +- homeassistant/components/backup/manager.py | 62 +++++++++-- homeassistant/components/backup/websocket.py | 11 +- tests/components/backup/conftest.py | 73 +++++++++++++ .../backup/snapshots/test_websocket.ambr | 17 ++- tests/components/backup/test_manager.py | 101 ++++++++---------- tests/components/backup/test_websocket.py | 18 ++-- 7 files changed, 200 insertions(+), 86 deletions(-) create mode 100644 tests/components/backup/conftest.py diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 200cb4a3f6559..907fda4c7f8dd 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -32,7 +32,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_handle_create_service(call: ServiceCall) -> None: """Service handler for creating backups.""" - await backup_manager.async_create_backup() + await backup_manager.async_create_backup(on_progress=None) + if backup_task := backup_manager.backup_task: + await backup_task hass.services.async_register(DOMAIN, "create", async_handle_create_service) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 4300f75eed0be..ddc0a1eac3fb6 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -4,6 +4,7 @@ import abc import asyncio +from collections.abc import Callable from dataclasses import asdict, dataclass import hashlib import io @@ -34,6 +35,13 @@ BUF_SIZE = 2**20 * 4 # 4MB +@dataclass(slots=True) +class NewBackup: + """New backup class.""" + + slug: str + + @dataclass(slots=True) class Backup: """Backup class.""" @@ -49,6 +57,15 @@ def as_dict(self) -> dict: return {**asdict(self), "path": self.path.as_posix()} +@dataclass(slots=True) +class BackupProgress: + """Backup progress class.""" + + done: bool + stage: str | None + success: bool | None + + class BackupPlatformProtocol(Protocol): """Define the format that backup platforms can have.""" @@ -65,7 +82,7 @@ class BaseBackupManager(abc.ABC): def __init__(self, hass: HomeAssistant) -> None: """Initialize the backup manager.""" self.hass = hass - self.backing_up = False + self.backup_task: asyncio.Task | None = None self.backups: dict[str, Backup] = {} self.loaded_platforms = False self.platforms: dict[str, BackupPlatformProtocol] = {} @@ -133,7 +150,12 @@ async def async_restore_backup(self, slug: str, **kwargs: Any) -> None: """Restore a backup.""" @abc.abstractmethod - async def async_create_backup(self, **kwargs: Any) -> Backup: + async def async_create_backup( + self, + *, + on_progress: Callable[[BackupProgress], None] | None, + **kwargs: Any, + ) -> NewBackup: """Generate a backup.""" @abc.abstractmethod @@ -292,17 +314,36 @@ def _move_and_cleanup() -> None: await self.hass.async_add_executor_job(_move_and_cleanup) await self.load_backups() - async def async_create_backup(self, **kwargs: Any) -> Backup: + async def async_create_backup( + self, + *, + on_progress: Callable[[BackupProgress], None] | None, + **kwargs: Any, + ) -> NewBackup: """Generate a backup.""" - if self.backing_up: + if self.backup_task: raise HomeAssistantError("Backup already in progress") + backup_name = f"Core {HAVERSION}" + date_str = dt_util.now().isoformat() + slug = _generate_slug(date_str, backup_name) + self.backup_task = self.hass.async_create_task( + self._async_create_backup(backup_name, date_str, slug, on_progress), + name="backup_manager_create_backup", + eager_start=False, # To ensure the task is not started before we return + ) + return NewBackup(slug=slug) + async def _async_create_backup( + self, + backup_name: str, + date_str: str, + slug: str, + on_progress: Callable[[BackupProgress], None] | None, + ) -> Backup: + """Generate a backup.""" + success = False try: - self.backing_up = True await self.async_pre_backup_actions() - backup_name = f"Core {HAVERSION}" - date_str = dt_util.now().isoformat() - slug = _generate_slug(date_str, backup_name) backup_data = { "slug": slug, @@ -329,9 +370,12 @@ async def async_create_backup(self, **kwargs: Any) -> Backup: if self.loaded_backups: self.backups[slug] = backup LOGGER.debug("Generated new backup with slug %s", slug) + success = True return backup finally: - self.backing_up = False + if on_progress: + on_progress(BackupProgress(done=True, stage=None, success=success)) + self.backup_task = None await self.async_post_backup_actions() def _mkdir_and_generate_backup_contents( diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 3ac8a7ace3e67..a7c61b7c66c54 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant, callback from .const import DATA_MANAGER, LOGGER +from .manager import BackupProgress @callback @@ -40,7 +41,7 @@ async def handle_info( msg["id"], { "backups": list(backups.values()), - "backing_up": manager.backing_up, + "backing_up": manager.backup_task is not None, }, ) @@ -113,7 +114,11 @@ async def handle_create( msg: dict[str, Any], ) -> None: """Generate a backup.""" - backup = await hass.data[DATA_MANAGER].async_create_backup() + + def on_progress(progress: BackupProgress) -> None: + connection.send_message(websocket_api.event_message(msg["id"], progress)) + + backup = await hass.data[DATA_MANAGER].async_create_backup(on_progress=on_progress) connection.send_result(msg["id"], backup) @@ -127,7 +132,6 @@ async def handle_backup_start( ) -> None: """Backup start notification.""" manager = hass.data[DATA_MANAGER] - manager.backing_up = True LOGGER.debug("Backup start notification") try: @@ -149,7 +153,6 @@ async def handle_backup_end( ) -> None: """Backup end notification.""" manager = hass.data[DATA_MANAGER] - manager.backing_up = False LOGGER.debug("Backup end notification") try: diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py new file mode 100644 index 0000000000000..631c774e63cf6 --- /dev/null +++ b/tests/components/backup/conftest.py @@ -0,0 +1,73 @@ +"""Test fixtures for the Backup integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from homeassistant.core import HomeAssistant + + +@pytest.fixture(name="mocked_json_bytes") +def mocked_json_bytes_fixture() -> Generator[Mock]: + """Mock json_bytes.""" + with patch( + "homeassistant.components.backup.manager.json_bytes", + return_value=b"{}", # Empty JSON + ) as mocked_json_bytes: + yield mocked_json_bytes + + +@pytest.fixture(name="mocked_tarfile") +def mocked_tarfile_fixture() -> Generator[Mock]: + """Mock tarfile.""" + with patch( + "homeassistant.components.backup.manager.SecureTarFile" + ) as mocked_tarfile: + yield mocked_tarfile + + +@pytest.fixture(name="mock_backup_generation") +def mock_backup_generation_fixture( + hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock +) -> Generator[None]: + """Mock backup generator.""" + + def _mock_iterdir(path: Path) -> list[Path]: + if not path.name.endswith("testing_config"): + return [] + return [ + Path("test.txt"), + Path(".DS_Store"), + Path(".storage"), + ] + + with ( + patch("pathlib.Path.iterdir", _mock_iterdir), + patch("pathlib.Path.stat", MagicMock(st_size=123)), + patch("pathlib.Path.is_file", lambda x: x.name != ".storage"), + patch( + "pathlib.Path.is_dir", + lambda x: x.name == ".storage", + ), + patch( + "pathlib.Path.exists", + lambda x: x != Path(hass.config.path("backups")), + ), + patch( + "pathlib.Path.is_symlink", + lambda _: False, + ), + patch( + "pathlib.Path.mkdir", + MagicMock(), + ), + patch( + "homeassistant.components.backup.manager.HAVERSION", + "2025.1.0", + ), + ): + yield diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 096df37d70477..42eb524e529dd 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -210,16 +210,23 @@ dict({ 'id': 1, 'result': dict({ - 'date': '1970-01-01T00:00:00.000Z', - 'name': 'Test', - 'path': 'abc123.tar', - 'size': 0.0, - 'slug': 'abc123', + 'slug': '27f5c632', }), 'success': True, 'type': 'result', }) # --- +# name: test_generate[without_hassio].1 + dict({ + 'event': dict({ + 'done': True, + 'stage': None, + 'success': True, + }), + 'id': 1, + 'type': 'event', + }) +# --- # name: test_info[with_hassio] dict({ 'error': dict({ diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index a3f70267643b0..9d24964aedf7b 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pathlib import Path +import asyncio from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch import aiohttp @@ -10,7 +10,10 @@ import pytest from homeassistant.components.backup import BackupManager -from homeassistant.components.backup.manager import BackupPlatformProtocol +from homeassistant.components.backup.manager import ( + BackupPlatformProtocol, + BackupProgress, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -20,59 +23,30 @@ from tests.common import MockPlatform, mock_platform -async def _mock_backup_generation(manager: BackupManager): +async def _mock_backup_generation( + manager: BackupManager, mocked_json_bytes: Mock, mocked_tarfile: Mock +) -> None: """Mock backup generator.""" - def _mock_iterdir(path: Path) -> list[Path]: - if not path.name.endswith("testing_config"): - return [] - return [ - Path("test.txt"), - Path(".DS_Store"), - Path(".storage"), - ] + progress: list[BackupProgress] = [] - with ( - patch( - "homeassistant.components.backup.manager.SecureTarFile" - ) as mocked_tarfile, - patch("pathlib.Path.iterdir", _mock_iterdir), - patch("pathlib.Path.stat", MagicMock(st_size=123)), - patch("pathlib.Path.is_file", lambda x: x.name != ".storage"), - patch( - "pathlib.Path.is_dir", - lambda x: x.name == ".storage", - ), - patch( - "pathlib.Path.exists", - lambda x: x != manager.backup_dir, - ), - patch( - "pathlib.Path.is_symlink", - lambda _: False, - ), - patch( - "pathlib.Path.mkdir", - MagicMock(), - ), - patch( - "homeassistant.components.backup.manager.json_bytes", - return_value=b"{}", # Empty JSON - ) as mocked_json_bytes, - patch( - "homeassistant.components.backup.manager.HAVERSION", - "2025.1.0", - ), - ): - await manager.async_create_backup() - - assert mocked_json_bytes.call_count == 1 - backup_json_dict = mocked_json_bytes.call_args[0][0] - assert isinstance(backup_json_dict, dict) - assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"} - assert manager.backup_dir.as_posix() in str( - mocked_tarfile.call_args_list[0][0][0] - ) + def on_progress(_progress: BackupProgress) -> None: + """Mock progress callback.""" + progress.append(_progress) + + assert manager.backup_task is None + await manager.async_create_backup(on_progress=on_progress) + assert manager.backup_task is not None + assert progress == [] + + await manager.backup_task + assert progress == [BackupProgress(done=True, stage=None, success=True)] + + assert mocked_json_bytes.call_count == 1 + backup_json_dict = mocked_json_bytes.call_args[0][0] + assert isinstance(backup_json_dict, dict) + assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"} + assert manager.backup_dir.as_posix() in str(mocked_tarfile.call_args_list[0][0][0]) async def _setup_mock_domain( @@ -176,21 +150,26 @@ async def test_getting_backup_that_does_not_exist( async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None: """Test generate backup.""" + event = asyncio.Event() manager = BackupManager(hass) - manager.backing_up = True + manager.backup_task = hass.async_create_task(event.wait()) with pytest.raises(HomeAssistantError, match="Backup already in progress"): - await manager.async_create_backup() + await manager.async_create_backup(on_progress=None) + event.set() +@pytest.mark.usefixtures("mock_backup_generation") async def test_async_create_backup( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, ) -> None: """Test generate backup.""" manager = BackupManager(hass) manager.loaded_backups = True - await _mock_backup_generation(manager) + await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) assert "Generated new backup with slug " in caplog.text assert "Creating backup directory" in caplog.text @@ -247,7 +226,9 @@ async def test_not_loading_bad_platforms( ) -async def test_exception_plaform_pre(hass: HomeAssistant) -> None: +async def test_exception_plaform_pre( + hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock +) -> None: """Test exception in pre step.""" manager = BackupManager(hass) manager.loaded_backups = True @@ -264,10 +245,12 @@ async def _mock_step(hass: HomeAssistant) -> None: ) with pytest.raises(HomeAssistantError): - await _mock_backup_generation(manager) + await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) -async def test_exception_plaform_post(hass: HomeAssistant) -> None: +async def test_exception_plaform_post( + hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock +) -> None: """Test exception in post step.""" manager = BackupManager(hass) manager.loaded_backups = True @@ -284,7 +267,7 @@ async def _mock_step(hass: HomeAssistant) -> None: ) with pytest.raises(HomeAssistantError): - await _mock_backup_generation(manager) + await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) async def test_loading_platforms_when_running_async_pre_backup_actions( diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 125ba8adaad1c..3e031f172aee9 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -115,29 +116,30 @@ async def test_remove( @pytest.mark.parametrize( - "with_hassio", + ("with_hassio", "number_of_messages"), [ - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), + pytest.param(True, 1, id="with_hassio"), + pytest.param(False, 2, id="without_hassio"), ], ) +@pytest.mark.usefixtures("mock_backup_generation") async def test_generate( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, with_hassio: bool, + number_of_messages: int, ) -> None: """Test generating a backup.""" await setup_backup_integration(hass, with_hassio=with_hassio) client = await hass_ws_client(hass) + freezer.move_to("2024-11-13 12:01:00+01:00") await hass.async_block_till_done() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_create_backup", - return_value=TEST_BACKUP, - ): - await client.send_json_auto_id({"type": "backup/generate"}) + await client.send_json_auto_id({"type": "backup/generate"}) + for _ in range(number_of_messages): assert snapshot == await client.receive_json() From 5f68d405b2fa0f08959dcb38a33444c6c330ee94 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Nov 2024 17:26:27 +0100 Subject: [PATCH 0420/1070] Update huum to 0.7.12 (#130527) --- homeassistant/components/huum/__init__.py | 15 ++++----------- homeassistant/components/huum/climate.py | 12 +++++------- homeassistant/components/huum/config_flow.py | 7 ++----- homeassistant/components/huum/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/huum/conftest.py | 6 ------ 7 files changed, 14 insertions(+), 32 deletions(-) delete mode 100644 tests/components/huum/conftest.py diff --git a/homeassistant/components/huum/__init__.py b/homeassistant/components/huum/__init__.py index c533ca34ef3a5..75faf1923df6c 100644 --- a/homeassistant/components/huum/__init__.py +++ b/homeassistant/components/huum/__init__.py @@ -3,30 +3,23 @@ from __future__ import annotations import logging -import sys + +from huum.exceptions import Forbidden, NotAuthenticated +from huum.huum import Huum from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS -if sys.version_info < (3, 13): - from huum.exceptions import Forbidden, NotAuthenticated - from huum.huum import Huum - _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Huum from a config entry.""" - if sys.version_info >= (3, 13): - raise HomeAssistantError( - "Huum is not supported on Python 3.13. Please use Python 3.12." - ) - username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index b659e33038a57..df740aea3d120 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -3,9 +3,13 @@ from __future__ import annotations import logging -import sys from typing import Any +from huum.const import SaunaStatus +from huum.exceptions import SafetyException +from huum.huum import Huum +from huum.schemas import HuumStatusResponse + from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -20,12 +24,6 @@ from .const import DOMAIN -if sys.version_info < (3, 13): - from huum.const import SaunaStatus - from huum.exceptions import SafetyException - from huum.huum import Huum - from huum.schemas import HuumStatusResponse - _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py index 10c3137818464..6a5fd96b99d95 100644 --- a/homeassistant/components/huum/config_flow.py +++ b/homeassistant/components/huum/config_flow.py @@ -3,9 +3,10 @@ from __future__ import annotations import logging -import sys from typing import Any +from huum.exceptions import Forbidden, NotAuthenticated +from huum.huum import Huum import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -14,10 +15,6 @@ from .const import DOMAIN -if sys.version_info < (3, 13): - from huum.exceptions import Forbidden, NotAuthenticated - from huum.huum import Huum - _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 025d1b97f216f..38562e1a07294 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", - "requirements": ["huum==0.7.11;python_version<'3.13'"] + "requirements": ["huum==0.7.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3de766e93c7d4..00984b9a5a6fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1148,7 +1148,7 @@ httplib2==0.20.4 huawei-lte-api==1.10.0 # homeassistant.components.huum -huum==0.7.11;python_version<'3.13' +huum==0.7.12 # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b492a6f7020f5..ffda690bc33a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -971,7 +971,7 @@ httplib2==0.20.4 huawei-lte-api==1.10.0 # homeassistant.components.huum -huum==0.7.11;python_version<'3.13' +huum==0.7.12 # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/tests/components/huum/conftest.py b/tests/components/huum/conftest.py deleted file mode 100644 index da66cc54b72ea..0000000000000 --- a/tests/components/huum/conftest.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Skip test collection for Python 3.13.""" - -import sys - -if sys.version_info >= (3, 13): - collect_ignore_glob = ["test_*.py"] From 7fd337d67f2ff1b1cfcbc61c36c1b7583a6cfcee Mon Sep 17 00:00:00 2001 From: Brig Lamoreaux Date: Wed, 13 Nov 2024 10:42:26 -0700 Subject: [PATCH 0421/1070] fix translation in srp_energy (#130540) --- homeassistant/components/srp_energy/strings.json | 3 ++- tests/components/srp_energy/test_config_flow.py | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index 191d10a70dd62..eca4f4654351e 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -17,7 +17,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "unknown": "Unexpected error" } }, "entity": { diff --git a/tests/components/srp_energy/test_config_flow.py b/tests/components/srp_energy/test_config_flow.py index 149e08014ac3b..e3abb3c98df2d 100644 --- a/tests/components/srp_energy/test_config_flow.py +++ b/tests/components/srp_energy/test_config_flow.py @@ -100,10 +100,6 @@ async def test_form_invalid_auth( assert result["errors"] == {"base": "invalid_auth"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.srp_energy.config.abort.unknown"], -) async def test_form_unknown_error( hass: HomeAssistant, mock_srp_energy_config_flow: MagicMock, From 0a5a2de78e0677c1e146909b482b4299d7c4b172 Mon Sep 17 00:00:00 2001 From: Sheldon Ip <4224778+sheldonip@users.noreply.github.com> Date: Wed, 13 Nov 2024 09:46:52 -0800 Subject: [PATCH 0422/1070] Fix translations in subaru (#130486) --- homeassistant/components/subaru/strings.json | 4 ++-- tests/components/subaru/test_config_flow.py | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 78625192e4a58..00da729dccdcf 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -37,13 +37,13 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "incorrect_pin": "Incorrect PIN", "bad_pin_format": "PIN should be 4 digits", - "two_factor_request_failed": "Request for 2FA code failed, please try again", "bad_validation_code_format": "Validation code should be 6 digits", "incorrect_validation_code": "Incorrect validation code" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "two_factor_request_failed": "Request for 2FA code failed, please try again" } }, "options": { diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index d930aafbdfb1e..6abc544c92a4e 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -192,10 +192,6 @@ async def test_two_factor_request_success( assert len(mock_two_factor_request.mock_calls) == 1 -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.subaru.config.abort.two_factor_request_failed"], -) async def test_two_factor_request_fail( hass: HomeAssistant, two_factor_start_form ) -> None: From ed5560aec235ee6e31d6bcf836d00243ff36c035 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Nov 2024 19:28:53 +0100 Subject: [PATCH 0423/1070] Update base image to Python 3.13 and deprecated 3.12 (#130425) --- .github/workflows/builder.yml | 2 +- Dockerfile.dev | 2 +- build.yaml | 10 +++++----- homeassistant/const.py | 4 ++-- pyproject.toml | 1 + 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 7c08df39000c8..cc100c48fd863 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -10,7 +10,7 @@ on: env: BUILD_TYPE: core - DEFAULT_PYTHON: "3.12" + DEFAULT_PYTHON: "3.13" PIP_TIMEOUT: 60 UV_HTTP_TIMEOUT: 60 UV_SYSTEM_PYTHON: "true" diff --git a/Dockerfile.dev b/Dockerfile.dev index 48f582a15810b..5a3f1a2ae6480 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/devcontainers/python:1-3.12 +FROM mcr.microsoft.com/devcontainers/python:1-3.13 SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/build.yaml b/build.yaml index 13618740ab808..a8755bbbf5cab 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.06.1 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.06.1 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.06.1 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.06.1 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.06.1 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.11.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.11.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.11.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.11.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.11.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/const.py b/homeassistant/const.py index 558e7ec2b0b84..4082a076b940f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -29,9 +29,9 @@ __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) -REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) +REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) # Truthy date string triggers showing related deprecation warning messages. -REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" +REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2025.2" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" diff --git a/pyproject.toml b/pyproject.toml index 8e588ce0b0e53..a9b958e08058d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Home Automation", ] requires-python = ">=3.12.0" From c35ef6bda34aa8c01cae6ea6863cae24a5009fc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Nov 2024 12:32:14 -0600 Subject: [PATCH 0424/1070] Bump aiohttp to 3.11.0 (#130542) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7a0e43b299e5e..abaf269103e52 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.11.0rc2 +aiohttp==3.11.0 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index a9b958e08058d..ebf22a93d7db0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.0rc2", + "aiohttp==3.11.0", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index ac7c00b805091..b97c8dc57a007 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.0rc2 +aiohttp==3.11.0 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From 4002bc3c257507b82d08abcc836de767ba57c5d3 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Wed, 13 Nov 2024 22:03:34 +0100 Subject: [PATCH 0425/1070] Downgrade devcontainer to Python 3.12 again (#130562) --- Dockerfile.dev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.dev b/Dockerfile.dev index 5a3f1a2ae6480..48f582a15810b 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/devcontainers/python:1-3.13 +FROM mcr.microsoft.com/devcontainers/python:1-3.12 SHELL ["/bin/bash", "-o", "pipefail", "-c"] From 51c6ee97b19706eb56bb440a3b5155e3b34f3afd Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 13 Nov 2024 15:50:08 -0600 Subject: [PATCH 0426/1070] Upgrade to hassil 2.0 (#130544) * Working on hassil 2.0 * Bump to hassil 2.0 * Update snapshots * Remove debug logging --- .../components/conversation/default_agent.py | 88 +++++-------------- homeassistant/components/conversation/http.py | 8 +- .../components/conversation/manifest.json | 2 +- .../components/conversation/trigger.py | 5 +- homeassistant/package_constraints.txt | 4 +- requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- script/hassfest/docker/Dockerfile | 2 +- .../snapshots/test_websocket.ambr | 4 +- .../conversation/snapshots/test_http.ambr | 4 +- .../conversation/test_default_agent.py | 28 +++--- tests/components/conversation/test_trace.py | 2 +- 12 files changed, 53 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index a7110c3579555..4838d19537a1b 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -16,11 +16,11 @@ from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList from hassil.recognize import ( MISSING_ENTITY, - MatchEntity, RecognizeResult, - UnmatchedTextEntity, recognize_all, + recognize_best, ) +from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity from hassil.util import merge_dict from home_assistant_intents import ErrorKey, get_intents, get_languages import yaml @@ -499,6 +499,7 @@ def _recognize( maybe_result: RecognizeResult | None = None best_num_matched_entities = 0 best_num_unmatched_entities = 0 + best_num_unmatched_ranges = 0 for result in recognize_all( user_input.text, lang_intents.intents, @@ -517,10 +518,14 @@ def _recognize( num_matched_entities += 1 num_unmatched_entities = 0 + num_unmatched_ranges = 0 for unmatched_entity in result.unmatched_entities_list: if isinstance(unmatched_entity, UnmatchedTextEntity): if unmatched_entity.text != MISSING_ENTITY: num_unmatched_entities += 1 + elif isinstance(unmatched_entity, UnmatchedRangeEntity): + num_unmatched_ranges += 1 + num_unmatched_entities += 1 else: num_unmatched_entities += 1 @@ -532,15 +537,24 @@ def _recognize( (num_matched_entities == best_num_matched_entities) and (num_unmatched_entities < best_num_unmatched_entities) ) + or ( + # Prefer unmatched ranges + (num_matched_entities == best_num_matched_entities) + and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges > best_num_unmatched_ranges) + ) or ( # More literal text matched (num_matched_entities == best_num_matched_entities) and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges == best_num_unmatched_ranges) and (result.text_chunks_matched > maybe_result.text_chunks_matched) ) or ( # Prefer match failures with entities (result.text_chunks_matched == maybe_result.text_chunks_matched) + and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges == best_num_unmatched_ranges) and ( ("name" in result.entities) or ("name" in result.unmatched_entities) @@ -550,6 +564,7 @@ def _recognize( maybe_result = result best_num_matched_entities = num_matched_entities best_num_unmatched_entities = num_unmatched_entities + best_num_unmatched_ranges = num_unmatched_ranges return maybe_result @@ -562,76 +577,15 @@ def _recognize_strict( language: str, ) -> RecognizeResult | None: """Search intents for a strict match to user input.""" - custom_found = False - name_found = False - best_results: list[RecognizeResult] = [] - best_name_quality: int | None = None - best_text_chunks_matched: int | None = None - for result in recognize_all( + return recognize_best( user_input.text, lang_intents.intents, slot_lists=slot_lists, intent_context=intent_context, language=language, - ): - # Prioritize user intents - is_custom = ( - result.intent_metadata is not None - and result.intent_metadata.get(METADATA_CUSTOM_SENTENCE) - ) - - if custom_found and not is_custom: - continue - - if not custom_found and is_custom: - custom_found = True - # Clear builtin results - name_found = False - best_results = [] - best_name_quality = None - best_text_chunks_matched = None - - # Prioritize results with a "name" slot - name = result.entities.get("name") - is_name = name and not name.is_wildcard - - if name_found and not is_name: - continue - - if not name_found and is_name: - name_found = True - # Clear non-name results - best_results = [] - best_text_chunks_matched = None - - if is_name: - # Prioritize results with a better "name" slot - name_quality = len(cast(MatchEntity, name).value.split()) - if (best_name_quality is None) or (name_quality > best_name_quality): - best_name_quality = name_quality - # Clear worse name results - best_results = [] - best_text_chunks_matched = None - elif name_quality < best_name_quality: - continue - - # Prioritize results with more literal text - # This causes wildcards to match last. - if (best_text_chunks_matched is None) or ( - result.text_chunks_matched > best_text_chunks_matched - ): - best_results = [result] - best_text_chunks_matched = result.text_chunks_matched - elif result.text_chunks_matched == best_text_chunks_matched: - # Accumulate results with the same number of literal text matched. - # We will resolve the ambiguity below. - best_results.append(result) - - if best_results: - # Successful strict match - return best_results[0] - - return None + best_metadata_key=METADATA_CUSTOM_SENTENCE, + best_slot_name="name", + ) async def _build_speech( self, diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index df1ffc7f74f2a..5e5800ad6f18f 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -6,12 +6,8 @@ from typing import Any from aiohttp import web -from hassil.recognize import ( - MISSING_ENTITY, - RecognizeResult, - UnmatchedRangeEntity, - UnmatchedTextEntity, -) +from hassil.recognize import MISSING_ENTITY, RecognizeResult +from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity import voluptuous as vol from homeassistant.components import http, websocket_api diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 8b5c6ef173ff3..1676cdf825485 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.6"] + "requirements": ["hassil==2.0.1", "home-assistant-intents==2024.11.13"] } diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index ec7ecc76da062..a4f64ffbad9ce 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -4,7 +4,8 @@ from typing import Any -from hassil.recognize import PUNCTUATION, RecognizeResult +from hassil.recognize import RecognizeResult +from hassil.util import PUNCTUATION_ALL import voluptuous as vol from homeassistant.const import CONF_COMMAND, CONF_PLATFORM @@ -20,7 +21,7 @@ def has_no_punctuation(value: list[str]) -> list[str]: """Validate result does not contain punctuation.""" for sentence in value: - if PUNCTUATION.search(sentence): + if PUNCTUATION_ALL.search(sentence): raise vol.Invalid("sentence should not contain punctuation") return value diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index abaf269103e52..04e28fef58a88 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,10 +32,10 @@ go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 habluetooth==3.6.0 hass-nabucasa==0.84.0 -hassil==1.7.4 +hassil==2.0.1 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241106.2 -home-assistant-intents==2024.11.6 +home-assistant-intents==2024.11.13 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 00984b9a5a6fc..e9b5cb8129f29 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1093,7 +1093,7 @@ hass-nabucasa==0.84.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.7.4 +hassil==2.0.1 # homeassistant.components.jewish_calendar hdate==0.10.9 @@ -1130,7 +1130,7 @@ holidays==0.60 home-assistant-frontend==20241106.2 # homeassistant.components.conversation -home-assistant-intents==2024.11.6 +home-assistant-intents==2024.11.13 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ffda690bc33a8..de08e2db39555 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -928,7 +928,7 @@ habluetooth==3.6.0 hass-nabucasa==0.84.0 # homeassistant.components.conversation -hassil==1.7.4 +hassil==2.0.1 # homeassistant.components.jewish_calendar hdate==0.10.9 @@ -956,7 +956,7 @@ holidays==0.60 home-assistant-frontend==20241106.2 # homeassistant.components.conversation -home-assistant-intents==2024.11.6 +home-assistant-intents==2024.11.13 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 9bad1e8aecc04..c921cf0e186e4 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.3 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.2 hassil==1.7.4 home-assistant-intents==2024.11.6 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.2 hassil==2.0.1 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 131444c17ac6a..b806c6faf23eb 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -697,7 +697,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any area called are', + 'speech': 'Sorry, I am not aware of any area called Are', }), }), }), @@ -741,7 +741,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any area called are', + 'speech': 'Sorry, I am not aware of any area called Are', }), }), }), diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 08aca43aba520..d9d859113f824 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -639,7 +639,7 @@ 'details': dict({ 'brightness': dict({ 'name': 'brightness', - 'text': '100%', + 'text': '100', 'value': 100, }), 'name': dict({ @@ -654,7 +654,7 @@ 'match': True, 'sentence_template': '[] brightness [to] ', 'slots': dict({ - 'brightness': '100%', + 'brightness': '100', 'name': 'test light', }), 'source': 'builtin', diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 9f54671d8a1b1..3c6b463670a37 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -770,8 +770,8 @@ async def test_error_no_device_on_floor_exposed( ) with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[recognize_result], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=recognize_result, ): result = await conversation.async_converse( hass, "turn on test light on the ground floor", None, Context(), None @@ -838,8 +838,8 @@ async def test_error_no_domain(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[recognize_result], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=recognize_result, ): result = await conversation.async_converse( hass, "turn on the fans", None, Context(), None @@ -873,8 +873,8 @@ async def test_error_no_domain_exposed(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[recognize_result], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=recognize_result, ): result = await conversation.async_converse( hass, "turn on the fans", None, Context(), None @@ -1047,8 +1047,8 @@ async def test_error_no_device_class(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[recognize_result], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=recognize_result, ): result = await conversation.async_converse( hass, "open the windows", None, Context(), None @@ -1096,8 +1096,8 @@ async def test_error_no_device_class_exposed(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[recognize_result], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=recognize_result, ): result = await conversation.async_converse( hass, "open all the windows", None, Context(), None @@ -1207,8 +1207,8 @@ async def test_error_no_device_class_on_floor_exposed( ) with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[recognize_result], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=recognize_result, ): result = await conversation.async_converse( hass, "open ground floor windows", None, Context(), None @@ -1229,8 +1229,8 @@ async def test_error_no_device_class_on_floor_exposed( async def test_error_no_intent(hass: HomeAssistant) -> None: """Test response with an intent match failure.""" with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=None, ): result = await conversation.async_converse( hass, "do something", None, Context(), None diff --git a/tests/components/conversation/test_trace.py b/tests/components/conversation/test_trace.py index 59cd10d251011..7c00b9a80b28c 100644 --- a/tests/components/conversation/test_trace.py +++ b/tests/components/conversation/test_trace.py @@ -56,7 +56,7 @@ async def test_converation_trace( "intent_name": "HassListAddItem", "slots": { "name": "Shopping List", - "item": "apples ", + "item": "apples", }, } From 6a3b4a6a237382e640c87e0f3f644385e65abb6a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Nov 2024 00:49:39 +0100 Subject: [PATCH 0427/1070] Adjust minimum scapy version to 2.6.1 (#130565) --- homeassistant/package_constraints.txt | 4 ++-- script/gen_requirements_all.py | 4 ++-- tests/components/dhcp/conftest.py | 21 --------------------- 3 files changed, 4 insertions(+), 25 deletions(-) delete mode 100644 tests/components/dhcp/conftest.py diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 04e28fef58a88..5bc539beb8693 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -181,8 +181,8 @@ chacha20poly1305-reuseable>=0.13.0 # https://github.com/pycountry/pycountry/blob/ea69bab36f00df58624a0e490fdad4ccdc14268b/HISTORY.txt#L39 pycountry>=23.12.11 -# scapy<2.5.0 will not work with python3.12 -scapy>=2.5.0 +# scapy==2.6.0 causes CI failures due to a race condition +scapy>=2.6.1 # tuf isn't updated to deal with breaking changes in securesystemslib==1.0. # Only tuf>=4 includes a constraint to <1.0. diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index c5611069bf5b8..7d53741c661eb 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -214,8 +214,8 @@ # https://github.com/pycountry/pycountry/blob/ea69bab36f00df58624a0e490fdad4ccdc14268b/HISTORY.txt#L39 pycountry>=23.12.11 -# scapy<2.5.0 will not work with python3.12 -scapy>=2.5.0 +# scapy==2.6.0 causes CI failures due to a race condition +scapy>=2.6.1 # tuf isn't updated to deal with breaking changes in securesystemslib==1.0. # Only tuf>=4 includes a constraint to <1.0. diff --git a/tests/components/dhcp/conftest.py b/tests/components/dhcp/conftest.py deleted file mode 100644 index b0fa3f573c506..0000000000000 --- a/tests/components/dhcp/conftest.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Tests for the dhcp integration.""" - -import os -import pathlib - - -def pytest_sessionstart(session): - """Try to avoid flaky FileExistsError in CI. - - Called after the Session object has been created and - before performing collection and entering the run test loop. - - This is needed due to a race condition in scapy v2.6.0 - See https://github.com/secdev/scapy/pull/4558 - - Can be removed when scapy 2.6.1 is released. - """ - for sub_dir in (".cache", ".config"): - path = pathlib.Path(os.path.join(os.path.expanduser("~"), sub_dir)) - if not path.exists(): - path.mkdir(mode=0o700, exist_ok=True) From 4aad614497a3dc951ed7c616355b2e551137afef Mon Sep 17 00:00:00 2001 From: Tony <29752086+ms264556@users.noreply.github.com> Date: Thu, 14 Nov 2024 21:43:59 +1300 Subject: [PATCH 0428/1070] Bump aioruckus to 0.42 (#130487) --- homeassistant/components/ruckus_unleashed/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ruckus_unleashed/manifest.json b/homeassistant/components/ruckus_unleashed/manifest.json index 2066b65221e5f..8d56f3a556336 100644 --- a/homeassistant/components/ruckus_unleashed/manifest.json +++ b/homeassistant/components/ruckus_unleashed/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioruckus"], - "requirements": ["aioruckus==0.41"] + "requirements": ["aioruckus==0.42"] } diff --git a/requirements_all.txt b/requirements_all.txt index e9b5cb8129f29..a68fc1a828ccf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -354,7 +354,7 @@ aiorecollect==2023.09.0 aioridwell==2024.01.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.41 +aioruckus==0.42 # homeassistant.components.russound_rio aiorussound==4.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de08e2db39555..7501398f4d393 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -336,7 +336,7 @@ aiorecollect==2023.09.0 aioridwell==2024.01.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.41 +aioruckus==0.42 # homeassistant.components.russound_rio aiorussound==4.1.0 From 4200913d03489f67e8ca332dda0800c6d1303588 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Nov 2024 02:45:08 -0600 Subject: [PATCH 0429/1070] Fix non-thread-safe operation in powerview number (#130557) --- homeassistant/components/hunterdouglas_powerview/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hunterdouglas_powerview/number.py b/homeassistant/components/hunterdouglas_powerview/number.py index f893b04b2d1e4..fb8c9f76d7916 100644 --- a/homeassistant/components/hunterdouglas_powerview/number.py +++ b/homeassistant/components/hunterdouglas_powerview/number.py @@ -95,7 +95,7 @@ def __init__( self.entity_description = description self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" - def set_native_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" self._attr_native_value = value self.entity_description.store_value_fn(self.coordinator, self._shade.id, value) From 2fda4c82de226f5d6e90bc3b81caa35c74756275 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 14 Nov 2024 18:46:24 +1000 Subject: [PATCH 0430/1070] Force login prompt in Tesla Fleet (#130576) --- homeassistant/components/tesla_fleet/oauth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_fleet/oauth.py b/homeassistant/components/tesla_fleet/oauth.py index 00976abf56fd6..8b43460436b27 100644 --- a/homeassistant/components/tesla_fleet/oauth.py +++ b/homeassistant/components/tesla_fleet/oauth.py @@ -49,6 +49,7 @@ def name(self) -> str: def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" return { + "prompt": "login", "scope": " ".join(SCOPES), "code_challenge": self.code_challenge, # PKCE } @@ -83,4 +84,4 @@ def __init__( @property def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" - return {"scope": " ".join(SCOPES)} + return {"prompt": "login", "scope": " ".join(SCOPES)} From 938b1eca2299130b28467632aa0b09aaa9c408c9 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 14 Nov 2024 03:52:28 -0500 Subject: [PATCH 0431/1070] Fix when the Roborock map is being provisioned (#130574) --- homeassistant/components/roborock/coordinator.py | 7 +++++-- homeassistant/components/roborock/select.py | 8 +++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 20bc50f9855cd..fe592074f710a 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from datetime import timedelta import logging @@ -107,8 +106,12 @@ async def _update_device_prop(self) -> None: async def _async_update_data(self) -> DeviceProp: """Update data via library.""" try: - await asyncio.gather(*(self._update_device_prop(), self.get_rooms())) + # Update device props and standard api information + await self._update_device_prop() + # Set the new map id from the updated device props self._set_current_map() + # Get the rooms for that map id. + await self.get_rooms() except RoborockException as ex: raise UpdateFailed(ex) from ex return self.roborock_device_info.props diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 3dfe0e72a7b36..73cb95d2d7c32 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -135,6 +135,9 @@ async def async_select_option(self, option: str) -> None: RoborockCommand.LOAD_MULTI_MAP, [map_id], ) + # Update the current map id manually so that nothing gets broken + # if another service hits the api. + self.coordinator.current_map = map_id # We need to wait after updating the map # so that other commands will be executed correctly. await asyncio.sleep(MAP_SLEEP) @@ -148,6 +151,9 @@ def options(self) -> list[str]: @property def current_option(self) -> str | None: """Get the current status of the select entity from device_status.""" - if (current_map := self.coordinator.current_map) is not None: + if ( + (current_map := self.coordinator.current_map) is not None + and current_map in self.coordinator.maps + ): # 63 means it is searching for a map. return self.coordinator.maps[current_map].name return None From 2c1d1f577718dd08b0779e7ce786609c2c1df002 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 14 Nov 2024 09:09:58 +0000 Subject: [PATCH 0432/1070] Do not trigger events for updated ring events (#130430) --- homeassistant/components/ring/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py index e6d9d25542fad..71a4bc8aea5d3 100644 --- a/homeassistant/components/ring/event.py +++ b/homeassistant/components/ring/event.py @@ -96,7 +96,7 @@ def _get_coordinator_alert(self) -> RingAlert | None: @callback def _handle_coordinator_update(self) -> None: - if alert := self._get_coordinator_alert(): + if (alert := self._get_coordinator_alert()) and not alert.is_update: self._async_handle_event(alert.kind) super()._handle_coordinator_update() From 58fd917cb763e876353437e9ab46304cd429872b Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Thu, 14 Nov 2024 04:11:44 -0500 Subject: [PATCH 0433/1070] Disable brightness from devices with no display in Cambridge Audio (#130369) --- homeassistant/components/cambridge_audio/manifest.json | 2 +- homeassistant/components/cambridge_audio/select.py | 7 ++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index edacd17f54dd2..c359ca14a21a0 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aiostreammagic"], - "requirements": ["aiostreammagic==2.8.4"], + "requirements": ["aiostreammagic==2.8.5"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/homeassistant/components/cambridge_audio/select.py b/homeassistant/components/cambridge_audio/select.py index ca6eebdec6b8c..c99abc853e552 100644 --- a/homeassistant/components/cambridge_audio/select.py +++ b/homeassistant/components/cambridge_audio/select.py @@ -51,8 +51,13 @@ def _audio_output_value_fn(client: StreamMagicClient) -> str | None: CambridgeAudioSelectEntityDescription( key="display_brightness", translation_key="display_brightness", - options=[x.value for x in DisplayBrightness], + options=[ + DisplayBrightness.BRIGHT.value, + DisplayBrightness.DIM.value, + DisplayBrightness.OFF.value, + ], entity_category=EntityCategory.CONFIG, + load_fn=lambda client: client.display.brightness != DisplayBrightness.NONE, value_fn=lambda client: client.display.brightness, set_value_fn=lambda client, value: client.set_display_brightness( DisplayBrightness(value) diff --git a/requirements_all.txt b/requirements_all.txt index a68fc1a828ccf..32f111781da16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -381,7 +381,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.8.4 +aiostreammagic==2.8.5 # homeassistant.components.switcher_kis aioswitcher==4.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7501398f4d393..237c70c8afbe3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -363,7 +363,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.8.4 +aiostreammagic==2.8.5 # homeassistant.components.switcher_kis aioswitcher==4.4.0 From 245fc246d85931c9697b9e1ba586fdde2e10325b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 14 Nov 2024 04:13:29 -0500 Subject: [PATCH 0434/1070] Ensure ZHA setup works with container installs (#130470) --- homeassistant/components/zha/config_flow.py | 36 +++++++++-------- tests/components/zha/test_config_flow.py | 43 ++++++++++++++++----- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 1c7e0d105c419..f3f7f38772d2d 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -33,6 +33,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.selector import FileSelector, FileSelectorConfig from homeassistant.util import dt as dt_util @@ -104,25 +105,26 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: yellow_radio.description = "Yellow Zigbee module" yellow_radio.manufacturer = "Nabu Casa" - # Present the multi-PAN addon as a setup option, if it's available - multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( - hass - ) - - try: - addon_info = await multipan_manager.async_get_addon_info() - except (AddonError, KeyError): - addon_info = None - - if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: - addon_port = ListPortInfo( - device=silabs_multiprotocol_addon.get_zigbee_socket(), - skip_link_detection=True, + if is_hassio(hass): + # Present the multi-PAN addon as a setup option, if it's available + multipan_manager = ( + await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(hass) ) - addon_port.description = "Multiprotocol add-on" - addon_port.manufacturer = "Nabu Casa" - ports.append(addon_port) + try: + addon_info = await multipan_manager.async_get_addon_info() + except (AddonError, KeyError): + addon_info = None + + if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: + addon_port = ListPortInfo( + device=silabs_multiprotocol_addon.get_zigbee_socket(), + skip_link_detection=True, + ) + + addon_port.description = "Multiprotocol add-on" + addon_port.manufacturer = "Nabu Casa" + ports.append(addon_port) return ports diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 1382c5c2569e7..87ba46a4ced11 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -21,7 +21,7 @@ from homeassistant import config_entries from homeassistant.components import ssdp, usb, zeroconf -from homeassistant.components.hassio import AddonState +from homeassistant.components.hassio import AddonError, AddonState from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.const import ( @@ -1878,10 +1878,23 @@ async def test_config_flow_port_yellow_port_name(hass: HomeAssistant) -> None: ) +async def test_config_flow_ports_no_hassio(hass: HomeAssistant) -> None: + """Test config flow serial port name when this is not a hassio install.""" + + with ( + patch("homeassistant.components.zha.config_flow.is_hassio", return_value=False), + patch("serial.tools.list_ports.comports", MagicMock(return_value=[])), + ): + ports = await config_flow.list_serial_ports(hass) + + assert ports == [] + + async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) -> None: """Test config flow serial port name for multiprotocol add-on.""" with ( + patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True), patch( "homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info" ) as async_get_addon_info, @@ -1889,16 +1902,28 @@ async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) -> ): async_get_addon_info.return_value.state = AddonState.RUNNING async_get_addon_info.return_value.hostname = "core-silabs-multiprotocol" + ports = await config_flow.list_serial_ports(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) + assert len(ports) == 1 + assert ports[0].description == "Multiprotocol add-on" + assert ports[0].manufacturer == "Nabu Casa" + assert ports[0].device == "socket://core-silabs-multiprotocol:9999" - assert ( - result["data_schema"].schema["path"].container[0] - == "socket://core-silabs-multiprotocol:9999 - Multiprotocol add-on - Nabu Casa" - ) + +async def test_config_flow_port_no_multiprotocol(hass: HomeAssistant) -> None: + """Test config flow serial port listing when addon info fails to load.""" + + with ( + patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True), + patch( + "homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info", + side_effect=AddonError, + ), + patch("serial.tools.list_ports.comports", MagicMock(return_value=[])), + ): + ports = await config_flow.list_serial_ports(hass) + + assert ports == [] @patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) From 301043ec387f581c8aedba8c7ac7475c53349048 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 14 Nov 2024 10:27:45 +0100 Subject: [PATCH 0435/1070] Add require_webrtc_support decorator (#130519) --- homeassistant/components/camera/webrtc.py | 93 ++++++++++++----------- 1 file changed, 50 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index 0612c96e40c8a..d627a88816948 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Awaitable, Callable, Iterable from dataclasses import asdict, dataclass, field -from functools import cache, partial +from functools import cache, partial, wraps import logging from typing import TYPE_CHECKING, Any, Protocol @@ -205,6 +205,49 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None: ) +type WsCommandWithCamera = Callable[ + [websocket_api.ActiveConnection, dict[str, Any], Camera], + Awaitable[None], +] + + +def require_webrtc_support( + error_code: str, +) -> Callable[[WsCommandWithCamera], websocket_api.AsyncWebSocketCommandHandler]: + """Validate that the camera supports WebRTC.""" + + def decorate( + func: WsCommandWithCamera, + ) -> websocket_api.AsyncWebSocketCommandHandler: + """Decorate func.""" + + @wraps(func) + async def validate( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: + """Validate that the camera supports WebRTC.""" + entity_id = msg["entity_id"] + camera = get_camera_from_entity_id(hass, entity_id) + if camera.frontend_stream_type != StreamType.WEB_RTC: + connection.send_error( + msg["id"], + error_code, + ( + "Camera does not support WebRTC," + f" frontend_stream_type={camera.frontend_stream_type}" + ), + ) + return + + await func(connection, msg, camera) + + return validate + + return decorate + + @websocket_api.websocket_command( { vol.Required("type"): "camera/webrtc/offer", @@ -213,8 +256,9 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None: } ) @websocket_api.async_response +@require_webrtc_support("webrtc_offer_failed") async def ws_webrtc_offer( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] + connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera ) -> None: """Handle the signal path for a WebRTC stream. @@ -226,20 +270,7 @@ async def ws_webrtc_offer( Async friendly. """ - entity_id = msg["entity_id"] offer = msg["offer"] - camera = get_camera_from_entity_id(hass, entity_id) - if camera.frontend_stream_type != StreamType.WEB_RTC: - connection.send_error( - msg["id"], - "webrtc_offer_failed", - ( - "Camera does not support WebRTC," - f" frontend_stream_type={camera.frontend_stream_type}" - ), - ) - return - session_id = ulid() connection.subscriptions[msg["id"]] = partial( camera.close_webrtc_session, session_id @@ -278,23 +309,11 @@ def send_message(message: WebRTCMessage) -> None: } ) @websocket_api.async_response +@require_webrtc_support("webrtc_get_client_config_failed") async def ws_get_client_config( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] + connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera ) -> None: """Handle get WebRTC client config websocket command.""" - entity_id = msg["entity_id"] - camera = get_camera_from_entity_id(hass, entity_id) - if camera.frontend_stream_type != StreamType.WEB_RTC: - connection.send_error( - msg["id"], - "webrtc_get_client_config_failed", - ( - "Camera does not support WebRTC," - f" frontend_stream_type={camera.frontend_stream_type}" - ), - ) - return - config = camera.async_get_webrtc_client_configuration().to_frontend_dict() connection.send_result( msg["id"], @@ -311,23 +330,11 @@ async def ws_get_client_config( } ) @websocket_api.async_response +@require_webrtc_support("webrtc_candidate_failed") async def ws_candidate( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] + connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera ) -> None: """Handle WebRTC candidate websocket command.""" - entity_id = msg["entity_id"] - camera = get_camera_from_entity_id(hass, entity_id) - if camera.frontend_stream_type != StreamType.WEB_RTC: - connection.send_error( - msg["id"], - "webrtc_candidate_failed", - ( - "Camera does not support WebRTC," - f" frontend_stream_type={camera.frontend_stream_type}" - ), - ) - return - await camera.async_on_webrtc_candidate( msg["session_id"], RTCIceCandidate(msg["candidate"]) ) From 46cfe6aa32d30f9d8ecdb29742b3568d871d403f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 14 Nov 2024 10:28:04 +0100 Subject: [PATCH 0436/1070] Refactor camera WebRTC tests (#130581) --- tests/components/camera/test_webrtc.py | 65 +++++++++++++------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index ba5cf35c52f35..29fb9d61c4ed7 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -139,42 +139,46 @@ async def async_unload_entry_init( return test_camera -@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") async def test_async_register_webrtc_provider( hass: HomeAssistant, ) -> None: """Test registering a WebRTC provider.""" - await async_setup_component(hass, "camera", {}) - camera = get_camera_from_entity_id(hass, "camera.demo_camera") - assert camera.frontend_stream_type is StreamType.HLS + assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS} provider = SomeTestProvider() unregister = async_register_webrtc_provider(hass, provider) await hass.async_block_till_done() - assert camera.frontend_stream_type is StreamType.WEB_RTC + assert camera.camera_capabilities.frontend_stream_types == { + StreamType.HLS, + StreamType.WEB_RTC, + } # Mark stream as unsupported provider._is_supported = False # Manually refresh the provider await camera.async_refresh_providers() - assert camera.frontend_stream_type is StreamType.HLS + assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS} # Mark stream as supported provider._is_supported = True # Manually refresh the provider await camera.async_refresh_providers() - assert camera.frontend_stream_type is StreamType.WEB_RTC + assert camera.camera_capabilities.frontend_stream_types == { + StreamType.HLS, + StreamType.WEB_RTC, + } unregister() await hass.async_block_till_done() - assert camera.frontend_stream_type is StreamType.HLS + assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS} -@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") async def test_async_register_webrtc_provider_twice( hass: HomeAssistant, register_test_provider: SomeTestProvider, @@ -192,13 +196,11 @@ async def test_async_register_webrtc_provider_camera_not_loaded( async_register_webrtc_provider(hass, SomeTestProvider()) -@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_async_register_ice_server( hass: HomeAssistant, ) -> None: """Test registering an ICE server.""" - await async_setup_component(hass, "camera", {}) - # Clear any existing ICE servers hass.data[DATA_ICE_SERVERS].clear() @@ -216,7 +218,7 @@ def get_ice_servers() -> list[RTCIceServer]: unregister = async_register_ice_servers(hass, get_ice_servers) assert not called - camera = get_camera_from_entity_id(hass, "camera.demo_camera") + camera = get_camera_from_entity_id(hass, "camera.async") config = camera.async_get_webrtc_client_configuration() assert config.configuration.ice_servers == [ @@ -277,7 +279,7 @@ def get_ice_servers_2() -> list[RTCIceServer]: assert config.configuration.ice_servers == [] -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_get_client_config( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -286,7 +288,7 @@ async def test_ws_get_client_config( client = await hass_ws_client(hass) await client.send_json_auto_id( - {"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"} + {"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"} ) msg = await client.receive_json() @@ -320,7 +322,7 @@ def get_ice_server() -> list[RTCIceServer]: async_register_ice_servers(hass, get_ice_server) await client.send_json_auto_id( - {"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"} + {"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"} ) msg = await client.receive_json() @@ -370,7 +372,7 @@ async def test_ws_get_client_config_sync_offer( } -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_get_client_config_custom_config( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -384,7 +386,7 @@ async def test_ws_get_client_config_custom_config( client = await hass_ws_client(hass) await client.send_json_auto_id( - {"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"} + {"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"} ) msg = await client.receive_json() @@ -435,7 +437,7 @@ def mock_rtsp_to_webrtc_fixture(hass: HomeAssistant) -> Generator[Mock]: unsub() -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_websocket_webrtc_offer( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -444,7 +446,7 @@ async def test_websocket_webrtc_offer( await client.send_json_auto_id( { "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", + "entity_id": "camera.async", "offer": WEBRTC_OFFER, } ) @@ -555,11 +557,11 @@ async def test_websocket_webrtc_offer_webrtc_provider( mock_async_close_session.assert_called_once_with(session_id) -@pytest.mark.usefixtures("mock_camera_webrtc") async def test_websocket_webrtc_offer_invalid_entity( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test WebRTC with a camera entity that does not exist.""" + await async_setup_component(hass, "camera", {}) client = await hass_ws_client(hass) await client.send_json_auto_id( { @@ -578,7 +580,7 @@ async def test_websocket_webrtc_offer_invalid_entity( } -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_websocket_webrtc_offer_missing_offer( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -605,7 +607,6 @@ async def test_websocket_webrtc_offer_missing_offer( (TimeoutError(), "Timeout handling WebRTC offer"), ], ) -@pytest.mark.usefixtures("mock_camera_webrtc_frontendtype_only") async def test_websocket_webrtc_offer_failure( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -949,7 +950,7 @@ async def provide_none( unsub() -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_webrtc_candidate( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -957,13 +958,13 @@ async def test_ws_webrtc_candidate( client = await hass_ws_client(hass) session_id = "session_id" candidate = "candidate" - with patch( - "homeassistant.components.camera.Camera.async_on_webrtc_candidate" + with patch.object( + get_camera_from_entity_id(hass, "camera.async"), "async_on_webrtc_candidate" ) as mock_on_webrtc_candidate: await client.send_json_auto_id( { "type": "camera/webrtc/candidate", - "entity_id": "camera.demo_camera", + "entity_id": "camera.async", "session_id": session_id, "candidate": candidate, } @@ -976,7 +977,7 @@ async def test_ws_webrtc_candidate( ) -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_webrtc_candidate_not_supported( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -985,7 +986,7 @@ async def test_ws_webrtc_candidate_not_supported( await client.send_json_auto_id( { "type": "camera/webrtc/candidate", - "entity_id": "camera.demo_camera", + "entity_id": "camera.sync", "session_id": "session_id", "candidate": "candidate", } @@ -1028,11 +1029,11 @@ async def test_ws_webrtc_candidate_webrtc_provider( ) -@pytest.mark.usefixtures("mock_camera_webrtc") async def test_ws_webrtc_candidate_invalid_entity( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test ws WebRTC candidate command with a camera entity that does not exist.""" + await async_setup_component(hass, "camera", {}) client = await hass_ws_client(hass) await client.send_json_auto_id( { @@ -1052,7 +1053,7 @@ async def test_ws_webrtc_candidate_invalid_entity( } -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_webrtc_canidate_missing_candidate( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1061,7 +1062,7 @@ async def test_ws_webrtc_canidate_missing_candidate( await client.send_json_auto_id( { "type": "camera/webrtc/candidate", - "entity_id": "camera.demo_camera", + "entity_id": "camera.async", "session_id": "session_id", } ) From 93f79be2f4a83f3dd420a99a59076e2c61d7683f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 14 Nov 2024 10:35:03 +0100 Subject: [PATCH 0437/1070] Update uptime deviation for Vodafone Station (#130571) Update sensor.py --- homeassistant/components/vodafone_station/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index fb76253eb3d1d..307fcaf0ea8e3 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -22,7 +22,7 @@ from .coordinator import VodafoneStationRouter NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] -UPTIME_DEVIATION = 45 +UPTIME_DEVIATION = 60 @dataclass(frozen=True, kw_only=True) From d0a58b68e8d35d2dea7bfdf14fd7a6a45b10fb99 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 14 Nov 2024 10:48:25 +0100 Subject: [PATCH 0438/1070] Bump reolink-aio to 0.11.1 (#130600) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 22fd625770fa1..7921bdb6ed58f 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.11.0"] + "requirements": ["reolink-aio==0.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 32f111781da16..9ad6a1199f2c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2553,7 +2553,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.0 +reolink-aio==0.11.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 237c70c8afbe3..68d1c393fc1c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2044,7 +2044,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.0 +reolink-aio==0.11.1 # homeassistant.components.rflink rflink==0.0.66 From 3201142fd8c3f84a7440c5ce4d76fd6597d8e9ed Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 14 Nov 2024 11:01:26 +0100 Subject: [PATCH 0439/1070] Fix hassfest by adding go2rtc reqs (#130602) --- script/hassfest/docker.py | 2 ++ script/hassfest/docker/Dockerfile | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 137bbc7ff66ca..0eb72b91c023c 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -161,6 +161,8 @@ def _generate_hassfest_dockerimage( packages.update( gather_recursive_requirements(platform.value, already_checked_domains) ) + # Add go2rtc requirements as this file needs the go2rtc integration + packages.update(gather_recursive_requirements("go2rtc", already_checked_domains)) return File( _HASSFEST_TEMPLATE.format( diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index c921cf0e186e4..fe18c4dd48628 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.3 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.2 hassil==2.0.1 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.1 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From a748897bd23b29be81b81487405c335ba217d7c2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 14 Nov 2024 12:44:06 +0100 Subject: [PATCH 0440/1070] Update hassfest image to Python 3.13 (#130607) --- script/hassfest/docker.py | 2 +- script/hassfest/docker/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 0eb72b91c023c..57d86bc4def77 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -80,7 +80,7 @@ _HASSFEST_TEMPLATE = r"""# Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p docker -FROM python:3.12-alpine +FROM python:3.13-alpine ENV \ UV_SYSTEM_PYTHON=true \ diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index fe18c4dd48628..0fa0a1a89faa4 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -1,7 +1,7 @@ # Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p docker -FROM python:3.12-alpine +FROM python:3.13-alpine ENV \ UV_SYSTEM_PYTHON=true \ From a949d18c30f86beabc21c73bae5e04d88da64bb8 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Thu, 14 Nov 2024 13:04:22 +0100 Subject: [PATCH 0441/1070] Bump eq3btsmart to 1.4.1 (#130426) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index bd3f14939ca91..b30f806bf63a8 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -23,5 +23,5 @@ "iot_class": "local_polling", "loggers": ["eq3btsmart"], "quality_scale": "silver", - "requirements": ["eq3btsmart==1.2.1", "bleak-esphome==1.1.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9ad6a1199f2c5..3b46bf19ae6d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -860,7 +860,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.2.1 +eq3btsmart==1.4.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68d1c393fc1c7..b27979b23f295 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -729,7 +729,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.2.1 +eq3btsmart==1.4.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 From eea782bbfe230168df52d8a30ceac94e463d2c98 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 14 Nov 2024 13:28:38 +0100 Subject: [PATCH 0442/1070] Add acaia integration (#130059) Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/acaia/__init__.py | 29 +++ homeassistant/components/acaia/button.py | 61 +++++ homeassistant/components/acaia/config_flow.py | 149 +++++++++++ homeassistant/components/acaia/const.py | 4 + homeassistant/components/acaia/coordinator.py | 86 +++++++ homeassistant/components/acaia/entity.py | 40 +++ homeassistant/components/acaia/icons.json | 15 ++ homeassistant/components/acaia/manifest.json | 29 +++ homeassistant/components/acaia/strings.json | 38 +++ homeassistant/generated/bluetooth.py | 20 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/acaia/__init__.py | 14 + tests/components/acaia/conftest.py | 80 ++++++ .../acaia/snapshots/test_button.ambr | 139 ++++++++++ .../components/acaia/snapshots/test_init.ambr | 33 +++ tests/components/acaia/test_button.py | 83 ++++++ tests/components/acaia/test_config_flow.py | 242 ++++++++++++++++++ tests/components/acaia/test_init.py | 65 +++++ 22 files changed, 1142 insertions(+) create mode 100644 homeassistant/components/acaia/__init__.py create mode 100644 homeassistant/components/acaia/button.py create mode 100644 homeassistant/components/acaia/config_flow.py create mode 100644 homeassistant/components/acaia/const.py create mode 100644 homeassistant/components/acaia/coordinator.py create mode 100644 homeassistant/components/acaia/entity.py create mode 100644 homeassistant/components/acaia/icons.json create mode 100644 homeassistant/components/acaia/manifest.json create mode 100644 homeassistant/components/acaia/strings.json create mode 100644 tests/components/acaia/__init__.py create mode 100644 tests/components/acaia/conftest.py create mode 100644 tests/components/acaia/snapshots/test_button.ambr create mode 100644 tests/components/acaia/snapshots/test_init.ambr create mode 100644 tests/components/acaia/test_button.py create mode 100644 tests/components/acaia/test_config_flow.py create mode 100644 tests/components/acaia/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 76422734c921a..8fd34a357c0db 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -40,6 +40,8 @@ build.json @home-assistant/supervisor # Integrations /homeassistant/components/abode/ @shred86 /tests/components/abode/ @shred86 +/homeassistant/components/acaia/ @zweckj +/tests/components/acaia/ @zweckj /homeassistant/components/accuweather/ @bieniu /tests/components/accuweather/ @bieniu /homeassistant/components/acmeda/ @atmurray diff --git a/homeassistant/components/acaia/__init__.py b/homeassistant/components/acaia/__init__.py new file mode 100644 index 0000000000000..dfdb4cb935d8b --- /dev/null +++ b/homeassistant/components/acaia/__init__.py @@ -0,0 +1,29 @@ +"""Initialize the Acaia component.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import AcaiaConfigEntry, AcaiaCoordinator + +PLATFORMS = [ + Platform.BUTTON, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool: + """Set up acaia as config entry.""" + + coordinator = AcaiaCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool: + """Unload a config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/acaia/button.py b/homeassistant/components/acaia/button.py new file mode 100644 index 0000000000000..50671eecbba53 --- /dev/null +++ b/homeassistant/components/acaia/button.py @@ -0,0 +1,61 @@ +"""Button entities for Acaia scales.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from aioacaia.acaiascale import AcaiaScale + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import AcaiaConfigEntry +from .entity import AcaiaEntity + + +@dataclass(kw_only=True, frozen=True) +class AcaiaButtonEntityDescription(ButtonEntityDescription): + """Description for acaia button entities.""" + + press_fn: Callable[[AcaiaScale], Coroutine[Any, Any, None]] + + +BUTTONS: tuple[AcaiaButtonEntityDescription, ...] = ( + AcaiaButtonEntityDescription( + key="tare", + translation_key="tare", + press_fn=lambda scale: scale.tare(), + ), + AcaiaButtonEntityDescription( + key="reset_timer", + translation_key="reset_timer", + press_fn=lambda scale: scale.reset_timer(), + ), + AcaiaButtonEntityDescription( + key="start_stop", + translation_key="start_stop", + press_fn=lambda scale: scale.start_stop_timer(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AcaiaConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up button entities and services.""" + + coordinator = entry.runtime_data + async_add_entities(AcaiaButton(coordinator, description) for description in BUTTONS) + + +class AcaiaButton(AcaiaEntity, ButtonEntity): + """Representation of an Acaia button.""" + + entity_description: AcaiaButtonEntityDescription + + async def async_press(self) -> None: + """Handle the button press.""" + await self.entity_description.press_fn(self._scale) diff --git a/homeassistant/components/acaia/config_flow.py b/homeassistant/components/acaia/config_flow.py new file mode 100644 index 0000000000000..36727059c8a8a --- /dev/null +++ b/homeassistant/components/acaia/config_flow.py @@ -0,0 +1,149 @@ +"""Config flow for Acaia integration.""" + +import logging +from typing import Any + +from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice +from aioacaia.helpers import is_new_scale +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_IS_NEW_STYLE_SCALE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for acaia.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered: dict[str, Any] = {} + self._discovered_devices: dict[str, str] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + + errors: dict[str, str] = {} + + if user_input is not None: + mac = format_mac(user_input[CONF_ADDRESS]) + try: + is_new_style_scale = await is_new_scale(mac) + except AcaiaDeviceNotFound: + errors["base"] = "device_not_found" + except AcaiaError: + _LOGGER.exception("Error occurred while connecting to the scale") + errors["base"] = "unknown" + except AcaiaUnknownDevice: + return self.async_abort(reason="unsupported_device") + else: + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + + if not errors: + return self.async_create_entry( + title=self._discovered_devices[user_input[CONF_ADDRESS]], + data={ + CONF_ADDRESS: mac, + CONF_IS_NEW_STYLE_SCALE: is_new_style_scale, + }, + ) + + for device in async_discovered_service_info(self.hass): + self._discovered_devices[device.address] = device.name + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + options = [ + SelectOptionDict( + value=device_mac, + label=f"{device_name} ({device_mac})", + ) + for device_mac, device_name in self._discovered_devices.items() + ] + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): SelectSelector( + SelectSelectorConfig( + options=options, + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ), + errors=errors, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + """Handle a discovered Bluetooth device.""" + + self._discovered[CONF_ADDRESS] = mac = format_mac(discovery_info.address) + self._discovered[CONF_NAME] = discovery_info.name + + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + + try: + self._discovered[CONF_IS_NEW_STYLE_SCALE] = await is_new_scale( + discovery_info.address + ) + except AcaiaDeviceNotFound: + _LOGGER.debug("Device not found during discovery") + return self.async_abort(reason="device_not_found") + except AcaiaError: + _LOGGER.debug( + "Error occurred while connecting to the scale during discovery", + exc_info=True, + ) + return self.async_abort(reason="unknown") + except AcaiaUnknownDevice: + _LOGGER.debug("Unsupported device during discovery") + return self.async_abort(reason="unsupported_device") + + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle confirmation of Bluetooth discovery.""" + + if user_input is not None: + return self.async_create_entry( + title=self._discovered[CONF_NAME], + data={ + CONF_ADDRESS: self._discovered[CONF_ADDRESS], + CONF_IS_NEW_STYLE_SCALE: self._discovered[CONF_IS_NEW_STYLE_SCALE], + }, + ) + + self.context["title_placeholders"] = placeholders = { + CONF_NAME: self._discovered[CONF_NAME] + } + + self._set_confirm_only() + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders=placeholders, + ) diff --git a/homeassistant/components/acaia/const.py b/homeassistant/components/acaia/const.py new file mode 100644 index 0000000000000..c603578763ddb --- /dev/null +++ b/homeassistant/components/acaia/const.py @@ -0,0 +1,4 @@ +"""Constants for component.""" + +DOMAIN = "acaia" +CONF_IS_NEW_STYLE_SCALE = "is_new_style_scale" diff --git a/homeassistant/components/acaia/coordinator.py b/homeassistant/components/acaia/coordinator.py new file mode 100644 index 0000000000000..bd915b4240815 --- /dev/null +++ b/homeassistant/components/acaia/coordinator.py @@ -0,0 +1,86 @@ +"""Coordinator for Acaia integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from aioacaia.acaiascale import AcaiaScale +from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_IS_NEW_STYLE_SCALE + +SCAN_INTERVAL = timedelta(seconds=15) + +_LOGGER = logging.getLogger(__name__) + +type AcaiaConfigEntry = ConfigEntry[AcaiaCoordinator] + + +class AcaiaCoordinator(DataUpdateCoordinator[None]): + """Class to handle fetching data from the scale.""" + + config_entry: AcaiaConfigEntry + + def __init__(self, hass: HomeAssistant, entry: AcaiaConfigEntry) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name="acaia coordinator", + update_interval=SCAN_INTERVAL, + config_entry=entry, + ) + + self._scale = AcaiaScale( + address_or_ble_device=entry.data[CONF_ADDRESS], + name=entry.title, + is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE], + notify_callback=self.async_update_listeners, + ) + + @property + def scale(self) -> AcaiaScale: + """Return the scale object.""" + return self._scale + + async def _async_update_data(self) -> None: + """Fetch data.""" + + # scale is already connected, return + if self._scale.connected: + return + + # scale is not connected, try to connect + try: + await self._scale.connect(setup_tasks=False) + except (AcaiaDeviceNotFound, AcaiaError, TimeoutError) as ex: + _LOGGER.debug( + "Could not connect to scale: %s, Error: %s", + self.config_entry.data[CONF_ADDRESS], + ex, + ) + self._scale.device_disconnected_handler(notify=False) + return + + # connected, set up background tasks + if not self._scale.heartbeat_task or self._scale.heartbeat_task.done(): + self._scale.heartbeat_task = self.config_entry.async_create_background_task( + hass=self.hass, + target=self._scale.send_heartbeats(), + name="acaia_heartbeat_task", + ) + + if not self._scale.process_queue_task or self._scale.process_queue_task.done(): + self._scale.process_queue_task = ( + self.config_entry.async_create_background_task( + hass=self.hass, + target=self._scale.process_queue(), + name="acaia_process_queue_task", + ) + ) diff --git a/homeassistant/components/acaia/entity.py b/homeassistant/components/acaia/entity.py new file mode 100644 index 0000000000000..8a2108d26871b --- /dev/null +++ b/homeassistant/components/acaia/entity.py @@ -0,0 +1,40 @@ +"""Base class for Acaia entities.""" + +from dataclasses import dataclass + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AcaiaCoordinator + + +@dataclass +class AcaiaEntity(CoordinatorEntity[AcaiaCoordinator]): + """Common elements for all entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AcaiaCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._scale = coordinator.scale + self._attr_unique_id = f"{self._scale.mac}_{entity_description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._scale.mac)}, + manufacturer="Acaia", + model=self._scale.model, + suggested_area="Kitchen", + ) + + @property + def available(self) -> bool: + """Returns whether entity is available.""" + return super().available and self._scale.connected diff --git a/homeassistant/components/acaia/icons.json b/homeassistant/components/acaia/icons.json new file mode 100644 index 0000000000000..aeab07ee91287 --- /dev/null +++ b/homeassistant/components/acaia/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "button": { + "tare": { + "default": "mdi:scale-balance" + }, + "reset_timer": { + "default": "mdi:timer-refresh" + }, + "start_stop": { + "default": "mdi:timer-play" + } + } + } +} diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json new file mode 100644 index 0000000000000..c907a70a38e7a --- /dev/null +++ b/homeassistant/components/acaia/manifest.json @@ -0,0 +1,29 @@ +{ + "domain": "acaia", + "name": "Acaia", + "bluetooth": [ + { + "manufacturer_id": 16962 + }, + { + "local_name": "ACAIA*" + }, + { + "local_name": "PYXIS-*" + }, + { + "local_name": "LUNAR-*" + }, + { + "local_name": "PROCHBT001" + } + ], + "codeowners": ["@zweckj"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/acaia", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["aioacaia"], + "requirements": ["aioacaia==0.1.6"] +} diff --git a/homeassistant/components/acaia/strings.json b/homeassistant/components/acaia/strings.json new file mode 100644 index 0000000000000..f6a1aeb66fdb6 --- /dev/null +++ b/homeassistant/components/acaia/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "flow_title": "{name}", + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "unsupported_device": "This device is not supported." + }, + "error": { + "device_not_found": "Device could not be found.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + } + } + }, + "entity": { + "button": { + "tare": { + "name": "Tare" + }, + "reset_timer": { + "name": "Reset timer" + }, + "start_stop": { + "name": "Start/stop timer" + } + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index c4612898cb2a8..a105efc268520 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -8,6 +8,26 @@ from typing import Final BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ + { + "domain": "acaia", + "manufacturer_id": 16962, + }, + { + "domain": "acaia", + "local_name": "ACAIA*", + }, + { + "domain": "acaia", + "local_name": "PYXIS-*", + }, + { + "domain": "acaia", + "local_name": "LUNAR-*", + }, + { + "domain": "acaia", + "local_name": "PROCHBT001", + }, { "domain": "airthings_ble", "manufacturer_id": 820, diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 78e1612654299..ffe61b915c648 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -24,6 +24,7 @@ ], "integration": [ "abode", + "acaia", "accuweather", "acmeda", "adax", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 33a7d02776f54..f007db878686d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -11,6 +11,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "acaia": { + "name": "Acaia", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "accuweather": { "name": "AccuWeather", "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index 3b46bf19ae6d8..cdba146d251a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -172,6 +172,9 @@ aio-geojson-usgs-earthquakes==0.3 # homeassistant.components.gdacs aio-georss-gdacs==0.10 +# homeassistant.components.acaia +aioacaia==0.1.6 + # homeassistant.components.airq aioairq==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b27979b23f295..39fb7f17d8051 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -160,6 +160,9 @@ aio-geojson-usgs-earthquakes==0.3 # homeassistant.components.gdacs aio-georss-gdacs==0.10 +# homeassistant.components.acaia +aioacaia==0.1.6 + # homeassistant.components.airq aioairq==0.3.2 diff --git a/tests/components/acaia/__init__.py b/tests/components/acaia/__init__.py new file mode 100644 index 0000000000000..f4eaa39e615f3 --- /dev/null +++ b/tests/components/acaia/__init__.py @@ -0,0 +1,14 @@ +"""Common test tools for the acaia integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the acaia integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/acaia/conftest.py b/tests/components/acaia/conftest.py new file mode 100644 index 0000000000000..1dc6ff310512b --- /dev/null +++ b/tests/components/acaia/conftest.py @@ -0,0 +1,80 @@ +"""Common fixtures for the acaia tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from aioacaia.acaiascale import AcaiaDeviceState +from aioacaia.const import UnitMass as AcaiaUnitOfMass +import pytest + +from homeassistant.components.acaia.const import CONF_IS_NEW_STYLE_SCALE, DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.acaia.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_verify() -> Generator[AsyncMock]: + """Override is_new_scale check.""" + with patch( + "homeassistant.components.acaia.config_flow.is_new_scale", return_value=True + ) as mock_verify: + yield mock_verify + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="LUNAR-DDEEFF", + domain=DOMAIN, + version=1, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_IS_NEW_STYLE_SCALE: True, + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_scale: MagicMock +) -> None: + """Set up the acaia integration for testing.""" + await setup_integration(hass, mock_config_entry) + + +@pytest.fixture +def mock_scale() -> Generator[MagicMock]: + """Return a mocked acaia scale client.""" + with ( + patch( + "homeassistant.components.acaia.coordinator.AcaiaScale", + autospec=True, + ) as scale_mock, + ): + scale = scale_mock.return_value + scale.connected = True + scale.mac = "aa:bb:cc:dd:ee:ff" + scale.model = "Lunar" + scale.timer_running = True + scale.heartbeat_task = None + scale.process_queue_task = None + scale.device_state = AcaiaDeviceState( + battery_level=42, units=AcaiaUnitOfMass.GRAMS + ) + scale.weight = 123.45 + yield scale diff --git a/tests/components/acaia/snapshots/test_button.ambr b/tests/components/acaia/snapshots/test_button.ambr new file mode 100644 index 0000000000000..7e2624923af76 --- /dev/null +++ b/tests/components/acaia/snapshots/test_button.ambr @@ -0,0 +1,139 @@ +# serializer version: 1 +# name: test_buttons[entry_button_reset_timer] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.lunar_ddeeff_reset_timer', + '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': 'Reset timer', + 'platform': 'acaia', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_timer', + 'unique_id': 'aa:bb:cc:dd:ee:ff_reset_timer', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[entry_button_start_stop_timer] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.lunar_ddeeff_start_stop_timer', + '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': 'Start/stop timer', + 'platform': 'acaia', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_stop', + 'unique_id': 'aa:bb:cc:dd:ee:ff_start_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[entry_button_tare] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.lunar_ddeeff_tare', + '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': 'Tare', + 'platform': 'acaia', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tare', + 'unique_id': 'aa:bb:cc:dd:ee:ff_tare', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[state_button_reset_timer] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LUNAR-DDEEFF Reset timer', + }), + 'context': , + 'entity_id': 'button.lunar_ddeeff_reset_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[state_button_start_stop_timer] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LUNAR-DDEEFF Start/stop timer', + }), + 'context': , + 'entity_id': 'button.lunar_ddeeff_start_stop_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[state_button_tare] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LUNAR-DDEEFF Tare', + }), + 'context': , + 'entity_id': 'button.lunar_ddeeff_tare', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr new file mode 100644 index 0000000000000..1cc3d8dbbc0f4 --- /dev/null +++ b/tests/components/acaia/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': 'kitchen', + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'acaia', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Acaia', + 'model': 'Lunar', + 'model_id': None, + 'name': 'LUNAR-DDEEFF', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Kitchen', + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/acaia/test_button.py b/tests/components/acaia/test_button.py new file mode 100644 index 0000000000000..62eb8b61b8a4a --- /dev/null +++ b/tests/components/acaia/test_button.py @@ -0,0 +1,83 @@ +"""Tests for the acaia buttons.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import async_fire_time_changed + +pytestmark = pytest.mark.usefixtures("init_integration") + + +BUTTONS = ( + "tare", + "reset_timer", + "start_stop_timer", +) + + +async def test_buttons( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the acaia buttons.""" + for button in BUTTONS: + state = hass.states.get(f"button.lunar_ddeeff_{button}") + assert state + assert state == snapshot(name=f"state_button_{button}") + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot(name=f"entry_button_{button}") + + +async def test_button_presses( + hass: HomeAssistant, + mock_scale: MagicMock, +) -> None: + """Test the acaia button presses.""" + + for button in BUTTONS: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: f"button.lunar_ddeeff_{button}", + }, + blocking=True, + ) + + function = getattr(mock_scale, button) + function.assert_called_once() + + +async def test_buttons_unavailable_on_disconnected_scale( + hass: HomeAssistant, + mock_scale: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the acaia buttons are unavailable when the scale is disconnected.""" + + for button in BUTTONS: + state = hass.states.get(f"button.lunar_ddeeff_{button}") + assert state + assert state.state == STATE_UNKNOWN + + mock_scale.connected = False + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for button in BUTTONS: + state = hass.states.get(f"button.lunar_ddeeff_{button}") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/acaia/test_config_flow.py b/tests/components/acaia/test_config_flow.py new file mode 100644 index 0000000000000..2bf4b1dbe8a1b --- /dev/null +++ b/tests/components/acaia/test_config_flow.py @@ -0,0 +1,242 @@ +"""Test the acaia config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice +import pytest + +from homeassistant.components.acaia.const import CONF_IS_NEW_STYLE_SCALE, DOMAIN +from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.common import MockConfigEntry + +service_info = BluetoothServiceInfo( + name="LUNAR-DDEEFF", + address="aa:bb:cc:dd:ee:ff", + rssi=-63, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", +) + + +@pytest.fixture +def mock_discovered_service_info() -> Generator[AsyncMock]: + """Override getting Bluetooth service info.""" + with patch( + "homeassistant.components.acaia.config_flow.async_discovered_service_info", + return_value=[service_info], + ) as mock_discovered_service_info: + yield mock_discovered_service_info + + +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_verify: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + user_input = { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + } + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "LUNAR-DDEEFF" + assert result2["data"] == { + **user_input, + CONF_IS_NEW_STYLE_SCALE: True, + } + + +async def test_bluetooth_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_verify: AsyncMock, +) -> None: + """Test we can discover a device.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == service_info.name + assert result2["data"] == { + CONF_ADDRESS: service_info.address, + CONF_IS_NEW_STYLE_SCALE: True, + } + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AcaiaDeviceNotFound("Error"), "device_not_found"), + (AcaiaError, "unknown"), + (AcaiaUnknownDevice, "unsupported_device"), + ], +) +async def test_bluetooth_discovery_errors( + hass: HomeAssistant, + mock_verify: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test abortions of Bluetooth discovery.""" + mock_verify.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error + + +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_verify: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Ensure we can't add the same device twice.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_already_configured_bluetooth_discovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure configure device is not discovered again.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AcaiaDeviceNotFound("Error"), "device_not_found"), + (AcaiaError, "unknown"), + ], +) +async def test_recoverable_config_flow_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_verify: AsyncMock, + mock_discovered_service_info: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test recoverable errors.""" + mock_verify.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": error} + + # recover + mock_verify.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + + +async def test_unsupported_device( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_verify: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test flow aborts on unsupported device.""" + mock_verify.side_effect = AcaiaUnknownDevice + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unsupported_device" + + +async def test_no_bluetooth_devices( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test flow aborts on unsupported device.""" + mock_discovered_service_info.return_value = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" diff --git a/tests/components/acaia/test_init.py b/tests/components/acaia/test_init.py new file mode 100644 index 0000000000000..8ad988d3b9be0 --- /dev/null +++ b/tests/components/acaia/test_init.py @@ -0,0 +1,65 @@ +"""Test init of acaia integration.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.acaia.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry, async_fire_time_changed + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "exception", [AcaiaError, AcaiaDeviceNotFound("Boom"), TimeoutError] +) +async def test_update_exception_leads_to_active_disconnect( + hass: HomeAssistant, + mock_scale: MagicMock, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test scale gets disconnected on exception.""" + + mock_scale.connect.side_effect = exception + mock_scale.connected = False + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_scale.device_disconnected_handler.assert_called_once() + + +async def test_device( + mock_scale: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot the device from registry.""" + + device = device_registry.async_get_device({(DOMAIN, mock_scale.mac)}) + assert device + assert device == snapshot From 3d84e35268e4024604f7a55acc15ef091788f228 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Thu, 14 Nov 2024 14:27:19 +0100 Subject: [PATCH 0443/1070] Move lcn non-config_entry related code to async_setup (#130603) * Move non-config_entry related code to async_setup * Remove action unload --- homeassistant/components/lcn/__init__.py | 32 +++++++++++------------- homeassistant/components/lcn/services.py | 8 ++++++ 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 27f911822b552..eb26ef48e4efc 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -20,7 +20,8 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType from .const import ( ADD_ENTITIES_CALLBACKS, @@ -41,15 +42,26 @@ register_lcn_address_devices, register_lcn_host_device, ) -from .services import SERVICES +from .services import register_services from .websocket import register_panel_and_ws_api _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the LCN component.""" + hass.data.setdefault(DOMAIN, {}) + + await register_services(hass) + await register_panel_and_ws_api(hass) + + return True + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up a connection to PCHK host from a config entry.""" - hass.data.setdefault(DOMAIN, {}) if config_entry.entry_id in hass.data[DOMAIN]: return False @@ -109,15 +121,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) lcn_connection.register_for_inputs(input_received) - # register service calls - for service_name, service in SERVICES: - if not hass.services.has_service(DOMAIN, service_name): - hass.services.async_register( - DOMAIN, service_name, service(hass).async_call_service, service.schema - ) - - await register_panel_and_ws_api(hass) - return True @@ -168,11 +171,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> host = hass.data[DOMAIN].pop(config_entry.entry_id) await host[CONNECTION].async_close() - # unregister service calls - if unload_ok and not hass.data[DOMAIN]: # check if this is the last entry to unload - for service_name, _ in SERVICES: - hass.services.async_remove(DOMAIN, service_name) - return unload_ok diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 611a7353bcdd5..92f5863c47eb0 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -429,3 +429,11 @@ class LcnService(StrEnum): (LcnService.DYN_TEXT, DynText), (LcnService.PCK, Pck), ) + + +async def register_services(hass: HomeAssistant) -> None: + """Register services for LCN.""" + for service_name, service in SERVICES: + hass.services.async_register( + DOMAIN, service_name, service(hass).async_call_service, service.schema + ) From 01332a542cbcc01ff8cfd4ae1bff6b8f4d4c01fe Mon Sep 17 00:00:00 2001 From: Thibaut Date: Thu, 14 Nov 2024 15:23:55 +0100 Subject: [PATCH 0444/1070] Removing myself from template codeowners (#130617) * Removing myself as codeowners * Fix --------- Co-authored-by: Joostlek --- CODEOWNERS | 4 ++-- homeassistant/components/template/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 8fd34a357c0db..e204463695ea9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1489,8 +1489,8 @@ build.json @home-assistant/supervisor /tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike -/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core -/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core +/homeassistant/components/template/ @PhracturedBlue @home-assistant/core +/tests/components/template/ @PhracturedBlue @home-assistant/core /homeassistant/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77 /homeassistant/components/tesla_wall_connector/ @einarhauks diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json index 57188aebaa3f7..f1225f74f0667 100644 --- a/homeassistant/components/template/manifest.json +++ b/homeassistant/components/template/manifest.json @@ -2,7 +2,7 @@ "domain": "template", "name": "Template", "after_dependencies": ["group"], - "codeowners": ["@PhracturedBlue", "@tetienne", "@home-assistant/core"], + "codeowners": ["@PhracturedBlue", "@home-assistant/core"], "config_flow": true, "dependencies": ["blueprint"], "documentation": "https://www.home-assistant.io/integrations/template", From 61d0de3042dccf94332440e406ff27532e7e6163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 14 Nov 2024 15:27:10 +0100 Subject: [PATCH 0445/1070] Bump aioairzone to 0.9.6 (#130559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update aioairzone to v0.9.6 Signed-off-by: Álvaro Fernández Rojas * Remove _async_migrator_mac_empty and improve tests Signed-off-by: Álvaro Fernández Rojas * Remove WebServer empty mac fixes as requested by @epenet Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 10fb20bb2ce9f..6bf374087a6ca 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.5"] + "requirements": ["aioairzone==0.9.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index cdba146d251a4..65ef5f1ebf24c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairq==0.3.2 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.5 +aioairzone==0.9.6 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39fb7f17d8051..b61e65f3c68eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.3.2 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.5 +aioairzone==0.9.6 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 0c44c632d47242cf5c9dacd7cf992e73114384c4 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Thu, 14 Nov 2024 15:38:38 +0100 Subject: [PATCH 0446/1070] Add number platform to eq3btsmart (#130429) --- .../components/eq3btsmart/__init__.py | 1 + homeassistant/components/eq3btsmart/const.py | 7 + .../components/eq3btsmart/icons.json | 17 ++ homeassistant/components/eq3btsmart/models.py | 3 - homeassistant/components/eq3btsmart/number.py | 158 ++++++++++++++++++ .../components/eq3btsmart/strings.json | 17 ++ 6 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/eq3btsmart/number.py diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index 86c555ec15119..84b27161edd34 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -21,6 +21,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.NUMBER, Platform.SWITCH, ] diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py index 64bc1cf497c44..78292940e60ea 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -24,6 +24,11 @@ ENTITY_KEY_LOCK = "lock" ENTITY_KEY_BOOST = "boost" ENTITY_KEY_AWAY = "away" +ENTITY_KEY_COMFORT = "comfort" +ENTITY_KEY_ECO = "eco" +ENTITY_KEY_OFFSET = "offset" +ENTITY_KEY_WINDOW_OPEN_TEMPERATURE = "window_open_temperature" +ENTITY_KEY_WINDOW_OPEN_TIMEOUT = "window_open_timeout" GET_DEVICE_TIMEOUT = 5 # seconds @@ -77,3 +82,5 @@ class TargetTemperatureSelector(str, Enum): SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected" SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected" + +EQ3BT_STEP = 0.5 diff --git a/homeassistant/components/eq3btsmart/icons.json b/homeassistant/components/eq3btsmart/icons.json index fb0862f14bcdb..e6eb7532f37c6 100644 --- a/homeassistant/components/eq3btsmart/icons.json +++ b/homeassistant/components/eq3btsmart/icons.json @@ -8,6 +8,23 @@ } } }, + "number": { + "comfort": { + "default": "mdi:sun-thermometer" + }, + "eco": { + "default": "mdi:snowflake-thermometer" + }, + "offset": { + "default": "mdi:thermometer-plus" + }, + "window_open_temperature": { + "default": "mdi:window-open-variant" + }, + "window_open_timeout": { + "default": "mdi:timer-refresh" + } + }, "switch": { "away": { "default": "mdi:home-account", diff --git a/homeassistant/components/eq3btsmart/models.py b/homeassistant/components/eq3btsmart/models.py index 8ea0955dbdd3d..858465effa8d3 100644 --- a/homeassistant/components/eq3btsmart/models.py +++ b/homeassistant/components/eq3btsmart/models.py @@ -2,7 +2,6 @@ from dataclasses import dataclass -from eq3btsmart.const import DEFAULT_AWAY_HOURS, DEFAULT_AWAY_TEMP from eq3btsmart.thermostat import Thermostat from .const import ( @@ -23,8 +22,6 @@ class Eq3Config: target_temp_selector: TargetTemperatureSelector = DEFAULT_TARGET_TEMP_SELECTOR external_temp_sensor: str = "" scan_interval: int = DEFAULT_SCAN_INTERVAL - default_away_hours: float = DEFAULT_AWAY_HOURS - default_away_temperature: float = DEFAULT_AWAY_TEMP @dataclass(slots=True) diff --git a/homeassistant/components/eq3btsmart/number.py b/homeassistant/components/eq3btsmart/number.py new file mode 100644 index 0000000000000..2e069180fa3ae --- /dev/null +++ b/homeassistant/components/eq3btsmart/number.py @@ -0,0 +1,158 @@ +"""Platform for eq3 number entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from eq3btsmart import Thermostat +from eq3btsmart.const import ( + EQ3BT_MAX_OFFSET, + EQ3BT_MAX_TEMP, + EQ3BT_MIN_OFFSET, + EQ3BT_MIN_TEMP, +) +from eq3btsmart.models import Presets + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Eq3ConfigEntry +from .const import ( + ENTITY_KEY_COMFORT, + ENTITY_KEY_ECO, + ENTITY_KEY_OFFSET, + ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, + ENTITY_KEY_WINDOW_OPEN_TIMEOUT, + EQ3BT_STEP, +) +from .entity import Eq3Entity + + +@dataclass(frozen=True, kw_only=True) +class Eq3NumberEntityDescription(NumberEntityDescription): + """Entity description for eq3 number entities.""" + + value_func: Callable[[Presets], float] + value_set_func: Callable[ + [Thermostat], + Callable[[float], Awaitable[None]], + ] + mode: NumberMode = NumberMode.BOX + entity_category: EntityCategory | None = EntityCategory.CONFIG + + +NUMBER_ENTITY_DESCRIPTIONS = [ + Eq3NumberEntityDescription( + key=ENTITY_KEY_COMFORT, + value_func=lambda presets: presets.comfort_temperature.value, + value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature, + translation_key=ENTITY_KEY_COMFORT, + native_min_value=EQ3BT_MIN_TEMP, + native_max_value=EQ3BT_MAX_TEMP, + native_step=EQ3BT_STEP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + ), + Eq3NumberEntityDescription( + key=ENTITY_KEY_ECO, + value_func=lambda presets: presets.eco_temperature.value, + value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature, + translation_key=ENTITY_KEY_ECO, + native_min_value=EQ3BT_MIN_TEMP, + native_max_value=EQ3BT_MAX_TEMP, + native_step=EQ3BT_STEP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + ), + Eq3NumberEntityDescription( + key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, + value_func=lambda presets: presets.window_open_temperature.value, + value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature, + translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, + native_min_value=EQ3BT_MIN_TEMP, + native_max_value=EQ3BT_MAX_TEMP, + native_step=EQ3BT_STEP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + ), + Eq3NumberEntityDescription( + key=ENTITY_KEY_OFFSET, + value_func=lambda presets: presets.offset_temperature.value, + value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset, + translation_key=ENTITY_KEY_OFFSET, + native_min_value=EQ3BT_MIN_OFFSET, + native_max_value=EQ3BT_MAX_OFFSET, + native_step=EQ3BT_STEP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + ), + Eq3NumberEntityDescription( + key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT, + value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration, + value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60, + translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT, + native_min_value=0, + native_max_value=60, + native_step=5, + native_unit_of_measurement=UnitOfTime.MINUTES, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: Eq3ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the entry.""" + + async_add_entities( + Eq3NumberEntity(entry, entity_description) + for entity_description in NUMBER_ENTITY_DESCRIPTIONS + ) + + +class Eq3NumberEntity(Eq3Entity, NumberEntity): + """Base class for all eq3 number entities.""" + + entity_description: Eq3NumberEntityDescription + + def __init__( + self, entry: Eq3ConfigEntry, entity_description: Eq3NumberEntityDescription + ) -> None: + """Initialize the entity.""" + + super().__init__(entry, entity_description.key) + self.entity_description = entity_description + + @property + def native_value(self) -> float: + """Return the state of the entity.""" + + if TYPE_CHECKING: + assert self._thermostat.status is not None + assert self._thermostat.status.presets is not None + + return self.entity_description.value_func(self._thermostat.status.presets) + + async def async_set_native_value(self, value: float) -> None: + """Set the state of the entity.""" + + await self.entity_description.value_set_func(self._thermostat)(value) + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + + return ( + self._thermostat.status is not None + and self._thermostat.status.presets is not None + and self._attr_available + ) diff --git a/homeassistant/components/eq3btsmart/strings.json b/homeassistant/components/eq3btsmart/strings.json index 03c3b21b964fc..acfd5082f4591 100644 --- a/homeassistant/components/eq3btsmart/strings.json +++ b/homeassistant/components/eq3btsmart/strings.json @@ -25,6 +25,23 @@ "name": "Daylight saving time" } }, + "number": { + "comfort": { + "name": "Comfort temperature" + }, + "eco": { + "name": "Eco temperature" + }, + "offset": { + "name": "Offset temperature" + }, + "window_open_temperature": { + "name": "Window open temperature" + }, + "window_open_timeout": { + "name": "Window open timeout" + } + }, "switch": { "lock": { "name": "Lock" From 472414a8d6bd231ce9f5c661248a2fdfd97eabb1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Nov 2024 16:17:08 +0100 Subject: [PATCH 0447/1070] Add missing translation string to smarty (#130624) --- homeassistant/components/smarty/strings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/smarty/strings.json b/homeassistant/components/smarty/strings.json index 188459b4f1630..341a300a26e57 100644 --- a/homeassistant/components/smarty/strings.json +++ b/homeassistant/components/smarty/strings.json @@ -28,6 +28,10 @@ "deprecated_yaml_import_issue_auth_error": { "title": "YAML import failed due to an authentication error", "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "YAML import failed due to a connection error", + "description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." } }, "entity": { From c7ee7dc880a0952dcc8b447f70747980bbb56f88 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Nov 2024 16:26:05 +0100 Subject: [PATCH 0448/1070] Refactor translation checks (#130585) * Refactor translation checks * Adjust * Improve * Restore await * Delay pytest.fail until the end of the test --- tests/components/conftest.py | 155 ++++++++++++++++++++--------------- 1 file changed, 91 insertions(+), 64 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 5535ec3b97682..363d39a2e6382 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -26,7 +26,12 @@ ) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowHandler, FlowManager, FlowResultType +from homeassistant.data_entry_flow import ( + FlowContext, + FlowHandler, + FlowManager, + FlowResultType, +) from homeassistant.helpers.translation import async_get_translations if TYPE_CHECKING: @@ -557,12 +562,12 @@ def _validate_translation_placeholders( description_placeholders is None or placeholder not in description_placeholders ): - pytest.fail( + ignore_translations[full_key] = ( f"Description not found for placeholder `{placeholder}` in {full_key}" ) -async def _ensure_translation_exists( +async def _validate_translation( hass: HomeAssistant, ignore_translations: dict[str, StoreInfo], category: str, @@ -588,7 +593,7 @@ async def _ensure_translation_exists( ignore_translations[full_key] = "used" return - pytest.fail( + ignore_translations[full_key] = ( f"Translation not found for {component}: `{category}.{key}`. " f"Please add to homeassistant/components/{component}/strings.json" ) @@ -604,84 +609,106 @@ def ignore_translations() -> str | list[str]: return [] +async def _check_config_flow_result_translations( + manager: FlowManager, + flow: FlowHandler, + result: FlowResult[FlowContext, str], + ignore_translations: dict[str, str], +) -> None: + if isinstance(manager, ConfigEntriesFlowManager): + category = "config" + integration = flow.handler + elif isinstance(manager, OptionsFlowManager): + category = "options" + integration = flow.hass.config_entries.async_get_entry(flow.handler).domain + else: + return + + # Check if this flow has been seen before + # Gets set to False on first run, and to True on subsequent runs + setattr(flow, "__flow_seen_before", hasattr(flow, "__flow_seen_before")) + + if result["type"] is FlowResultType.FORM: + if step_id := result.get("step_id"): + # neither title nor description are required + # - title defaults to integration name + # - description is optional + for header in ("title", "description"): + await _validate_translation( + flow.hass, + ignore_translations, + category, + integration, + f"step.{step_id}.{header}", + result["description_placeholders"], + translation_required=False, + ) + if errors := result.get("errors"): + for error in errors.values(): + await _validate_translation( + flow.hass, + ignore_translations, + category, + integration, + f"error.{error}", + result["description_placeholders"], + ) + return + + if result["type"] is FlowResultType.ABORT: + # We don't need translations for a discovery flow which immediately + # aborts, since such flows won't be seen by users + if not flow.__flow_seen_before and flow.source in DISCOVERY_SOURCES: + return + await _validate_translation( + flow.hass, + ignore_translations, + category, + integration, + f"abort.{result["reason"]}", + result["description_placeholders"], + ) + + @pytest.fixture(autouse=True) -def check_config_translations(ignore_translations: str | list[str]) -> Generator[None]: - """Ensure config_flow translations are available.""" +def check_translations(ignore_translations: str | list[str]) -> Generator[None]: + """Check that translation requirements are met. + + Current checks: + - data entry flow results (ConfigFlow/OptionsFlow) + """ if not isinstance(ignore_translations, list): ignore_translations = [ignore_translations] _ignore_translations = {k: "unused" for k in ignore_translations} - _original = FlowManager._async_handle_step - async def _async_handle_step( + # Keep reference to original functions + _original_flow_manager_async_handle_step = FlowManager._async_handle_step + + # Prepare override functions + async def _flow_manager_async_handle_step( self: FlowManager, flow: FlowHandler, *args ) -> FlowResult: - result = await _original(self, flow, *args) - if isinstance(self, ConfigEntriesFlowManager): - category = "config" - component = flow.handler - elif isinstance(self, OptionsFlowManager): - category = "options" - component = flow.hass.config_entries.async_get_entry(flow.handler).domain - else: - return result - - # Check if this flow has been seen before - # Gets set to False on first run, and to True on subsequent runs - setattr(flow, "__flow_seen_before", hasattr(flow, "__flow_seen_before")) - - if result["type"] is FlowResultType.FORM: - if step_id := result.get("step_id"): - # neither title nor description are required - # - title defaults to integration name - # - description is optional - for header in ("title", "description"): - await _ensure_translation_exists( - flow.hass, - _ignore_translations, - category, - component, - f"step.{step_id}.{header}", - result["description_placeholders"], - translation_required=False, - ) - if errors := result.get("errors"): - for error in errors.values(): - await _ensure_translation_exists( - flow.hass, - _ignore_translations, - category, - component, - f"error.{error}", - result["description_placeholders"], - ) - return result - - if result["type"] is FlowResultType.ABORT: - # We don't need translations for a discovery flow which immediately - # aborts, since such flows won't be seen by users - if not flow.__flow_seen_before and flow.source in DISCOVERY_SOURCES: - return result - await _ensure_translation_exists( - flow.hass, - _ignore_translations, - category, - component, - f"abort.{result["reason"]}", - result["description_placeholders"], - ) - + result = await _original_flow_manager_async_handle_step(self, flow, *args) + await _check_config_flow_result_translations( + self, flow, result, _ignore_translations + ) return result + # Use override functions with patch( "homeassistant.data_entry_flow.FlowManager._async_handle_step", - _async_handle_step, + _flow_manager_async_handle_step, ): yield + # Run final checks unused_ignore = [k for k, v in _ignore_translations.items() if v == "unused"] if unused_ignore: pytest.fail( f"Unused ignore translations: {', '.join(unused_ignore)}. " "Please remove them from the ignore_translations fixture." ) + for description in _ignore_translations.values(): + if description not in {"used", "unused"}: + pytest.fail(description) From cd1272008507c7cb82155a8d7509c95067290774 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 14 Nov 2024 16:31:33 +0100 Subject: [PATCH 0449/1070] Add Python version to issue ID (#130611) --- homeassistant/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index dcfb668562702..1034223051c7d 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -515,7 +515,7 @@ async def async_from_config_dict( issue_registry.async_create_issue( hass, core.DOMAIN, - "python_version", + f"python_version_{required_python_version}", is_fixable=False, severity=issue_registry.IssueSeverity.WARNING, breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE, From 1ce8bfdaa438949da707d94ff7b12ff7b20ce0cc Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 14 Nov 2024 16:34:17 +0100 Subject: [PATCH 0450/1070] Use test helpers for acaia buttons (#130626) --- .../acaia/snapshots/test_button.ambr | 60 +++++++++---------- tests/components/acaia/test_button.py | 33 ++++++---- 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/tests/components/acaia/snapshots/test_button.ambr b/tests/components/acaia/snapshots/test_button.ambr index 7e2624923af76..cd91ca1a17a86 100644 --- a/tests/components/acaia/snapshots/test_button.ambr +++ b/tests/components/acaia/snapshots/test_button.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_buttons[entry_button_reset_timer] +# name: test_buttons[button.lunar_ddeeff_reset_timer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -32,7 +32,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_buttons[entry_button_start_stop_timer] +# name: test_buttons[button.lunar_ddeeff_reset_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LUNAR-DDEEFF Reset timer', + }), + 'context': , + 'entity_id': 'button.lunar_ddeeff_reset_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.lunar_ddeeff_start_stop_timer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -65,7 +78,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_buttons[entry_button_tare] +# name: test_buttons[button.lunar_ddeeff_start_stop_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LUNAR-DDEEFF Start/stop timer', + }), + 'context': , + 'entity_id': 'button.lunar_ddeeff_start_stop_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.lunar_ddeeff_tare-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -98,33 +124,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_buttons[state_button_reset_timer] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'LUNAR-DDEEFF Reset timer', - }), - 'context': , - 'entity_id': 'button.lunar_ddeeff_reset_timer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[state_button_start_stop_timer] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'LUNAR-DDEEFF Start/stop timer', - }), - 'context': , - 'entity_id': 'button.lunar_ddeeff_start_stop_timer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[state_button_tare] +# name: test_buttons[button.lunar_ddeeff_tare-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'LUNAR-DDEEFF Tare', diff --git a/tests/components/acaia/test_button.py b/tests/components/acaia/test_button.py index 62eb8b61b8a4a..f68f85e253deb 100644 --- a/tests/components/acaia/test_button.py +++ b/tests/components/acaia/test_button.py @@ -1,21 +1,24 @@ """Tests for the acaia buttons.""" from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory -import pytest from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import async_fire_time_changed - -pytestmark = pytest.mark.usefixtures("init_integration") +from . import setup_integration +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform BUTTONS = ( "tare", @@ -28,24 +31,25 @@ async def test_buttons( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_scale: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test the acaia buttons.""" - for button in BUTTONS: - state = hass.states.get(f"button.lunar_ddeeff_{button}") - assert state - assert state == snapshot(name=f"state_button_{button}") - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry == snapshot(name=f"entry_button_{button}") + with patch("homeassistant.components.acaia.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_button_presses( hass: HomeAssistant, mock_scale: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test the acaia button presses.""" + await setup_integration(hass, mock_config_entry) + for button in BUTTONS: await hass.services.async_call( BUTTON_DOMAIN, @@ -63,10 +67,13 @@ async def test_button_presses( async def test_buttons_unavailable_on_disconnected_scale( hass: HomeAssistant, mock_scale: MagicMock, + mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test the acaia buttons are unavailable when the scale is disconnected.""" + await setup_integration(hass, mock_config_entry) + for button in BUTTONS: state = hass.states.get(f"button.lunar_ddeeff_{button}") assert state From bfec48cc0e9eb8ff4f84dabb7308391b56232829 Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Thu, 14 Nov 2024 10:50:34 -0500 Subject: [PATCH 0451/1070] Bump sense-energy to 0.13.4 (#130625) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index d4889c0c5f5bd..da3912a9d2581 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.13.3"] + "requirements": ["sense-energy==0.13.4"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index df2317c3a6c9a..966488b6a487e 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.3"] + "requirements": ["sense-energy==0.13.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 65ef5f1ebf24c..ea7bdefce2b9e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2632,7 +2632,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.3 +sense-energy==0.13.4 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b61e65f3c68eb..cf1e761bfa930 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2099,7 +2099,7 @@ securetar==2024.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.3 +sense-energy==0.13.4 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From df55d198c85065583bba25e499ceab2499b47911 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Nov 2024 17:25:47 +0100 Subject: [PATCH 0452/1070] Add translation checks for repair flows (#130619) * Add translation checks for repair flows * Ignore fake_integration in repairs --- tests/components/conftest.py | 19 ++++++++++++++++--- .../components/repairs/test_websocket_api.py | 4 ++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 363d39a2e6382..dcbf589982c93 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -18,6 +18,7 @@ ) import pytest +from homeassistant.components import repairs from homeassistant.config_entries import ( DISCOVERY_SOURCES, ConfigEntriesFlowManager, @@ -32,6 +33,7 @@ FlowManager, FlowResultType, ) +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.translation import async_get_translations if TYPE_CHECKING: @@ -615,12 +617,23 @@ async def _check_config_flow_result_translations( result: FlowResult[FlowContext, str], ignore_translations: dict[str, str], ) -> None: + if result["type"] is FlowResultType.CREATE_ENTRY: + # No need to check translations for a completed flow + return + + key_prefix = "" if isinstance(manager, ConfigEntriesFlowManager): category = "config" integration = flow.handler elif isinstance(manager, OptionsFlowManager): category = "options" integration = flow.hass.config_entries.async_get_entry(flow.handler).domain + elif isinstance(manager, repairs.RepairsFlowManager): + category = "issues" + integration = flow.handler + issue_id = flow.issue_id + issue = ir.async_get(flow.hass).async_get_issue(integration, issue_id) + key_prefix = f"{issue.translation_key}.fix_flow." else: return @@ -639,7 +652,7 @@ async def _check_config_flow_result_translations( ignore_translations, category, integration, - f"step.{step_id}.{header}", + f"{key_prefix}step.{step_id}.{header}", result["description_placeholders"], translation_required=False, ) @@ -650,7 +663,7 @@ async def _check_config_flow_result_translations( ignore_translations, category, integration, - f"error.{error}", + f"{key_prefix}error.{error}", result["description_placeholders"], ) return @@ -665,7 +678,7 @@ async def _check_config_flow_result_translations( ignore_translations, category, integration, - f"abort.{result["reason"]}", + f"{key_prefix}abort.{result["reason"]}", result["description_placeholders"], ) diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index bb3d50f9eb536..b23977842c60b 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -533,6 +533,10 @@ async def test_list_issues( } +@pytest.mark.parametrize( + "ignore_translations", + ["component.fake_integration.issues.abc_123.fix_flow.abort.not_given"], +) async def test_fix_issue_aborted( hass: HomeAssistant, hass_client: ClientSessionGenerator, From 5fa9a945d901d40f63aece6321788c1ccd3afcdf Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 14 Nov 2024 10:50:50 -0600 Subject: [PATCH 0453/1070] Handle sentence triggers and local intents before pipeline agent (#129058) * Handle sentence triggers and registered intents in Assist LLM API * Remove from LLM * Check sentence triggers and local intents first * Fix type * Fix type again * Use pipeline language * Fix cloud test * Clean up and fix translation key * Refactor async_recognize --- .../components/assist_pipeline/pipeline.py | 58 ++++++- .../components/cloud/assist_pipeline.py | 3 +- .../components/conversation/__init__.py | 28 ++- .../components/conversation/default_agent.py | 163 ++++++++++++------ homeassistant/components/conversation/http.py | 60 +++---- .../assist_pipeline/snapshots/test_init.ambr | 4 +- tests/components/assist_pipeline/test_init.py | 154 ++++++++++++++++- .../assist_pipeline/test_pipeline.py | 2 + .../assist_pipeline/test_websocket.py | 10 ++ tests/components/cloud/__init__.py | 3 + tests/components/conversation/test_http.py | 6 +- tests/components/conversation/test_init.py | 99 ++++++++++- 12 files changed, 492 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a55e23ae05189..d90424d52d329 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -31,6 +31,7 @@ ) from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent from homeassistant.helpers.collection import ( CHANGE_UPDATED, CollectionError, @@ -109,6 +110,7 @@ def validate_language(data: dict[str, Any]) -> Any: vol.Required("tts_voice"): vol.Any(str, None), vol.Required("wake_word_entity"): vol.Any(str, None), vol.Required("wake_word_id"): vol.Any(str, None), + vol.Optional("prefer_local_intents"): bool, } STORED_PIPELINE_RUNS = 10 @@ -322,6 +324,7 @@ async def async_update_pipeline( tts_voice: str | None | UndefinedType = UNDEFINED, wake_word_entity: str | None | UndefinedType = UNDEFINED, wake_word_id: str | None | UndefinedType = UNDEFINED, + prefer_local_intents: bool | UndefinedType = UNDEFINED, ) -> None: """Update a pipeline.""" pipeline_data: PipelineData = hass.data[DOMAIN] @@ -345,6 +348,7 @@ async def async_update_pipeline( ("tts_voice", tts_voice), ("wake_word_entity", wake_word_entity), ("wake_word_id", wake_word_id), + ("prefer_local_intents", prefer_local_intents), ) if val is not UNDEFINED } @@ -398,6 +402,7 @@ class Pipeline: tts_voice: str | None wake_word_entity: str | None wake_word_id: str | None + prefer_local_intents: bool = False id: str = field(default_factory=ulid_util.ulid_now) @@ -421,6 +426,7 @@ def from_json(cls, data: dict[str, Any]) -> Pipeline: tts_voice=data["tts_voice"], wake_word_entity=data["wake_word_entity"], wake_word_id=data["wake_word_id"], + prefer_local_intents=data.get("prefer_local_intents", False), ) def to_json(self) -> dict[str, Any]: @@ -438,6 +444,7 @@ def to_json(self) -> dict[str, Any]: "tts_voice": self.tts_voice, "wake_word_entity": self.wake_word_entity, "wake_word_id": self.wake_word_id, + "prefer_local_intents": self.prefer_local_intents, } @@ -1016,15 +1023,58 @@ async def recognize_intent( ) try: - conversation_result = await conversation.async_converse( - hass=self.hass, + user_input = conversation.ConversationInput( text=intent_input, + context=self.context, conversation_id=conversation_id, device_id=device_id, - context=self.context, - language=self.pipeline.conversation_language, + language=self.pipeline.language, agent_id=self.intent_agent, ) + + # Sentence triggers override conversation agent + if ( + trigger_response_text + := await conversation.async_handle_sentence_triggers( + self.hass, user_input + ) + ): + # Sentence trigger matched + trigger_response = intent.IntentResponse( + self.pipeline.conversation_language + ) + trigger_response.async_set_speech(trigger_response_text) + conversation_result = conversation.ConversationResult( + response=trigger_response, + conversation_id=user_input.conversation_id, + ) + # Try local intents first, if preferred. + # Skip this step if the default agent is already used. + elif ( + self.pipeline.prefer_local_intents + and (user_input.agent_id != conversation.HOME_ASSISTANT_AGENT) + and ( + intent_response := await conversation.async_handle_intents( + self.hass, user_input + ) + ) + ): + # Local intent matched + conversation_result = conversation.ConversationResult( + response=intent_response, + conversation_id=user_input.conversation_id, + ) + else: + # Fall back to pipeline conversation agent + conversation_result = await conversation.async_converse( + hass=self.hass, + text=user_input.text, + conversation_id=user_input.conversation_id, + device_id=user_input.device_id, + context=user_input.context, + language=user_input.language, + agent_id=user_input.agent_id, + ) except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") raise IntentRecognitionError( diff --git a/homeassistant/components/cloud/assist_pipeline.py b/homeassistant/components/cloud/assist_pipeline.py index f3a591d6edae8..c97e5bdc0a202 100644 --- a/homeassistant/components/cloud/assist_pipeline.py +++ b/homeassistant/components/cloud/assist_pipeline.py @@ -1,6 +1,7 @@ """Handle Cloud assist pipelines.""" import asyncio +from typing import Any from homeassistant.components.assist_pipeline import ( async_create_default_pipeline, @@ -98,7 +99,7 @@ async def async_migrate_cloud_pipeline_engine( # is an after dependency of cloud await async_setup_pipeline_store(hass) - kwargs: dict[str, str] = {pipeline_attribute: engine_id} + kwargs: dict[str, Any] = {pipeline_attribute: engine_id} pipelines = async_get_pipelines(hass) for pipeline in pipelines: if getattr(pipeline, pipeline_attribute) == DOMAIN: diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 17f3b6f5ccc0a..898b7b2cf4fab 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -44,7 +44,7 @@ SERVICE_RELOAD, ConversationEntityFeature, ) -from .default_agent import async_setup_default_agent +from .default_agent import DefaultAgent, async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult @@ -207,6 +207,32 @@ async def async_prepare_agent( await agent.async_prepare(language) +async def async_handle_sentence_triggers( + hass: HomeAssistant, user_input: ConversationInput +) -> str | None: + """Try to match input against sentence triggers and return response text. + + Returns None if no match occurred. + """ + default_agent = async_get_agent(hass) + assert isinstance(default_agent, DefaultAgent) + + return await default_agent.async_handle_sentence_triggers(user_input) + + +async def async_handle_intents( + hass: HomeAssistant, user_input: ConversationInput +) -> intent.IntentResponse | None: + """Try to match input against registered intents and return response. + + Returns None if no match occurred. + """ + default_agent = async_get_agent(hass) + assert isinstance(default_agent, DefaultAgent) + + return await default_agent.async_handle_intents(user_input) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the process service.""" entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 4838d19537a1b..c6d394a136686 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -213,13 +213,10 @@ def _listen_clear_slot_list(self) -> None: async_listen_entity_updates(self.hass, DOMAIN, self._async_clear_slot_list), ] - async def async_recognize( - self, user_input: ConversationInput - ) -> RecognizeResult | SentenceTriggerResult | None: + async def async_recognize_intent( + self, user_input: ConversationInput, strict_intents_only: bool = False + ) -> RecognizeResult | None: """Recognize intent from user input.""" - if trigger_result := await self._match_triggers(user_input.text): - return trigger_result - language = user_input.language or self.hass.config.language lang_intents = await self.async_get_or_load_intents(language) @@ -240,6 +237,7 @@ async def async_recognize( slot_lists, intent_context, language, + strict_intents_only, ) _LOGGER.debug( @@ -251,56 +249,36 @@ async def async_recognize( async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" - language = user_input.language or self.hass.config.language - conversation_id = None # Not supported - - result = await self.async_recognize(user_input) # Check if a trigger matched - if isinstance(result, SentenceTriggerResult): - # Gather callback responses in parallel - trigger_callbacks = [ - self._trigger_sentences[trigger_id].callback( - result.sentence, trigger_result, user_input.device_id - ) - for trigger_id, trigger_result in result.matched_triggers.items() - ] - - # Use first non-empty result as response. - # - # There may be multiple copies of a trigger running when editing in - # the UI, so it's critical that we filter out empty responses here. - response_text: str | None = None - response_set_by_trigger = False - for trigger_future in asyncio.as_completed(trigger_callbacks): - trigger_response = await trigger_future - if trigger_response is None: - continue - - response_text = trigger_response - response_set_by_trigger = True - break + if trigger_result := await self.async_recognize_sentence_trigger(user_input): + # Process callbacks and get response + response_text = await self._handle_trigger_result( + trigger_result, user_input + ) # Convert to conversation result - response = intent.IntentResponse(language=language) + response = intent.IntentResponse( + language=user_input.language or self.hass.config.language + ) response.response_type = intent.IntentResponseType.ACTION_DONE - - if response_set_by_trigger: - # Response was explicitly set to empty - response_text = response_text or "" - elif not response_text: - # Use translated acknowledgment for pipeline language - translations = await translation.async_get_translations( - self.hass, language, DOMAIN, [DOMAIN] - ) - response_text = translations.get( - f"component.{DOMAIN}.conversation.agent.done", "Done" - ) - response.async_set_speech(response_text) return ConversationResult(response=response) + # Match intents + intent_result = await self.async_recognize_intent(user_input) + return await self._async_process_intent_result(intent_result, user_input) + + async def _async_process_intent_result( + self, + result: RecognizeResult | None, + user_input: ConversationInput, + ) -> ConversationResult: + """Process user input with intents.""" + language = user_input.language or self.hass.config.language + conversation_id = None # Not supported + # Intent match or failure lang_intents = await self.async_get_or_load_intents(language) @@ -436,6 +414,7 @@ def _recognize( slot_lists: dict[str, SlotList], intent_context: dict[str, Any] | None, language: str, + strict_intents_only: bool, ) -> RecognizeResult | None: """Search intents for a match to user input.""" strict_result = self._recognize_strict( @@ -446,6 +425,9 @@ def _recognize( # Successful strict match return strict_result + if strict_intents_only: + return None + # Try again with all entities (including unexposed) entity_registry = er.async_get(self.hass) all_entity_names: list[tuple[str, str, dict[str, Any]]] = [] @@ -1056,7 +1038,9 @@ def _unregister_trigger(self, trigger_data: TriggerData) -> None: # Force rebuild on next use self._trigger_intents = None - async def _match_triggers(self, sentence: str) -> SentenceTriggerResult | None: + async def async_recognize_sentence_trigger( + self, user_input: ConversationInput + ) -> SentenceTriggerResult | None: """Try to match sentence against registered trigger sentences. Calls the registered callbacks if there's a match and returns a sentence @@ -1074,7 +1058,7 @@ async def _match_triggers(self, sentence: str) -> SentenceTriggerResult | None: matched_triggers: dict[int, RecognizeResult] = {} matched_template: str | None = None - for result in recognize_all(sentence, self._trigger_intents): + for result in recognize_all(user_input.text, self._trigger_intents): if result.intent_sentence is not None: matched_template = result.intent_sentence.text @@ -1091,12 +1075,88 @@ async def _match_triggers(self, sentence: str) -> SentenceTriggerResult | None: _LOGGER.debug( "'%s' matched %s trigger(s): %s", - sentence, + user_input.text, len(matched_triggers), list(matched_triggers), ) - return SentenceTriggerResult(sentence, matched_template, matched_triggers) + return SentenceTriggerResult( + user_input.text, matched_template, matched_triggers + ) + + async def _handle_trigger_result( + self, result: SentenceTriggerResult, user_input: ConversationInput + ) -> str: + """Run sentence trigger callbacks and return response text.""" + + # Gather callback responses in parallel + trigger_callbacks = [ + self._trigger_sentences[trigger_id].callback( + user_input.text, trigger_result, user_input.device_id + ) + for trigger_id, trigger_result in result.matched_triggers.items() + ] + + # Use first non-empty result as response. + # + # There may be multiple copies of a trigger running when editing in + # the UI, so it's critical that we filter out empty responses here. + response_text = "" + response_set_by_trigger = False + for trigger_future in asyncio.as_completed(trigger_callbacks): + trigger_response = await trigger_future + if trigger_response is None: + continue + + response_text = trigger_response + response_set_by_trigger = True + break + + if response_set_by_trigger: + # Response was explicitly set to empty + response_text = response_text or "" + elif not response_text: + # Use translated acknowledgment for pipeline language + language = user_input.language or self.hass.config.language + translations = await translation.async_get_translations( + self.hass, language, DOMAIN, [DOMAIN] + ) + response_text = translations.get( + f"component.{DOMAIN}.conversation.agent.done", "Done" + ) + + return response_text + + async def async_handle_sentence_triggers( + self, user_input: ConversationInput + ) -> str | None: + """Try to input sentence against sentence triggers and return response text. + + Returns None if no match occurred. + """ + if trigger_result := await self.async_recognize_sentence_trigger(user_input): + return await self._handle_trigger_result(trigger_result, user_input) + + return None + + async def async_handle_intents( + self, + user_input: ConversationInput, + ) -> intent.IntentResponse | None: + """Try to match sentence against registered intents and return response. + + Only performs strict matching with exposed entities and exact wording. + Returns None if no match occurred. + """ + result = await self.async_recognize_intent(user_input, strict_intents_only=True) + if not isinstance(result, RecognizeResult): + # No error message on failed match + return None + + conversation_result = await self._async_process_intent_result( + result, user_input + ) + return conversation_result.response def _make_error_result( @@ -1108,7 +1168,6 @@ def _make_error_result( """Create conversation result with error code and text.""" response = intent.IntentResponse(language=language) response.async_set_error(error_code, response_text) - return ConversationResult(response, conversation_id) diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 5e5800ad6f18f..ebc5d70f1efef 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -24,11 +24,7 @@ get_agent_manager, ) from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY -from .default_agent import ( - METADATA_CUSTOM_FILE, - METADATA_CUSTOM_SENTENCE, - SentenceTriggerResult, -) +from .default_agent import METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE, DefaultAgent from .entity import ConversationEntity from .models import ConversationInput @@ -167,44 +163,42 @@ async def websocket_hass_agent_debug( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Return intents that would be matched by the default agent for a list of sentences.""" - results = [ - await hass.data[DATA_DEFAULT_ENTITY].async_recognize( - ConversationInput( - text=sentence, - context=connection.context(msg), - conversation_id=None, - device_id=msg.get("device_id"), - language=msg.get("language", hass.config.language), - agent_id=None, - ) - ) - for sentence in msg["sentences"] - ] + agent = hass.data.get(DATA_DEFAULT_ENTITY) + assert isinstance(agent, DefaultAgent) # Return results for each sentence in the same order as the input. result_dicts: list[dict[str, Any] | None] = [] - for result in results: + for sentence in msg["sentences"]: + user_input = ConversationInput( + text=sentence, + context=connection.context(msg), + conversation_id=None, + device_id=msg.get("device_id"), + language=msg.get("language", hass.config.language), + agent_id=None, + ) result_dict: dict[str, Any] | None = None - if isinstance(result, SentenceTriggerResult): + + if trigger_result := await agent.async_recognize_sentence_trigger(user_input): result_dict = { # Matched a user-defined sentence trigger. # We can't provide the response here without executing the # trigger. "match": True, "source": "trigger", - "sentence_template": result.sentence_template or "", + "sentence_template": trigger_result.sentence_template or "", } - elif isinstance(result, RecognizeResult): - successful_match = not result.unmatched_entities + elif intent_result := await agent.async_recognize_intent(user_input): + successful_match = not intent_result.unmatched_entities result_dict = { # Name of the matching intent (or the closest) "intent": { - "name": result.intent.name, + "name": intent_result.intent.name, }, # Slot values that would be received by the intent "slots": { # direct access to values entity_key: entity.text or entity.value - for entity_key, entity in result.entities.items() + for entity_key, entity in intent_result.entities.items() }, # Extra slot details, such as the originally matched text "details": { @@ -213,7 +207,7 @@ async def websocket_hass_agent_debug( "value": entity.value, "text": entity.text, } - for entity_key, entity in result.entities.items() + for entity_key, entity in intent_result.entities.items() }, # Entities/areas/etc. that would be targeted "targets": {}, @@ -222,24 +216,26 @@ async def websocket_hass_agent_debug( # Text of the sentence template that matched (or was closest) "sentence_template": "", # When match is incomplete, this will contain the best slot guesses - "unmatched_slots": _get_unmatched_slots(result), + "unmatched_slots": _get_unmatched_slots(intent_result), } if successful_match: result_dict["targets"] = { state.entity_id: {"matched": is_matched} - for state, is_matched in _get_debug_targets(hass, result) + for state, is_matched in _get_debug_targets(hass, intent_result) } - if result.intent_sentence is not None: - result_dict["sentence_template"] = result.intent_sentence.text + if intent_result.intent_sentence is not None: + result_dict["sentence_template"] = intent_result.intent_sentence.text # Inspect metadata to determine if this matched a custom sentence - if result.intent_metadata and result.intent_metadata.get( + if intent_result.intent_metadata and intent_result.intent_metadata.get( METADATA_CUSTOM_SENTENCE ): result_dict["source"] = "custom" - result_dict["file"] = result.intent_metadata.get(METADATA_CUSTOM_FILE) + result_dict["file"] = intent_result.intent_metadata.get( + METADATA_CUSTOM_FILE + ) else: result_dict["source"] = "builtin" diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index e14bbac183954..7f77dada3be98 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -139,7 +139,7 @@ 'data': dict({ 'code': 'no_intent_match', }), - 'language': 'en-US', + 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ @@ -228,7 +228,7 @@ 'data': dict({ 'code': 'no_intent_match', }), - 'language': 'en-US', + 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index c4696573bade3..bdca27d527f76 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -11,13 +11,20 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import assist_pipeline, media_source, stt, tts +from homeassistant.components import ( + assist_pipeline, + conversation, + media_source, + stt, + tts, +) from homeassistant.components.assist_pipeline.const import ( BYTES_PER_CHUNK, CONF_DEBUG_RECORDING_DIR, DOMAIN, ) from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import intent from homeassistant.setup import async_setup_component from .conftest import ( @@ -927,3 +934,148 @@ async def test_tts_dict_preferred_format( assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_RATE)) == 48000 assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS)) == 2 assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_BYTES)) == 2 + + +async def test_sentence_trigger_overrides_conversation_agent( + hass: HomeAssistant, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that sentence triggers are checked before the conversation agent.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": [ + "test trigger sentence", + ], + }, + "action": { + "set_conversation_response": "test trigger response", + }, + } + }, + ) + + events: list[assist_pipeline.PipelineEvent] = [] + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test trigger sentence", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse" + ) as mock_async_converse: + await pipeline_input.execute() + + # Sentence trigger should have been handled + mock_async_converse.assert_not_called() + + # Verify sentence trigger response + intent_end_event = next( + ( + e + for e in events + if e.type == assist_pipeline.PipelineEventType.INTENT_END + ), + None, + ) + assert (intent_end_event is not None) and intent_end_event.data + assert ( + intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ + "speech" + ] + == "test trigger response" + ) + + +async def test_prefer_local_intents( + hass: HomeAssistant, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that the default agent is checked first when local intents are preferred.""" + events: list[assist_pipeline.PipelineEvent] = [] + + # Reuse custom sentences in test config + class OrderBeerIntentHandler(intent.IntentHandler): + intent_type = "OrderBeer" + + async def async_handle( + self, intent_obj: intent.Intent + ) -> intent.IntentResponse: + response = intent_obj.create_response() + response.async_set_speech("Order confirmed") + return response + + handler = OrderBeerIntentHandler() + intent.async_register(hass, handler) + + # Fake a test agent and prefer local intents + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine="test-agent", prefer_local_intents=True + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="I'd like to order a stout please", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ): + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse" + ) as mock_async_converse: + await pipeline_input.execute() + + # Test agent should not have been called + mock_async_converse.assert_not_called() + + # Verify local intent response + intent_end_event = next( + ( + e + for e in events + if e.type == assist_pipeline.PipelineEventType.INTENT_END + ), + None, + ) + assert (intent_end_event is not None) and intent_end_event.data + assert ( + intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ + "speech" + ] + == "Order confirmed" + ) diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 50d0fc9bed824..d52e2a762eea2 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -574,6 +574,7 @@ async def test_update_pipeline( "tts_voice": "test_voice", "wake_word_entity": "wake_work.test_1", "wake_word_id": "wake_word_id_1", + "prefer_local_intents": False, } await async_update_pipeline( @@ -617,6 +618,7 @@ async def test_update_pipeline( "tts_voice": "test_voice", "wake_word_entity": "wake_work.test_1", "wake_word_id": "wake_word_id_1", + "prefer_local_intents": False, } diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index e339ee74fbb8d..c9bc3ef41de36 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -974,6 +974,7 @@ async def test_add_pipeline( "tts_voice": "Arnold Schwarzenegger", "wake_word_entity": "wakeword_entity_1", "wake_word_id": "wakeword_id_1", + "prefer_local_intents": True, } ) msg = await client.receive_json() @@ -991,6 +992,7 @@ async def test_add_pipeline( "tts_voice": "Arnold Schwarzenegger", "wake_word_entity": "wakeword_entity_1", "wake_word_id": "wakeword_id_1", + "prefer_local_intents": True, } assert len(pipeline_store.data) == 2 @@ -1008,6 +1010,7 @@ async def test_add_pipeline( tts_voice="Arnold Schwarzenegger", wake_word_entity="wakeword_entity_1", wake_word_id="wakeword_id_1", + prefer_local_intents=True, ) await client.send_json_auto_id( @@ -1195,6 +1198,7 @@ async def test_get_pipeline( "tts_voice": "james_earl_jones", "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, } # Get conversation agent as pipeline @@ -1220,6 +1224,7 @@ async def test_get_pipeline( "tts_voice": "james_earl_jones", "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, } await client.send_json_auto_id( @@ -1249,6 +1254,7 @@ async def test_get_pipeline( "tts_voice": "Arnold Schwarzenegger", "wake_word_entity": "wakeword_entity_1", "wake_word_id": "wakeword_id_1", + "prefer_local_intents": False, } ) msg = await client.receive_json() @@ -1277,6 +1283,7 @@ async def test_get_pipeline( "tts_voice": "Arnold Schwarzenegger", "wake_word_entity": "wakeword_entity_1", "wake_word_id": "wakeword_id_1", + "prefer_local_intents": False, } @@ -1304,6 +1311,7 @@ async def test_list_pipelines( "tts_voice": "james_earl_jones", "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, } ], "preferred_pipeline": ANY, @@ -1395,6 +1403,7 @@ async def test_update_pipeline( "tts_voice": "new_tts_voice", "wake_word_entity": "new_wakeword_entity", "wake_word_id": "new_wakeword_id", + "prefer_local_intents": False, } assert len(pipeline_store.data) == 2 @@ -1446,6 +1455,7 @@ async def test_update_pipeline( "tts_voice": None, "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, } pipeline = pipeline_store.data[pipeline_id] diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 18f8cd4d311f6..1fb9f2b0d4052 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -35,6 +35,7 @@ "tts_voice": "Arnold Schwarzenegger", "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, }, { "conversation_engine": "conversation_engine_2", @@ -49,6 +50,7 @@ "tts_voice": "The Voice", "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, }, { "conversation_engine": "conversation_engine_3", @@ -63,6 +65,7 @@ "tts_voice": None, "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, }, ], "preferred_item": "01GX8ZWBAQYWNB1XV3EXEZ75DY", diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index 5b6f7072a2d59..e792d8c6913bd 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -355,15 +355,15 @@ async def test_ws_hass_agent_debug_null_result( """Test homeassistant agent debug websocket command with a null result.""" client = await hass_ws_client(hass) - async def async_recognize(self, user_input, *args, **kwargs): + async def async_recognize_intent(self, user_input, *args, **kwargs): if user_input.text == "bad sentence": return None return await self.async_recognize(user_input, *args, **kwargs) with patch( - "homeassistant.components.conversation.default_agent.DefaultAgent.async_recognize", - async_recognize, + "homeassistant.components.conversation.default_agent.DefaultAgent.async_recognize_intent", + async_recognize_intent, ): await client.send_json_auto_id( { diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index e92b1ab538fb5..0100e62cf8131 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -8,10 +8,15 @@ import voluptuous as vol from homeassistant.components import conversation -from homeassistant.components.conversation import default_agent +from homeassistant.components.conversation import ( + ConversationInput, + async_handle_intents, + async_handle_sentence_triggers, + default_agent, +) from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent from homeassistant.setup import async_setup_component @@ -229,3 +234,93 @@ async def test_prepare_agent( await conversation.async_prepare_agent(hass, agent_id, "en") assert len(mock_prepare.mock_calls) == 1 + + +async def test_async_handle_sentence_triggers(hass: HomeAssistant) -> None: + """Test handling sentence triggers with async_handle_sentence_triggers.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + + response_template = "response {{ trigger.device_id }}" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["my trigger"], + }, + "action": { + "set_conversation_response": response_template, + }, + } + }, + ) + + # Device id will be available in response template + device_id = "1234" + expected_response = f"response {device_id}" + actual_response = await async_handle_sentence_triggers( + hass, + ConversationInput( + text="my trigger", + context=Context(), + conversation_id=None, + device_id=device_id, + language=hass.config.language, + ), + ) + assert actual_response == expected_response + + +async def test_async_handle_intents(hass: HomeAssistant) -> None: + """Test handling registered intents with async_handle_intents.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + + # Reuse custom sentences in test config to trigger default agent. + class OrderBeerIntentHandler(intent.IntentHandler): + intent_type = "OrderBeer" + + def __init__(self) -> None: + super().__init__() + self.was_handled = False + + async def async_handle( + self, intent_obj: intent.Intent + ) -> intent.IntentResponse: + self.was_handled = True + return intent_obj.create_response() + + handler = OrderBeerIntentHandler() + intent.async_register(hass, handler) + + # Registered intent will be handled + result = await async_handle_intents( + hass, + ConversationInput( + text="I'd like to order a stout", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + ), + ) + assert result is not None + assert result.intent is not None + assert result.intent.intent_type == handler.intent_type + assert handler.was_handled + + # No error messages, just None as a result + result = await async_handle_intents( + hass, + ConversationInput( + text="this sentence does not exist", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + ), + ) + assert result is None From f9a4dd91c12e2d3167bc1d2f3d20a2ab1947ba71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Nov 2024 11:08:53 -0600 Subject: [PATCH 0454/1070] Bump aiohttp-fast-zlib to 0.2.0 (#130628) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5bc539beb8693..205274fab128b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp-fast-zlib==0.1.1 +aiohttp-fast-zlib==0.2.0 aiohttp==3.11.0 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 diff --git a/pyproject.toml b/pyproject.toml index ebf22a93d7db0..c18fc0c20b59f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "aiohasupervisor==0.2.1", "aiohttp==3.11.0", "aiohttp_cors==0.7.0", - "aiohttp-fast-zlib==0.1.1", + "aiohttp-fast-zlib==0.2.0", "aiozoneinfo==0.2.1", "astral==2.2", "async-interrupt==1.2.0", diff --git a/requirements.txt b/requirements.txt index b97c8dc57a007..814c250549eab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp==3.11.0 aiohttp_cors==0.7.0 -aiohttp-fast-zlib==0.1.1 +aiohttp-fast-zlib==0.2.0 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 From 74184660643000c258560a8cac60bec5bc5cb7d4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Nov 2024 19:39:14 +0100 Subject: [PATCH 0455/1070] Add missing translation string to hvv_departures (#130634) --- homeassistant/components/hvv_departures/strings.json | 4 ++++ tests/components/hvv_departures/test_config_flow.py | 9 --------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/hvv_departures/strings.json b/homeassistant/components/hvv_departures/strings.json index a9ec58f12ad54..f69dcd2204711 100644 --- a/homeassistant/components/hvv_departures/strings.json +++ b/homeassistant/components/hvv_departures/strings.json @@ -32,6 +32,10 @@ } }, "options": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, "step": { "init": { "title": "Options", diff --git a/tests/components/hvv_departures/test_config_flow.py b/tests/components/hvv_departures/test_config_flow.py index 8d82382d9a29e..c85bfb7f6ee98 100644 --- a/tests/components/hvv_departures/test_config_flow.py +++ b/tests/components/hvv_departures/test_config_flow.py @@ -4,7 +4,6 @@ from unittest.mock import patch from pygti.exceptions import CannotConnect, InvalidAuth -import pytest from homeassistant.components.hvv_departures.const import ( CONF_FILTER, @@ -313,10 +312,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: } -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.hvv_departures.options.error.invalid_auth"], -) async def test_options_flow_invalid_auth(hass: HomeAssistant) -> None: """Test that options flow works.""" @@ -360,10 +355,6 @@ async def test_options_flow_invalid_auth(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "invalid_auth"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.hvv_departures.options.error.cannot_connect"], -) async def test_options_flow_cannot_connect(hass: HomeAssistant) -> None: """Test that options flow works.""" From 7c34f5ea5670aab9de9ef2740c31fa961472df97 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Nov 2024 19:40:01 +0100 Subject: [PATCH 0456/1070] Add missing translation string to lg_netcast (#130635) --- homeassistant/components/lg_netcast/strings.json | 3 ++- tests/components/lg_netcast/test_config_flow.py | 6 ------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lg_netcast/strings.json b/homeassistant/components/lg_netcast/strings.json index 209c3837261db..0377d4bf3181d 100644 --- a/homeassistant/components/lg_netcast/strings.json +++ b/homeassistant/components/lg_netcast/strings.json @@ -25,7 +25,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" } }, "device_automation": { diff --git a/tests/components/lg_netcast/test_config_flow.py b/tests/components/lg_netcast/test_config_flow.py index 7959c0c445ec8..0270758248403 100644 --- a/tests/components/lg_netcast/test_config_flow.py +++ b/tests/components/lg_netcast/test_config_flow.py @@ -3,8 +3,6 @@ from datetime import timedelta from unittest.mock import DEFAULT, patch -import pytest - from homeassistant import data_entry_flow from homeassistant.components.lg_netcast.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -114,10 +112,6 @@ async def test_manual_host_unsuccessful_details_response(hass: HomeAssistant) -> assert result["reason"] == "cannot_connect" -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.lg_netcast.config.abort.invalid_host"], -) async def test_manual_host_no_unique_id_response(hass: HomeAssistant) -> None: """Test manual host configuration.""" with _patch_lg_netcast(no_unique_id=True): From 2c8f038c39ab2188a75868a6ebec0e7661776ae2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Nov 2024 19:40:38 +0100 Subject: [PATCH 0457/1070] Add missing translation string to philips_js (#130637) --- homeassistant/components/philips_js/strings.json | 2 +- tests/components/philips_js/test_config_flow.py | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index 3ea632ce436df..1f187d89ddac2 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -18,11 +18,11 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "pairing_failure": "Unable to pair: {error_id}", "invalid_pin": "Invalid PIN" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "pairing_failure": "Unable to pair: {error_id}", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index c08885634dbfe..80d059618133d 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -161,10 +161,6 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.philips_js.config.abort.pairing_failure"], -) async def test_pair_request_failed( hass: HomeAssistant, mock_tv_pairable, mock_setup_entry ) -> None: @@ -192,10 +188,6 @@ async def test_pair_request_failed( } -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.philips_js.config.abort.pairing_failure"], -) async def test_pair_grant_failed( hass: HomeAssistant, mock_tv_pairable, mock_setup_entry ) -> None: From a97090e0d55f9ce582793e370de74202b29365c9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Nov 2024 19:41:51 +0100 Subject: [PATCH 0458/1070] Fix incorrect patch in flume tests (#130631) --- tests/components/flume/conftest.py | 5 ++--- tests/components/flume/test_config_flow.py | 22 ---------------------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/tests/components/flume/conftest.py b/tests/components/flume/conftest.py index fb0d0157bbcaf..6173db1e2b956 100644 --- a/tests/components/flume/conftest.py +++ b/tests/components/flume/conftest.py @@ -3,8 +3,7 @@ from collections.abc import Generator import datetime from http import HTTPStatus -import json -from unittest.mock import mock_open, patch +from unittest.mock import patch import jwt import pytest @@ -116,7 +115,7 @@ def access_token_fixture(requests_mock: Mocker) -> Generator[None]: status_code=HTTPStatus.OK, json={"data": [token_response]}, ) - with patch("builtins.open", mock_open(read_data=json.dumps(token_response))): + with patch("homeassistant.components.flume.coordinator.FlumeAuth.write_token_file"): yield diff --git a/tests/components/flume/test_config_flow.py b/tests/components/flume/test_config_flow.py index c323defc791e3..87fe3a2bbf065 100644 --- a/tests/components/flume/test_config_flow.py +++ b/tests/components/flume/test_config_flow.py @@ -61,10 +61,6 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.flume.config.error.invalid_auth"], -) @pytest.mark.usefixtures("access_token") async def test_form_invalid_auth(hass: HomeAssistant, requests_mock: Mocker) -> None: """Test we handle invalid auth.""" @@ -93,10 +89,6 @@ async def test_form_invalid_auth(hass: HomeAssistant, requests_mock: Mocker) -> assert result2["errors"] == {"password": "invalid_auth"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.flume.config.error.cannot_connect"], -) @pytest.mark.usefixtures("access_token", "device_list_timeout") async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" @@ -118,16 +110,6 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - [ - [ - "component.flume.config.abort.reauth_successful", - "component.flume.config.error.cannot_connect", - "component.flume.config.error.invalid_auth", - ] - ], -) @pytest.mark.usefixtures("access_token") async def test_reauth(hass: HomeAssistant, requests_mock: Mocker) -> None: """Test we can reauth.""" @@ -208,10 +190,6 @@ async def test_reauth(hass: HomeAssistant, requests_mock: Mocker) -> None: assert result4["reason"] == "reauth_successful" -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.flume.config.error.cannot_connect"], -) @pytest.mark.usefixtures("access_token") async def test_form_no_devices(hass: HomeAssistant, requests_mock: Mocker) -> None: """Test a device list response that contains no values will raise an error.""" From a1e3c7513b0cd580afbfc930c3cb4f8731d4b297 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Nov 2024 19:45:42 +0100 Subject: [PATCH 0459/1070] Make Switch as x platform options translatable (#130443) Make Switch as x options translatable --- .../components/switch_as_x/config_flow.py | 16 +++++++++------- .../components/switch_as_x/strings.json | 12 ++++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/switch_as_x/config_flow.py b/homeassistant/components/switch_as_x/config_flow.py index 37df3affbad16..aa9f1d411cef4 100644 --- a/homeassistant/components/switch_as_x/config_flow.py +++ b/homeassistant/components/switch_as_x/config_flow.py @@ -18,12 +18,12 @@ from .const import CONF_INVERT, CONF_TARGET_DOMAIN, DOMAIN TARGET_DOMAIN_OPTIONS = [ - selector.SelectOptionDict(value=Platform.COVER, label="Cover"), - selector.SelectOptionDict(value=Platform.FAN, label="Fan"), - selector.SelectOptionDict(value=Platform.LIGHT, label="Light"), - selector.SelectOptionDict(value=Platform.LOCK, label="Lock"), - selector.SelectOptionDict(value=Platform.SIREN, label="Siren"), - selector.SelectOptionDict(value=Platform.VALVE, label="Valve"), + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.SIREN, + Platform.VALVE, ] CONFIG_FLOW = { @@ -35,7 +35,9 @@ ), vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(), vol.Required(CONF_TARGET_DOMAIN): selector.SelectSelector( - selector.SelectSelectorConfig(options=TARGET_DOMAIN_OPTIONS), + selector.SelectSelectorConfig( + options=TARGET_DOMAIN_OPTIONS, translation_key="target_domain" + ), ), } ) diff --git a/homeassistant/components/switch_as_x/strings.json b/homeassistant/components/switch_as_x/strings.json index 81567ef9e40fa..9c3db05231bac 100644 --- a/homeassistant/components/switch_as_x/strings.json +++ b/homeassistant/components/switch_as_x/strings.json @@ -26,5 +26,17 @@ } } } + }, + "selector": { + "target_domain": { + "options": { + "cover": "[%key:component::cover::title%]", + "fan": "[%key:component::fan::title%]", + "light": "[%key:component::light::title%]", + "lock": "[%key:component::lock::title%]", + "siren": "[%key:component::siren::title%]", + "valve": "[%key:component::valve::title%]" + } + } } } From 2344b7c9eb0b04b046157afafa018d7523248fed Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Thu, 14 Nov 2024 15:11:33 -0500 Subject: [PATCH 0460/1070] Fix translation missing errors in supervisor tests (#130640) * Fix translation missing errors in supervisor tests * Add suggestion to suggestions_by_issue mock --- tests/components/hassio/test_issues.py | 104 +++++++++++-------------- 1 file changed, 46 insertions(+), 58 deletions(-) diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 7ce11a18fb509..b0d3920be0940 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -323,6 +323,17 @@ async def test_reset_issues_supervisor_restart( uuid=(uuid := uuid4()), ) ], + suggestions_by_issue={ + uuid: [ + Suggestion( + SuggestionType.EXECUTE_REBOOT, + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + auto=False, + ) + ] + }, ) result = await async_setup_component(hass, "hassio", {}) @@ -341,7 +352,7 @@ async def test_reset_issues_supervisor_restart( uuid=uuid.hex, context="system", type_="reboot_required", - fixable=False, + fixable=True, reference=None, ) @@ -510,9 +521,9 @@ async def test_supervisor_issues( supervisor_client, issues=[ Issue( - type=IssueType.REBOOT_REQUIRED, - context=ContextType.SYSTEM, - reference=None, + type=IssueType.DETACHED_ADDON_MISSING, + context=ContextType.ADDON, + reference="test", uuid=(uuid_issue1 := uuid4()), ), Issue( @@ -553,10 +564,11 @@ async def test_supervisor_issues( assert_issue_repair_in_list( msg["result"]["issues"], uuid=uuid_issue1.hex, - context="system", - type_="reboot_required", + context="addon", + type_="detached_addon_missing", fixable=False, - reference=None, + reference="test", + placeholders={"addon_url": "/hassio/addon/test", "addon": "test"}, ) assert_issue_repair_in_list( msg["result"]["issues"], @@ -571,31 +583,39 @@ async def test_supervisor_issues( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues_initial_failure( hass: HomeAssistant, + supervisor_client: AsyncMock, resolution_info: AsyncMock, - resolution_suggestions_for_issue: AsyncMock, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test issues manager retries after initial update failure.""" - resolution_info.side_effect = [ - SupervisorBadRequestError("System is not ready with state: setup"), - ResolutionInfo( - unsupported=[], - unhealthy=[], - suggestions=[], - issues=[ - Issue( - type=IssueType.REBOOT_REQUIRED, + mock_resolution_info( + supervisor_client, + unsupported=[], + unhealthy=[], + issues=[ + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference=None, + uuid=(uuid := uuid4()), + ) + ], + suggestions_by_issue={ + uuid: [ + Suggestion( + SuggestionType.EXECUTE_REBOOT, context=ContextType.SYSTEM, reference=None, uuid=uuid4(), + auto=False, ) - ], - checks=[ - Check(enabled=True, slug=CheckType.SUPERVISOR_TRUST), - Check(enabled=True, slug=CheckType.FREE_SPACE), - ], - ), + ] + }, + ) + resolution_info.side_effect = [ + SupervisorBadRequestError("System is not ready with state: setup"), + resolution_info.return_value, ] with patch("homeassistant.components.hassio.issues.REQUEST_REFRESH_DELAY", new=0.1): @@ -643,38 +663,6 @@ async def test_supervisor_issues_add_remove( "type": "reboot_required", "context": "system", "reference": None, - }, - }, - } - ) - msg = await client.receive_json() - assert msg["success"] - await hass.async_block_till_done() - - await client.send_json({"id": 2, "type": "repairs/list_issues"}) - msg = await client.receive_json() - assert msg["success"] - assert len(msg["result"]["issues"]) == 1 - assert_issue_repair_in_list( - msg["result"]["issues"], - uuid=issue_uuid, - context="system", - type_="reboot_required", - fixable=False, - reference=None, - ) - - await client.send_json( - { - "id": 3, - "type": "supervisor/event", - "data": { - "event": "issue_changed", - "data": { - "uuid": issue_uuid, - "type": "reboot_required", - "context": "system", - "reference": None, "suggestions": [ { "uuid": uuid4().hex, @@ -691,7 +679,7 @@ async def test_supervisor_issues_add_remove( assert msg["success"] await hass.async_block_till_done() - await client.send_json({"id": 4, "type": "repairs/list_issues"}) + await client.send_json({"id": 2, "type": "repairs/list_issues"}) msg = await client.receive_json() assert msg["success"] assert len(msg["result"]["issues"]) == 1 @@ -706,7 +694,7 @@ async def test_supervisor_issues_add_remove( await client.send_json( { - "id": 5, + "id": 3, "type": "supervisor/event", "data": { "event": "issue_removed", @@ -723,7 +711,7 @@ async def test_supervisor_issues_add_remove( assert msg["success"] await hass.async_block_till_done() - await client.send_json({"id": 6, "type": "repairs/list_issues"}) + await client.send_json({"id": 4, "type": "repairs/list_issues"}) msg = await client.receive_json() assert msg["success"] assert msg["result"] == {"issues": []} From f4719a21ea95430e19c31327504ba7c29583e0fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Nov 2024 14:12:59 -0600 Subject: [PATCH 0461/1070] Bump aiohttp to 3.11.1 (#130636) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 205274fab128b..346de0d03bb85 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.2.0 -aiohttp==3.11.0 +aiohttp==3.11.1 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index c18fc0c20b59f..e411d84327f69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.0", + "aiohttp==3.11.1", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index 814c250549eab..a0277b038075c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.0 +aiohttp==3.11.1 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 aiozoneinfo==0.2.1 From 3db4d951bfb237063d9846accf15057a51dda3de Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 14 Nov 2024 21:27:40 +0100 Subject: [PATCH 0462/1070] Update mypy-dev to 1.14.0a3 (#130629) --- homeassistant/components/sleepiq/number.py | 4 ++-- requirements_test.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 905ceab18bdce..e4fa60a4a4366 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -58,14 +58,14 @@ def _get_actuator_name(bed: SleepIQBed, actuator: SleepIQActuator) -> str: f" {bed.name} {actuator.side_full} {actuator.actuator_full} {ENTITY_TYPES[ACTUATOR]}" ) - return f"SleepNumber {bed.name} {actuator.actuator_full} {ENTITY_TYPES[ACTUATOR]}" + return f"SleepNumber {bed.name} {actuator.actuator_full} {ENTITY_TYPES[ACTUATOR]}" # type: ignore[unreachable] def _get_actuator_unique_id(bed: SleepIQBed, actuator: SleepIQActuator) -> str: if actuator.side: return f"{bed.id}_{actuator.side.value}_{actuator.actuator}" - return f"{bed.id}_{actuator.actuator}" + return f"{bed.id}_{actuator.actuator}" # type: ignore[unreachable] def _get_sleeper_name(bed: SleepIQBed, sleeper: SleepIQSleeper) -> str: diff --git a/requirements_test.txt b/requirements_test.txt index 166fd965e2c64..73874e3a63119 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.1 freezegun==1.5.1 license-expression==30.4.0 mock-open==1.4.0 -mypy-dev==1.14.0a2 +mypy-dev==1.14.0a3 pre-commit==4.0.0 pydantic==1.10.19 pylint==3.3.1 From 76887705220e6c37fb3056108f405b4794bcef1d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 14 Nov 2024 23:09:16 +0100 Subject: [PATCH 0463/1070] Remove dumping config entry to log in setup of roborock (#130648) --- homeassistant/components/roborock/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index d1cbccc6b057f..d02dddece42f1 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -47,7 +47,6 @@ def values( async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: """Set up roborock from a config entry.""" - _LOGGER.debug("Integration async setup entry: %s", entry.as_dict()) entry.async_on_unload(entry.add_update_listener(update_listener)) user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) From eaa8a5a7508526f97a5a55d1fa888b309ef2a330 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 01:50:09 +0100 Subject: [PATCH 0464/1070] Fix missing translations in toon (#130655) --- homeassistant/components/toon/strings.json | 1 + tests/components/toon/test_config_flow.py | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json index ed29e77a58cc9..3072896653df2 100644 --- a/homeassistant/components/toon/strings.json +++ b/homeassistant/components/toon/strings.json @@ -16,6 +16,7 @@ "already_configured": "The selected agreement is already configured.", "unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "connection_error": "[%key:common::config_flow::error::cannot_connect%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "no_agreements": "This account has no Toon displays.", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", diff --git a/tests/components/toon/test_config_flow.py b/tests/components/toon/test_config_flow.py index 7855379db5b4b..1ad5ea1ca3dab 100644 --- a/tests/components/toon/test_config_flow.py +++ b/tests/components/toon/test_config_flow.py @@ -249,10 +249,6 @@ async def test_agreement_already_set_up( assert result3["reason"] == "already_configured" -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.toon.config.abort.connection_error"], -) @pytest.mark.usefixtures("current_request_with_host") async def test_toon_abort( hass: HomeAssistant, From b2d98ae93126ec8f5fdc2ec581c3ddaf5d9d3d42 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 01:50:47 +0100 Subject: [PATCH 0465/1070] Fix missing translations in vilfo (#130650) --- homeassistant/components/vilfo/config_flow.py | 2 +- homeassistant/components/vilfo/strings.json | 1 + tests/components/vilfo/test_config_flow.py | 6 +----- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/vilfo/config_flow.py b/homeassistant/components/vilfo/config_flow.py index a6cff506f79ea..cdba7f1b8c2df 100644 --- a/homeassistant/components/vilfo/config_flow.py +++ b/homeassistant/components/vilfo/config_flow.py @@ -109,7 +109,7 @@ async def async_step_user( try: info = await validate_input(self.hass, user_input) except InvalidHost: - errors[CONF_HOST] = "wrong_host" + errors["base"] = "invalid_host" except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: diff --git a/homeassistant/components/vilfo/strings.json b/homeassistant/components/vilfo/strings.json index f2c4c38780b43..55c996d4a3de5 100644 --- a/homeassistant/components/vilfo/strings.json +++ b/homeassistant/components/vilfo/strings.json @@ -14,6 +14,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/tests/components/vilfo/test_config_flow.py b/tests/components/vilfo/test_config_flow.py index 24739f509e4da..dcfdc8a9ffa4e 100644 --- a/tests/components/vilfo/test_config_flow.py +++ b/tests/components/vilfo/test_config_flow.py @@ -150,10 +150,6 @@ async def test_form_exceptions( assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.vilfo.config.error.wrong_host"], -) async def test_form_wrong_host( hass: HomeAssistant, mock_is_valid_host: AsyncMock, @@ -169,7 +165,7 @@ async def test_form_wrong_host( }, ) - assert result["errors"] == {"host": "wrong_host"} + assert result["errors"] == {"base": "invalid_host"} async def test_form_already_configured( From 4a7ae081dfd04e712f670c3ed2df307507dfa21f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Nov 2024 21:40:48 -0600 Subject: [PATCH 0466/1070] Bump aiohttp 3.11.2 (#130663) fix for cleaning up incorrectly closed WebSocket connections when a WebSocket task is cancelled changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.1...v3.11.2 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 346de0d03bb85..1e407cca106bc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.2.0 -aiohttp==3.11.1 +aiohttp==3.11.2 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index e411d84327f69..613a9608c878b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.1", + "aiohttp==3.11.2", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index a0277b038075c..a4f1c86cc2107 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.1 +aiohttp==3.11.2 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 aiozoneinfo==0.2.1 From 62a5a219d9e7157194a4754313f8d330bfa4aad4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 04:41:15 +0100 Subject: [PATCH 0467/1070] Fix missing translations in madvr (#130656) --- homeassistant/components/madvr/strings.json | 6 +++--- tests/components/madvr/test_config_flow.py | 4 ---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/madvr/strings.json b/homeassistant/components/madvr/strings.json index 06851efa2c8d4..1a4f0f79aae42 100644 --- a/homeassistant/components/madvr/strings.json +++ b/homeassistant/components/madvr/strings.json @@ -28,12 +28,12 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "set_up_new_device": "A new device was detected. Please set it up as a new entity instead of reconfiguring." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable.", - "set_up_new_device": "A new device was detected. Please set it up as a new entity instead of reconfiguring." + "no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable." } }, "entity": { diff --git a/tests/components/madvr/test_config_flow.py b/tests/components/madvr/test_config_flow.py index 35db8a01b5bf9..7b31ec6c17c5d 100644 --- a/tests/components/madvr/test_config_flow.py +++ b/tests/components/madvr/test_config_flow.py @@ -165,10 +165,6 @@ async def test_reconfigure_flow( mock_madvr_client.async_cancel_tasks.assert_called() -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.madvr.config.abort.set_up_new_device"], -) async def test_reconfigure_new_device( hass: HomeAssistant, mock_madvr_client: AsyncMock, From aea8e8abac0f9c090c200fd65fb40cfa4baf353f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 04:42:01 +0100 Subject: [PATCH 0468/1070] Fix missing translations in utility_meter (#130652) --- homeassistant/components/utility_meter/strings.json | 3 +++ tests/components/utility_meter/test_config_flow.py | 4 ---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json index fc1c727fb0a84..e05789aece16f 100644 --- a/homeassistant/components/utility_meter/strings.json +++ b/homeassistant/components/utility_meter/strings.json @@ -25,6 +25,9 @@ "tariffs": "A list of supported tariffs, leave empty if only a single tariff is needed." } } + }, + "error": { + "tariffs_not_unique": "Tariffs must be unique" } }, "options": { diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 612bfaa88d7a1..560566d7c4913 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -72,10 +72,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: assert config_entry.title == "Electricity meter" -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.utility_meter.config.error.tariffs_not_unique"], -) async def test_tariffs(hass: HomeAssistant) -> None: """Test tariffs.""" input_sensor_entity_id = "sensor.input" From e8b0b3e05cacc9e5e42ecf6869c173af4fc11d41 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 04:42:15 +0100 Subject: [PATCH 0469/1070] Fix missing translations in tradfri (#130654) * Fix missing translations in tradfri * Simplify --- homeassistant/components/tradfri/config_flow.py | 5 +---- homeassistant/components/tradfri/strings.json | 2 +- tests/components/tradfri/test_config_flow.py | 6 +----- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 8de401403391b..d9911472a6721 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -60,10 +60,7 @@ async def async_step_auth( return await self._entry_from_data(auth) except AuthError as err: - if err.code == "invalid_security_code": - errors[KEY_SECURITY_CODE] = err.code - else: - errors["base"] = err.code + errors["base"] = err.code else: user_input = {} diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 69a28a567ab0f..9ed7e167e7156 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -14,7 +14,7 @@ } }, "error": { - "invalid_key": "Failed to register with provided key. If this keeps happening, try restarting the gateway.", + "invalid_security_code": "Failed to register with provided key. If this keeps happening, try restarting the gateway.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout": "Timeout validating the code.", "cannot_authenticate": "Cannot authenticate, is Gateway paired with another server like e.g. Homekit?" diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index 5c06851782cc3..b6f38b1d83d75 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -86,10 +86,6 @@ async def test_user_connection_timeout( assert result["errors"] == {"base": "timeout"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.tradfri.config.error.invalid_security_code"], -) async def test_user_connection_bad_key( hass: HomeAssistant, mock_auth, mock_entry_setup ) -> None: @@ -107,7 +103,7 @@ async def test_user_connection_bad_key( assert len(mock_entry_setup.mock_calls) == 0 assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"security_code": "invalid_security_code"} + assert result["errors"] == {"base": "invalid_security_code"} async def test_discovery_connection( From 5806304d792d637ce46150a2def70d342a9f3030 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 04:43:13 +0100 Subject: [PATCH 0470/1070] Use single_config_entry in google_assistant_sdk (#130632) * Use single_config_entry in google_assistant_sdk * Cleanup --- .../google_assistant_sdk/config_flow.py | 4 --- .../google_assistant_sdk/manifest.json | 3 +- .../google_assistant_sdk/test_config_flow.py | 34 ------------------- 3 files changed, 2 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/google_assistant_sdk/config_flow.py b/homeassistant/components/google_assistant_sdk/config_flow.py index cd78c90e29716..48c9283248394 100644 --- a/homeassistant/components/google_assistant_sdk/config_flow.py +++ b/homeassistant/components/google_assistant_sdk/config_flow.py @@ -66,10 +66,6 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu self._get_reauth_entry(), data=data ) - if self._async_current_entries(): - # Config entry already exists, only one allowed. - return self.async_abort(reason="single_instance_allowed") - return self.async_create_entry( title=DEFAULT_NAME, data=data, diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json index b6281e2a4f083..9c3a3e03dfda3 100644 --- a/homeassistant/components/google_assistant_sdk/manifest.json +++ b/homeassistant/components/google_assistant_sdk/manifest.json @@ -8,5 +8,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["gassist-text==0.0.11"] + "requirements": ["gassist-text==0.0.11"], + "single_config_entry": true } diff --git a/tests/components/google_assistant_sdk/test_config_flow.py b/tests/components/google_assistant_sdk/test_config_flow.py index b6ee701b228ee..332610e74e86c 100644 --- a/tests/components/google_assistant_sdk/test_config_flow.py +++ b/tests/components/google_assistant_sdk/test_config_flow.py @@ -157,10 +157,6 @@ async def test_reauth( assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.google_assistant_sdk.config.abort.single_instance_allowed"], -) @pytest.mark.usefixtures("current_request_with_host") async def test_single_instance_allowed( hass: HomeAssistant, @@ -182,37 +178,7 @@ async def test_single_instance_allowed( result = await hass.config_entries.flow.async_init( "google_assistant_sdk", context={"source": config_entries.SOURCE_USER} ) - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - assert result["url"] == ( - f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}&scope=https://www.googleapis.com/auth/assistant-sdk-prototype" - "&access_type=offline&prompt=consent" - ) - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == 200 - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - aioclient_mock.post( - GOOGLE_TOKEN_URI, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - }, - ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "single_instance_allowed" From ae95d802cc51bab01347c5a9ddbdf628f1fdb90e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 08:40:57 +0100 Subject: [PATCH 0471/1070] Fix missing translations in onewire (#130673) --- homeassistant/components/onewire/config_flow.py | 2 +- homeassistant/components/onewire/strings.json | 3 +++ tests/components/onewire/test_config_flow.py | 6 +----- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index abb4c8849747f..3889db2a06935 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -144,7 +144,7 @@ async def async_step_init( } if not self.configurable_devices: - return self.async_abort(reason="No configurable devices found.") + return self.async_abort(reason="no_configurable_devices") return await self.async_step_device_selection(user_input=None) diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 8dbcbdf8978f2..68585c3203f7c 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -94,6 +94,9 @@ } }, "options": { + "abort": { + "no_configurable_devices": "No configurable devices found" + }, "error": { "device_not_selected": "Select devices to configure" }, diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index c554624267da3..029e1278c868e 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -253,10 +253,6 @@ async def test_user_options_set_multiple( ) -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.onewire.options.abort.No configurable devices found."], -) async def test_user_options_no_devices( hass: HomeAssistant, config_entry: ConfigEntry ) -> None: @@ -267,4 +263,4 @@ async def test_user_options_no_devices( result = await hass.config_entries.options.async_init(config_entry.entry_id) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "No configurable devices found." + assert result["reason"] == "no_configurable_devices" From b549c0f67c1d64ac949bf6f112922079e6b0383c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Nenz=C3=A9n?= Date: Fri, 15 Nov 2024 09:41:35 +0100 Subject: [PATCH 0472/1070] Bump pyplaato to 0.0.19 (#130641) Bumps version of pyplaato to 0.0.19 --- homeassistant/components/plaato/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plaato/manifest.json b/homeassistant/components/plaato/manifest.json index aac7ec2d06fc2..1547501ac5061 100644 --- a/homeassistant/components/plaato/manifest.json +++ b/homeassistant/components/plaato/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/plaato", "iot_class": "cloud_push", "loggers": ["pyplaato"], - "requirements": ["pyplaato==0.0.18"] + "requirements": ["pyplaato==0.0.19"] } diff --git a/requirements_all.txt b/requirements_all.txt index ea7bdefce2b9e..4a40875169268 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2167,7 +2167,7 @@ pypck==0.7.24 pypjlink2==1.2.1 # homeassistant.components.plaato -pyplaato==0.0.18 +pyplaato==0.0.19 # homeassistant.components.point pypoint==3.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf1e761bfa930..03c53a1ca9749 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1751,7 +1751,7 @@ pypck==0.7.24 pypjlink2==1.2.1 # homeassistant.components.plaato -pyplaato==0.0.18 +pyplaato==0.0.19 # homeassistant.components.point pypoint==3.0.0 From 6ee85e9094528f6432c85c1d1e039ac338f3a2ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 Nov 2024 09:44:33 +0100 Subject: [PATCH 0473/1070] Bump codecov/codecov-action from 4.6.0 to 5.0.0 (#130671) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fa05f6082a20e..4be2200c698ee 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1248,7 +1248,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v4.6.0 + uses: codecov/codecov-action@v5.0.0 with: fail_ci_if_error: true flags: full-suite @@ -1387,7 +1387,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v4.6.0 + uses: codecov/codecov-action@v5.0.0 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From b3fcc0cf60dd9c8c3600b1b46eb7825f2245c14f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Nenz=C3=A9n?= Date: Fri, 15 Nov 2024 09:46:12 +0100 Subject: [PATCH 0474/1070] Fixes webhook schema for different temp and volume units (#130578) --- homeassistant/components/plaato/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index 59441f2502514..585b6ecfd82e6 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -64,10 +64,10 @@ vol.Required(ATTR_DEVICE_NAME): cv.string, vol.Required(ATTR_DEVICE_ID): cv.positive_int, vol.Required(ATTR_TEMP_UNIT): vol.In( - UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT + [UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT] ), vol.Required(ATTR_VOLUME_UNIT): vol.In( - UnitOfVolume.LITERS, UnitOfVolume.GALLONS + [UnitOfVolume.LITERS, UnitOfVolume.GALLONS] ), vol.Required(ATTR_BPM): cv.positive_int, vol.Required(ATTR_TEMP): vol.Coerce(float), From 1e303dd70672cdbd97a9a0150336a73a833eb2d6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 09:48:02 +0100 Subject: [PATCH 0475/1070] Fix missing translations in generic (#130672) --- homeassistant/components/generic/config_flow.py | 6 +++++- homeassistant/components/generic/strings.json | 1 + tests/components/generic/test_config_flow.py | 7 ++----- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 8bd238fd0e645..84243101bd669 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -282,7 +282,7 @@ async def async_test_stream( return {CONF_STREAM_SOURCE: "timeout"} await stream.stop() except StreamWorkerError as err: - return {CONF_STREAM_SOURCE: str(err)} + return {CONF_STREAM_SOURCE: "unknown_with_details", "error_details": str(err)} except PermissionError: return {CONF_STREAM_SOURCE: "stream_not_permitted"} except OSError as err: @@ -339,6 +339,7 @@ async def async_step_user( ) -> ConfigFlowResult: """Handle the start of the config flow.""" errors = {} + description_placeholders = {} hass = self.hass if user_input: # Secondary validation because serialised vol can't seem to handle this complexity: @@ -372,6 +373,8 @@ async def async_step_user( # temporary preview for user to check the image self.preview_cam = user_input return await self.async_step_user_confirm_still() + if "error_details" in errors: + description_placeholders["error"] = errors.pop("error_details") elif self.user_input: user_input = self.user_input else: @@ -379,6 +382,7 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=build_schema(user_input), + description_placeholders=description_placeholders, errors=errors, ) diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index b05f17efc8d00..94360a5b7c295 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -3,6 +3,7 @@ "config": { "error": { "unknown": "[%key:common::config_flow::error::unknown%]", + "unknown_with_details": "An unknown error occurred: {error}", "already_exists": "A camera with these URL settings already exists.", "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", "unable_still_load_auth": "Unable to load valid image from still image URL: The camera may require a user name and password, or they are not correct.", diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 7575a07867587..a882ca4cd8dfa 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -637,10 +637,6 @@ async def test_form_stream_other_error(hass: HomeAssistant, user_flow) -> None: await hass.async_block_till_done() -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.generic.config.error.Some message"], -) @respx.mock @pytest.mark.usefixtures("fakeimg_png") async def test_form_stream_worker_error( @@ -656,7 +652,8 @@ async def test_form_stream_worker_error( TESTDATA, ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"stream_source": "Some message"} + assert result2["errors"] == {"stream_source": "unknown_with_details"} + assert result2["description_placeholders"] == {"error": "Some message"} @respx.mock From 0a2535cf8fa29845598859071c672e6a59b4660d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 09:51:28 +0100 Subject: [PATCH 0476/1070] Fix missing argument in translation checks (#130674) --- tests/components/conftest.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index dcbf589982c93..2c03bb9d7fcf5 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -554,6 +554,7 @@ def _validate_translation_placeholders( full_key: str, translation: str, description_placeholders: dict[str, str] | None, + translation_errors: dict[str, str], ) -> str | None: """Raise if translation exists with missing placeholders.""" tuples = list(string.Formatter().parse(translation)) @@ -564,14 +565,14 @@ def _validate_translation_placeholders( description_placeholders is None or placeholder not in description_placeholders ): - ignore_translations[full_key] = ( + translation_errors[full_key] = ( f"Description not found for placeholder `{placeholder}` in {full_key}" ) async def _validate_translation( hass: HomeAssistant, - ignore_translations: dict[str, StoreInfo], + translation_errors: dict[str, str], category: str, component: str, key: str, @@ -584,18 +585,18 @@ async def _validate_translation( translations = await async_get_translations(hass, "en", category, [component]) if (translation := translations.get(full_key)) is not None: _validate_translation_placeholders( - full_key, translation, description_placeholders + full_key, translation, description_placeholders, translation_errors ) return if not translation_required: return - if full_key in ignore_translations: - ignore_translations[full_key] = "used" + if full_key in translation_errors: + translation_errors[full_key] = "used" return - ignore_translations[full_key] = ( + translation_errors[full_key] = ( f"Translation not found for {component}: `{category}.{key}`. " f"Please add to homeassistant/components/{component}/strings.json" ) @@ -615,7 +616,7 @@ async def _check_config_flow_result_translations( manager: FlowManager, flow: FlowHandler, result: FlowResult[FlowContext, str], - ignore_translations: dict[str, str], + translation_errors: dict[str, str], ) -> None: if result["type"] is FlowResultType.CREATE_ENTRY: # No need to check translations for a completed flow @@ -649,7 +650,7 @@ async def _check_config_flow_result_translations( for header in ("title", "description"): await _validate_translation( flow.hass, - ignore_translations, + translation_errors, category, integration, f"{key_prefix}step.{step_id}.{header}", @@ -660,7 +661,7 @@ async def _check_config_flow_result_translations( for error in errors.values(): await _validate_translation( flow.hass, - ignore_translations, + translation_errors, category, integration, f"{key_prefix}error.{error}", @@ -675,7 +676,7 @@ async def _check_config_flow_result_translations( return await _validate_translation( flow.hass, - ignore_translations, + translation_errors, category, integration, f"{key_prefix}abort.{result["reason"]}", @@ -688,12 +689,12 @@ def check_translations(ignore_translations: str | list[str]) -> Generator[None]: """Check that translation requirements are met. Current checks: - - data entry flow results (ConfigFlow/OptionsFlow) + - data entry flow results (ConfigFlow/OptionsFlow/RepairFlow) """ if not isinstance(ignore_translations, list): ignore_translations = [ignore_translations] - _ignore_translations = {k: "unused" for k in ignore_translations} + translation_errors = {k: "unused" for k in ignore_translations} # Keep reference to original functions _original_flow_manager_async_handle_step = FlowManager._async_handle_step @@ -704,7 +705,7 @@ async def _flow_manager_async_handle_step( ) -> FlowResult: result = await _original_flow_manager_async_handle_step(self, flow, *args) await _check_config_flow_result_translations( - self, flow, result, _ignore_translations + self, flow, result, translation_errors ) return result @@ -716,12 +717,12 @@ async def _flow_manager_async_handle_step( yield # Run final checks - unused_ignore = [k for k, v in _ignore_translations.items() if v == "unused"] + unused_ignore = [k for k, v in translation_errors.items() if v == "unused"] if unused_ignore: pytest.fail( f"Unused ignore translations: {', '.join(unused_ignore)}. " "Please remove them from the ignore_translations fixture." ) - for description in _ignore_translations.values(): + for description in translation_errors.values(): if description not in {"used", "unused"}: pytest.fail(description) From 390b83a9638e7eb515501b848d30e735349f4b48 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 15 Nov 2024 03:55:22 -0500 Subject: [PATCH 0477/1070] Bump ZHA dependencies (#130563) --- homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/update.py | 23 +++++++++++----------- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/components/zha/test_update.py | 2 +- 5 files changed, 17 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 96c9bc030f679..8736dc8954946 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["universal-silabs-flasher==0.0.24", "zha==0.0.37"], + "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.39"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index 151d1c495e873..18b8ed1cca5ff 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -4,7 +4,6 @@ import functools import logging -import math from typing import Any from zha.exceptions import ZHAException @@ -97,6 +96,7 @@ class ZHAFirmwareUpdateEntity( | UpdateEntityFeature.SPECIFIC_VERSION | UpdateEntityFeature.RELEASE_NOTES ) + _attr_display_precision = 2 # 40 byte chunks with ~200KB files increments by 0.02% def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: """Initialize the ZHA siren.""" @@ -115,20 +115,19 @@ def installed_version(self) -> str | None: def in_progress(self) -> bool | int | None: """Update installation progress. - Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used. - - Can either return a boolean (True if in progress, False if not) - or an integer to indicate the progress in from 0 to 100%. + Should return a boolean (True if in progress, False if not). """ - if not self.entity_data.entity.in_progress: - return self.entity_data.entity.in_progress + return self.entity_data.entity.in_progress - # Stay in an indeterminate state until we actually send something - if self.entity_data.entity.progress == 0: - return True + @property + def update_percentage(self) -> int | float | None: + """Update installation progress. - # Rescale 0-100% to 2-100% to avoid 0 and 1 colliding with None, False, and True - return int(math.ceil(2 + 98 * self.entity_data.entity.progress / 100)) + Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used. + + Can either return a number to indicate the progress from 0 to 100% or None. + """ + return self.entity_data.entity.update_percentage @property def latest_version(self) -> str | None: diff --git a/requirements_all.txt b/requirements_all.txt index 4a40875169268..3d6a152ed589c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2912,7 +2912,7 @@ unifi_ap==0.0.1 unifiled==0.11 # homeassistant.components.zha -universal-silabs-flasher==0.0.24 +universal-silabs-flasher==0.0.25 # homeassistant.components.upb upb-lib==0.5.8 @@ -3081,7 +3081,7 @@ zeroconf==0.136.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.37 +zha==0.0.39 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03c53a1ca9749..e382455593c94 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2319,7 +2319,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.2.0 # homeassistant.components.zha -universal-silabs-flasher==0.0.24 +universal-silabs-flasher==0.0.25 # homeassistant.components.upb upb-lib==0.5.8 @@ -2464,7 +2464,7 @@ zeroconf==0.136.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.37 +zha==0.0.39 # homeassistant.components.zwave_js zwave-js-server-python==0.59.1 diff --git a/tests/components/zha/test_update.py b/tests/components/zha/test_update.py index 4b6dff4fc6b83..cd48ae62ff3bc 100644 --- a/tests/components/zha/test_update.py +++ b/tests/components/zha/test_update.py @@ -394,7 +394,7 @@ async def endpoint_reply(cluster, sequence, data, **kwargs): attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" ) assert attrs[ATTR_IN_PROGRESS] is True - assert attrs[ATTR_UPDATE_PERCENTAGE] == 58 + assert attrs[ATTR_UPDATE_PERCENTAGE] == pytest.approx(100 * 40 / 70) assert ( attrs[ATTR_LATEST_VERSION] == f"0x{fw_image.firmware.header.file_version:08x}" From 35bf584a9ccd02e60447844b287fda259b8ae13a Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 15 Nov 2024 10:06:30 +0100 Subject: [PATCH 0478/1070] Deprecate returning to dock in Husqvarna Automower (#130649) --- .../husqvarna_automower/binary_sensor.py | 55 +++++++++++++++++++ .../husqvarna_automower/strings.json | 6 ++ .../husqvarna_automower/test_binary_sensor.py | 3 + 3 files changed, 64 insertions(+) diff --git a/homeassistant/components/husqvarna_automower/binary_sensor.py b/homeassistant/components/husqvarna_automower/binary_sensor.py index 5d1ccb6a07495..f8b8f1554585f 100644 --- a/homeassistant/components/husqvarna_automower/binary_sensor.py +++ b/homeassistant/components/husqvarna_automower/binary_sensor.py @@ -3,24 +3,42 @@ from collections.abc import Callable from dataclasses import dataclass import logging +from typing import TYPE_CHECKING from aioautomower.model import MowerActivities, MowerAttributes +from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.components.script import scripts_with_entity from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from . import AutomowerConfigEntry +from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity _LOGGER = logging.getLogger(__name__) +def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: + """Get list of related automations and scripts.""" + used_in = automations_with_entity(hass, entity_id) + used_in += scripts_with_entity(hass, entity_id) + return used_in + + @dataclass(frozen=True, kw_only=True) class AutomowerBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Automower binary sensor entity.""" @@ -43,6 +61,7 @@ class AutomowerBinarySensorEntityDescription(BinarySensorEntityDescription): key="returning_to_dock", translation_key="returning_to_dock", value_fn=lambda data: data.mower.activity == MowerActivities.GOING_HOME, + entity_registry_enabled_default=False, ), ) @@ -81,3 +100,39 @@ def __init__( def is_on(self) -> bool: """Return the state of the binary sensor.""" return self.entity_description.value_fn(self.mower_attributes) + + async def async_added_to_hass(self) -> None: + """Raise issue when entity is registered and was not disabled.""" + if TYPE_CHECKING: + assert self.unique_id + if not ( + entity_id := er.async_get(self.hass).async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, self.unique_id + ) + ): + return + if ( + self.enabled + and self.entity_description.key == "returning_to_dock" + and entity_used_in(self.hass, entity_id) + ): + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_entity_{self.entity_description.key}", + breaks_in_ha_version="2025.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_entity", + translation_placeholders={ + "entity_name": str(self.name), + "entity": entity_id, + }, + ) + else: + async_delete_issue( + self.hass, + DOMAIN, + f"deprecated_task_entity_{self.entity_description.key}", + ) + await super().async_added_to_hass() diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 05a18bcb19feb..0f06e9c521e6b 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -311,6 +311,12 @@ } } }, + "issues": { + "deprecated_entity": { + "title": "The Husqvarna Automower {entity_name} sensor is deprecated", + "description": "The Husqavarna Automower entity `{entity}` is deprecated and will be removed in a future release.\nYou can use the new returning state of the lawn mower entity instead.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`." + } + }, "services": { "override_schedule": { "name": "Override schedule", diff --git a/tests/components/husqvarna_automower/test_binary_sensor.py b/tests/components/husqvarna_automower/test_binary_sensor.py index 858dc03b93f94..30c9cc1bdd3d9 100644 --- a/tests/components/husqvarna_automower/test_binary_sensor.py +++ b/tests/components/husqvarna_automower/test_binary_sensor.py @@ -4,6 +4,7 @@ from aioautomower.model import MowerActivities, MowerAttributes from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL @@ -17,6 +18,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensor_states( hass: HomeAssistant, mock_automower_client: AsyncMock, @@ -50,6 +52,7 @@ async def test_binary_sensor_states( assert state.state == "on" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensor_snapshot( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 027a643f24e28c5a09dd762e4f656e608cc0c73a Mon Sep 17 00:00:00 2001 From: David Knowles Date: Fri, 15 Nov 2024 04:30:37 -0500 Subject: [PATCH 0479/1070] Make Hydrawise poll non-critical data less frequently (#130289) --- .../components/hydrawise/__init__.py | 23 ++++- .../components/hydrawise/binary_sensor.py | 14 ++- homeassistant/components/hydrawise/const.py | 3 +- .../components/hydrawise/coordinator.py | 96 ++++++++++++++----- homeassistant/components/hydrawise/sensor.py | 49 ++++++---- homeassistant/components/hydrawise/switch.py | 10 +- homeassistant/components/hydrawise/valve.py | 10 +- .../hydrawise/test_binary_sensor.py | 7 +- .../hydrawise/test_entity_availability.py | 5 +- tests/components/hydrawise/test_sensor.py | 38 +++++++- 10 files changed, 177 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index d2af8f37e3625..9e402cd49326c 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -7,8 +7,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import DOMAIN, SCAN_INTERVAL -from .coordinator import HydrawiseDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import ( + HydrawiseMainDataUpdateCoordinator, + HydrawiseUpdateCoordinators, + HydrawiseWaterUseDataUpdateCoordinator, +) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -29,9 +33,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]) ) - coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL) - await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + main_coordinator = HydrawiseMainDataUpdateCoordinator(hass, hydrawise) + await main_coordinator.async_config_entry_first_refresh() + water_use_coordinator = HydrawiseWaterUseDataUpdateCoordinator( + hass, hydrawise, main_coordinator + ) + await water_use_coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = ( + HydrawiseUpdateCoordinators( + main=main_coordinator, + water_use=water_use_coordinator, + ) + ) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 9b6dcadf95f97..34c31d3ad16d4 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.helpers.typing import VolDictType from .const import DOMAIN, SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND -from .coordinator import HydrawiseDataUpdateCoordinator +from .coordinator import HydrawiseUpdateCoordinators from .entity import HydrawiseEntity @@ -81,18 +81,16 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Hydrawise binary_sensor platform.""" - coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] entities: list[HydrawiseBinarySensor] = [] - for controller in coordinator.data.controllers.values(): + for controller in coordinators.main.data.controllers.values(): entities.extend( - HydrawiseBinarySensor(coordinator, description, controller) + HydrawiseBinarySensor(coordinators.main, description, controller) for description in CONTROLLER_BINARY_SENSORS ) entities.extend( HydrawiseBinarySensor( - coordinator, + coordinators.main, description, controller, sensor_id=sensor.id, @@ -103,7 +101,7 @@ async def async_setup_entry( ) entities.extend( HydrawiseZoneBinarySensor( - coordinator, description, controller, zone_id=zone.id + coordinators.main, description, controller, zone_id=zone.id ) for zone in controller.zones for description in ZONE_BINARY_SENSORS diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index 47b9bef845e4b..633c00ce65907 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -10,7 +10,8 @@ MANUFACTURER = "Hydrawise" -SCAN_INTERVAL = timedelta(seconds=60) +MAIN_SCAN_INTERVAL = timedelta(seconds=60) +WATER_USE_SCAN_INTERVAL = timedelta(minutes=60) SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 6cd233eb1dfec..e82a4ec158848 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -2,8 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import timedelta +from dataclasses import dataclass, field from pydrawise import Hydrawise from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone @@ -12,7 +11,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import now -from .const import DOMAIN, LOGGER +from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL @dataclass @@ -20,22 +19,39 @@ class HydrawiseData: """Container for data fetched from the Hydrawise API.""" user: User - controllers: dict[int, Controller] - zones: dict[int, Zone] - sensors: dict[int, Sensor] - daily_water_summary: dict[int, ControllerWaterUseSummary] + controllers: dict[int, Controller] = field(default_factory=dict) + zones: dict[int, Zone] = field(default_factory=dict) + sensors: dict[int, Sensor] = field(default_factory=dict) + daily_water_summary: dict[int, ControllerWaterUseSummary] = field( + default_factory=dict + ) + + +@dataclass +class HydrawiseUpdateCoordinators: + """Container for all Hydrawise DataUpdateCoordinator instances.""" + + main: HydrawiseMainDataUpdateCoordinator + water_use: HydrawiseWaterUseDataUpdateCoordinator class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): - """The Hydrawise Data Update Coordinator.""" + """Base class for Hydrawise Data Update Coordinators.""" api: Hydrawise - def __init__( - self, hass: HomeAssistant, api: Hydrawise, scan_interval: timedelta - ) -> None: + +class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): + """The main Hydrawise Data Update Coordinator. + + This fetches the primary state data for Hydrawise controllers and zones + at a relatively frequent interval so that the primary functions of the + integration are updated in a timely manner. + """ + + def __init__(self, hass: HomeAssistant, api: Hydrawise) -> None: """Initialize HydrawiseDataUpdateCoordinator.""" - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval) + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=MAIN_SCAN_INTERVAL) self.api = api async def _async_update_data(self) -> HydrawiseData: @@ -43,28 +59,56 @@ async def _async_update_data(self) -> HydrawiseData: # Don't fetch zones. We'll fetch them for each controller later. # This is to prevent 502 errors in some cases. # See: https://github.com/home-assistant/core/issues/120128 - user = await self.api.get_user(fetch_zones=False) - controllers = {} - zones = {} - sensors = {} - daily_water_summary: dict[int, ControllerWaterUseSummary] = {} - for controller in user.controllers: - controllers[controller.id] = controller + data = HydrawiseData(user=await self.api.get_user(fetch_zones=False)) + for controller in data.user.controllers: + data.controllers[controller.id] = controller controller.zones = await self.api.get_zones(controller) for zone in controller.zones: - zones[zone.id] = zone + data.zones[zone.id] = zone for sensor in controller.sensors: - sensors[sensor.id] = sensor + data.sensors[sensor.id] = sensor + return data + + +class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): + """Data Update Coordinator for Hydrawise Water Use. + + This fetches data that is more expensive for the Hydrawise API to compute + at a less frequent interval as to not overload the Hydrawise servers. + """ + + _main_coordinator: HydrawiseMainDataUpdateCoordinator + + def __init__( + self, + hass: HomeAssistant, + api: Hydrawise, + main_coordinator: HydrawiseMainDataUpdateCoordinator, + ) -> None: + """Initialize HydrawiseWaterUseDataUpdateCoordinator.""" + super().__init__( + hass, + LOGGER, + name=f"{DOMAIN} water use", + update_interval=WATER_USE_SCAN_INTERVAL, + ) + self.api = api + self._main_coordinator = main_coordinator + + async def _async_update_data(self) -> HydrawiseData: + """Fetch the latest data from Hydrawise.""" + daily_water_summary: dict[int, ControllerWaterUseSummary] = {} + for controller in self._main_coordinator.data.controllers.values(): daily_water_summary[controller.id] = await self.api.get_water_use_summary( controller, now().replace(hour=0, minute=0, second=0, microsecond=0), now(), ) - + main_data = self._main_coordinator.data return HydrawiseData( - user=user, - controllers=controllers, - zones=zones, - sensors=sensors, + user=main_data.user, + controllers=main_data.controllers, + zones=main_data.zones, + sensors=main_data.sensors, daily_water_summary=daily_water_summary, ) diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 563af893700af..1d8c75d5437a7 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -19,7 +19,7 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN -from .coordinator import HydrawiseDataUpdateCoordinator +from .coordinator import HydrawiseUpdateCoordinators from .entity import HydrawiseEntity @@ -92,7 +92,7 @@ def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | No return daily_water_summary.total_use -CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = ( +WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = ( HydrawiseSensorEntityDescription( key="daily_active_water_time", translation_key="daily_active_water_time", @@ -103,6 +103,16 @@ def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | No ) +WATER_USE_ZONE_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = ( + HydrawiseSensorEntityDescription( + key="daily_active_water_time", + translation_key="daily_active_water_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + value_fn=_get_zone_daily_active_water_time, + ), +) + FLOW_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = ( HydrawiseSensorEntityDescription( key="daily_total_water_use", @@ -150,13 +160,6 @@ def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | No native_unit_of_measurement=UnitOfTime.MINUTES, value_fn=_get_zone_watering_time, ), - HydrawiseSensorEntityDescription( - key="daily_active_water_time", - translation_key="daily_active_water_time", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - value_fn=_get_zone_daily_active_water_time, - ), ) FLOW_MEASUREMENT_KEYS = [x.key for x in FLOW_CONTROLLER_SENSORS] @@ -168,29 +171,37 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Hydrawise sensor platform.""" - coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] entities: list[HydrawiseSensor] = [] - for controller in coordinator.data.controllers.values(): + for controller in coordinators.main.data.controllers.values(): entities.extend( - HydrawiseSensor(coordinator, description, controller) - for description in CONTROLLER_SENSORS + HydrawiseSensor(coordinators.water_use, description, controller) + for description in WATER_USE_CONTROLLER_SENSORS ) entities.extend( - HydrawiseSensor(coordinator, description, controller, zone_id=zone.id) + HydrawiseSensor( + coordinators.water_use, description, controller, zone_id=zone.id + ) + for zone in controller.zones + for description in WATER_USE_ZONE_SENSORS + ) + entities.extend( + HydrawiseSensor(coordinators.main, description, controller, zone_id=zone.id) for zone in controller.zones for description in ZONE_SENSORS ) - if coordinator.data.daily_water_summary[controller.id].total_use is not None: + if ( + coordinators.water_use.data.daily_water_summary[controller.id].total_use + is not None + ): # we have a flow sensor for this controller entities.extend( - HydrawiseSensor(coordinator, description, controller) + HydrawiseSensor(coordinators.water_use, description, controller) for description in FLOW_CONTROLLER_SENSORS ) entities.extend( HydrawiseSensor( - coordinator, + coordinators.water_use, description, controller, zone_id=zone.id, diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 001a8e399ee86..1addaf1ec927a 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -20,7 +20,7 @@ from homeassistant.util import dt as dt_util from .const import DEFAULT_WATERING_TIME, DOMAIN -from .coordinator import HydrawiseDataUpdateCoordinator +from .coordinator import HydrawiseUpdateCoordinators from .entity import HydrawiseEntity @@ -66,12 +66,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Hydrawise switch platform.""" - coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - HydrawiseSwitch(coordinator, description, controller, zone_id=zone.id) - for controller in coordinator.data.controllers.values() + HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id) + for controller in coordinators.main.data.controllers.values() for zone in controller.zones for description in SWITCH_TYPES ) diff --git a/homeassistant/components/hydrawise/valve.py b/homeassistant/components/hydrawise/valve.py index 6ceb3673c718f..37f196bc05434 100644 --- a/homeassistant/components/hydrawise/valve.py +++ b/homeassistant/components/hydrawise/valve.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import HydrawiseDataUpdateCoordinator +from .coordinator import HydrawiseUpdateCoordinators from .entity import HydrawiseEntity VALVE_TYPES: tuple[ValveEntityDescription, ...] = ( @@ -34,12 +34,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Hydrawise valve platform.""" - coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - HydrawiseValve(coordinator, description, controller, zone_id=zone.id) - for controller in coordinator.data.controllers.values() + HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id) + for controller in coordinators.main.data.controllers.values() for zone in controller.zones for description in VALVE_TYPES ) diff --git a/tests/components/hydrawise/test_binary_sensor.py b/tests/components/hydrawise/test_binary_sensor.py index a42f9b1c04421..40cd32920b02a 100644 --- a/tests/components/hydrawise/test_binary_sensor.py +++ b/tests/components/hydrawise/test_binary_sensor.py @@ -9,7 +9,7 @@ from pydrawise.schema import Controller from syrupy.assertion import SnapshotAssertion -from homeassistant.components.hydrawise.const import SCAN_INTERVAL +from homeassistant.components.hydrawise.const import MAIN_SCAN_INTERVAL from homeassistant.const import STATE_OFF, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -42,7 +42,8 @@ async def test_update_data_fails( # Make the coordinator refresh data. mock_pydrawise.get_user.reset_mock(return_value=True) mock_pydrawise.get_user.side_effect = ClientError - freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) + mock_pydrawise.get_water_use_summary.side_effect = ClientError + freezer.tick(MAIN_SCAN_INTERVAL + timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -61,7 +62,7 @@ async def test_controller_offline( """Test the binary_sensor for the controller being online.""" # Make the coordinator refresh data. controller.online = False - freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) + freezer.tick(MAIN_SCAN_INTERVAL + timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/hydrawise/test_entity_availability.py b/tests/components/hydrawise/test_entity_availability.py index 58ded5fe6c3e3..27587425c31d2 100644 --- a/tests/components/hydrawise/test_entity_availability.py +++ b/tests/components/hydrawise/test_entity_availability.py @@ -8,7 +8,7 @@ from freezegun.api import FrozenDateTimeFactory from pydrawise.schema import Controller -from homeassistant.components.hydrawise.const import SCAN_INTERVAL +from homeassistant.components.hydrawise.const import WATER_USE_SCAN_INTERVAL from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -42,7 +42,8 @@ async def test_api_offline( config_entry = await mock_add_config_entry() mock_pydrawise.get_user.reset_mock(return_value=True) mock_pydrawise.get_user.side_effect = ClientError - freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) + mock_pydrawise.get_water_use_summary.side_effect = ClientError + freezer.tick(WATER_USE_SCAN_INTERVAL + timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done() _test_availability(hass, config_entry, entity_registry) diff --git a/tests/components/hydrawise/test_sensor.py b/tests/components/hydrawise/test_sensor.py index b9ff99f0013c8..1c14a07f1823a 100644 --- a/tests/components/hydrawise/test_sensor.py +++ b/tests/components/hydrawise/test_sensor.py @@ -1,12 +1,18 @@ """Test Hydrawise sensor.""" from collections.abc import Awaitable, Callable -from unittest.mock import patch +from datetime import timedelta +from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory from pydrawise.schema import Controller, ControllerWaterUseSummary, User, Zone import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.hydrawise.const import ( + MAIN_SCAN_INTERVAL, + WATER_USE_SCAN_INTERVAL, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -16,7 +22,7 @@ UnitSystem, ) -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.freeze_time("2023-10-01 00:00:00+00:00") @@ -50,6 +56,34 @@ async def test_suspended_state( assert next_cycle.state == "unknown" +@pytest.mark.freeze_time("2024-11-01 00:00:00+00:00") +async def test_usage_refresh( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + controller_water_use_summary: ControllerWaterUseSummary, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that water usage summaries refresh less frequently than other data.""" + assert hass.states.get("sensor.zone_one_daily_active_water_use") is not None + mock_pydrawise.get_water_use_summary.assert_called_once() + + # Make the coordinator refresh data. + mock_pydrawise.get_water_use_summary.reset_mock() + freezer.tick(MAIN_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + # Make sure we didn't fetch water use summary again. + mock_pydrawise.get_water_use_summary.assert_not_called() + + # Wait for enough time to pass for a water use summary fetch. + mock_pydrawise.get_water_use_summary.return_value = controller_water_use_summary + freezer.tick(WATER_USE_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_pydrawise.get_water_use_summary.assert_called_once() + + async def test_no_sensor_and_water_state( hass: HomeAssistant, controller: Controller, From c1f3372980d25176a2c24201bdf5eb317b046d66 Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Fri, 15 Nov 2024 01:36:24 -0800 Subject: [PATCH 0480/1070] Bump python-smarttub to 0.0.38 (#130679) --- homeassistant/components/smarttub/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index f2514063a4091..432f6338d9f99 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["smarttub"], "quality_scale": "platinum", - "requirements": ["python-smarttub==0.0.36"] + "requirements": ["python-smarttub==0.0.38"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3d6a152ed589c..96fad9deaa21a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2405,7 +2405,7 @@ python-ripple-api==0.0.3 python-roborock==2.7.2 # homeassistant.components.smarttub -python-smarttub==0.0.36 +python-smarttub==0.0.38 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e382455593c94..0a1908351e1ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1926,7 +1926,7 @@ python-rabbitair==0.0.8 python-roborock==2.7.2 # homeassistant.components.smarttub -python-smarttub==0.0.36 +python-smarttub==0.0.38 # homeassistant.components.songpal python-songpal==0.16.2 From 76f065ce44e05c6f91e73114f3cec4b842b3b975 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 15 Nov 2024 10:41:23 +0100 Subject: [PATCH 0481/1070] Fix Reolink firmware updates by uploading directly (#127007) --- homeassistant/components/reolink/update.py | 206 ++++++++++++--------- tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_update.py | 74 +++++++- 3 files changed, 193 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 5738411fa724c..33e446e8b2520 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -3,11 +3,10 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime from typing import Any from reolink_aio.exceptions import ReolinkError -from reolink_aio.software_version import NewSoftwareVersion +from reolink_aio.software_version import NewSoftwareVersion, SoftwareVersion from homeassistant.components.update import ( UpdateDeviceClass, @@ -19,7 +18,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) +from . import DEVICE_UPDATE_INTERVAL from .entity import ( ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription, @@ -28,7 +32,9 @@ ) from .util import ReolinkConfigEntry, ReolinkData +RESUME_AFTER_INSTALL = 15 POLL_AFTER_INSTALL = 120 +POLL_PROGRESS = 2 @dataclass(frozen=True, kw_only=True) @@ -86,25 +92,28 @@ async def async_setup_entry( async_add_entities(entities) -class ReolinkUpdateEntity( - ReolinkChannelCoordinatorEntity, - UpdateEntity, +class ReolinkUpdateBaseEntity( + CoordinatorEntity[DataUpdateCoordinator[None]], UpdateEntity ): - """Base update entity class for Reolink IP cameras.""" + """Base update entity class for Reolink.""" - entity_description: ReolinkUpdateEntityDescription _attr_release_url = "https://reolink.com/download-center/" def __init__( self, reolink_data: ReolinkData, - channel: int, - entity_description: ReolinkUpdateEntityDescription, + channel: int | None, + coordinator: DataUpdateCoordinator[None], ) -> None: """Initialize Reolink update entity.""" - self.entity_description = entity_description - super().__init__(reolink_data, channel, reolink_data.firmware_coordinator) + CoordinatorEntity.__init__(self, coordinator) + self._channel = channel + self._host = reolink_data.host self._cancel_update: CALLBACK_TYPE | None = None + self._cancel_resume: CALLBACK_TYPE | None = None + self._cancel_progress: CALLBACK_TYPE | None = None + self._installing: bool = False + self._reolink_data = reolink_data @property def installed_version(self) -> str | None: @@ -123,6 +132,16 @@ def latest_version(self) -> str | None: return new_firmware.version_string + @property + def in_progress(self) -> bool: + """Update installation progress.""" + return self._host.api.sw_upload_progress(self._channel) < 100 + + @property + def update_percentage(self) -> int: + """Update installation progress.""" + return self._host.api.sw_upload_progress(self._channel) + @property def supported_features(self) -> UpdateEntityFeature: """Flag supported features.""" @@ -130,8 +149,27 @@ def supported_features(self) -> UpdateEntityFeature: new_firmware = self._host.api.firmware_update_available(self._channel) if isinstance(new_firmware, NewSoftwareVersion): supported_features |= UpdateEntityFeature.RELEASE_NOTES + supported_features |= UpdateEntityFeature.PROGRESS return supported_features + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self._installing or self._cancel_update is not None: + return True + return super().available + + def version_is_newer(self, latest_version: str, installed_version: str) -> bool: + """Return True if latest_version is newer than installed_version.""" + try: + installed = SoftwareVersion(installed_version) + latest = SoftwareVersion(latest_version) + except ReolinkError: + # when the online update API returns a unexpected string + return True + + return latest > installed + async def async_release_notes(self) -> str | None: """Return the release notes.""" new_firmware = self._host.api.firmware_update_available(self._channel) @@ -148,6 +186,11 @@ async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install the latest firmware version.""" + self._installing = True + await self._pause_update_coordinator() + self._cancel_progress = async_call_later( + self.hass, POLL_PROGRESS, self._async_update_progress + ) try: await self._host.api.update_firmware(self._channel) except ReolinkError as err: @@ -159,10 +202,38 @@ async def async_install( self._cancel_update = async_call_later( self.hass, POLL_AFTER_INSTALL, self._async_update_future ) + self._cancel_resume = async_call_later( + self.hass, RESUME_AFTER_INSTALL, self._resume_update_coordinator + ) + self._installing = False + + async def _pause_update_coordinator(self) -> None: + """Pause updating the states using the data update coordinator (during reboots).""" + self._reolink_data.device_coordinator.update_interval = None + self._reolink_data.device_coordinator.async_set_updated_data(None) + + async def _resume_update_coordinator(self, *args) -> None: + """Resume updating the states using the data update coordinator (after reboots).""" + self._reolink_data.device_coordinator.update_interval = DEVICE_UPDATE_INTERVAL + try: + await self._reolink_data.device_coordinator.async_refresh() + finally: + self._cancel_resume = None + + async def _async_update_progress(self, *args) -> None: + """Request update.""" + self.async_write_ha_state() + if self._installing: + self._cancel_progress = async_call_later( + self.hass, POLL_PROGRESS, self._async_update_progress + ) - async def _async_update_future(self, now: datetime | None = None) -> None: + async def _async_update_future(self, *args) -> None: """Request update.""" - await self.async_update() + try: + await self.async_update() + finally: + self._cancel_update = None async def async_added_to_hass(self) -> None: """Entity created.""" @@ -176,16 +247,44 @@ async def async_will_remove_from_hass(self) -> None: self._host.firmware_ch_list.remove(self._channel) if self._cancel_update is not None: self._cancel_update() + if self._cancel_progress is not None: + self._cancel_progress() + if self._cancel_resume is not None: + self._cancel_resume() + + +class ReolinkUpdateEntity( + ReolinkUpdateBaseEntity, + ReolinkChannelCoordinatorEntity, +): + """Base update entity class for Reolink IP cameras.""" + + entity_description: ReolinkUpdateEntityDescription + _channel: int + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkUpdateEntityDescription, + ) -> None: + """Initialize Reolink update entity.""" + self.entity_description = entity_description + ReolinkUpdateBaseEntity.__init__( + self, reolink_data, channel, reolink_data.firmware_coordinator + ) + ReolinkChannelCoordinatorEntity.__init__( + self, reolink_data, channel, reolink_data.firmware_coordinator + ) class ReolinkHostUpdateEntity( + ReolinkUpdateBaseEntity, ReolinkHostCoordinatorEntity, - UpdateEntity, ): """Update entity class for Reolink Host.""" entity_description: ReolinkHostUpdateEntityDescription - _attr_release_url = "https://reolink.com/download-center/" def __init__( self, @@ -194,76 +293,9 @@ def __init__( ) -> None: """Initialize Reolink update entity.""" self.entity_description = entity_description - super().__init__(reolink_data, reolink_data.firmware_coordinator) - self._cancel_update: CALLBACK_TYPE | None = None - - @property - def installed_version(self) -> str | None: - """Version currently in use.""" - return self._host.api.sw_version - - @property - def latest_version(self) -> str | None: - """Latest version available for install.""" - new_firmware = self._host.api.firmware_update_available() - if not new_firmware: - return self.installed_version - - if isinstance(new_firmware, str): - return new_firmware - - return new_firmware.version_string - - @property - def supported_features(self) -> UpdateEntityFeature: - """Flag supported features.""" - supported_features = UpdateEntityFeature.INSTALL - new_firmware = self._host.api.firmware_update_available() - if isinstance(new_firmware, NewSoftwareVersion): - supported_features |= UpdateEntityFeature.RELEASE_NOTES - return supported_features - - async def async_release_notes(self) -> str | None: - """Return the release notes.""" - new_firmware = self._host.api.firmware_update_available() - assert isinstance(new_firmware, NewSoftwareVersion) - - return ( - "If the install button fails, download this" - f" [firmware zip file]({new_firmware.download_url})." - " Then, follow the installation guide (PDF in the zip file).\n\n" - f"## Release notes\n\n{new_firmware.release_notes}" + ReolinkUpdateBaseEntity.__init__( + self, reolink_data, None, reolink_data.firmware_coordinator + ) + ReolinkHostCoordinatorEntity.__init__( + self, reolink_data, reolink_data.firmware_coordinator ) - - async def async_install( - self, version: str | None, backup: bool, **kwargs: Any - ) -> None: - """Install the latest firmware version.""" - try: - await self._host.api.update_firmware() - except ReolinkError as err: - raise HomeAssistantError( - f"Error trying to update Reolink firmware: {err}" - ) from err - finally: - self.async_write_ha_state() - self._cancel_update = async_call_later( - self.hass, POLL_AFTER_INSTALL, self._async_update_future - ) - - async def _async_update_future(self, now: datetime | None = None) -> None: - """Request update.""" - await self.async_update() - - async def async_added_to_hass(self) -> None: - """Entity created.""" - await super().async_added_to_hass() - self._host.firmware_ch_list.append(None) - - async def async_will_remove_from_hass(self) -> None: - """Entity removed.""" - await super().async_will_remove_from_hass() - if None in self._host.firmware_ch_list: - self._host.firmware_ch_list.remove(None) - if self._cancel_update is not None: - self._cancel_update() diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 94192c3502e08..81865d9880130 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -86,6 +86,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.sw_version_update_required = False host_mock.hardware_version = "IPC_00000" host_mock.sw_version = "v1.0.0.0.0.0000" + host_mock.sw_upload_progress.return_value = 100 host_mock.manufacturer = "Reolink" host_mock.model = TEST_HOST_MODEL host_mock.item_number = TEST_ITEM_NUMBER diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py index a13009204d7a3..a6cfe862963f3 100644 --- a/tests/components/reolink/test_update.py +++ b/tests/components/reolink/test_update.py @@ -1,5 +1,7 @@ """Test the Reolink update platform.""" +import asyncio +from datetime import timedelta from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory @@ -7,12 +9,13 @@ from reolink_aio.exceptions import ReolinkError from reolink_aio.software_version import NewSoftwareVersion -from homeassistant.components.reolink.update import POLL_AFTER_INSTALL +from homeassistant.components.reolink.update import POLL_AFTER_INSTALL, POLL_PROGRESS from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.dt import utcnow from .conftest import TEST_CAM_NAME, TEST_NVR_NAME @@ -73,6 +76,7 @@ async def test_update_firm( ) -> None: """Test update state when update available with firmware info from reolink.com.""" reolink_connect.camera_name.return_value = TEST_CAM_NAME + reolink_connect.sw_upload_progress.return_value = 100 reolink_connect.camera_sw_version.return_value = "v1.1.0.0.0.0000" new_firmware = NewSoftwareVersion( version_string="v3.3.0.226_23031644", @@ -88,6 +92,8 @@ async def test_update_firm( entity_id = f"{Platform.UPDATE}.{entity_name}_firmware" assert hass.states.get(entity_id).state == STATE_ON + assert not hass.states.get(entity_id).attributes["in_progress"] + assert hass.states.get(entity_id).attributes["update_percentage"] is None # release notes client = await hass_ws_client(hass) @@ -113,6 +119,22 @@ async def test_update_firm( ) reolink_connect.update_firmware.assert_called() + reolink_connect.sw_upload_progress.return_value = 50 + freezer.tick(POLL_PROGRESS) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).attributes["in_progress"] + assert hass.states.get(entity_id).attributes["update_percentage"] == 50 + + reolink_connect.sw_upload_progress.return_value = 100 + freezer.tick(POLL_AFTER_INSTALL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert not hass.states.get(entity_id).attributes["in_progress"] + assert hass.states.get(entity_id).attributes["update_percentage"] is None + reolink_connect.update_firmware.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -132,3 +154,53 @@ async def test_update_firm( assert hass.states.get(entity_id).state == STATE_OFF reolink_connect.update_firmware.side_effect = None + + +@pytest.mark.parametrize("entity_name", [TEST_NVR_NAME, TEST_CAM_NAME]) +async def test_update_firm_keeps_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + hass_ws_client: WebSocketGenerator, + entity_name: str, +) -> None: + """Test update entity keeps being available during update.""" + reolink_connect.camera_name.return_value = TEST_CAM_NAME + reolink_connect.camera_sw_version.return_value = "v1.1.0.0.0.0000" + new_firmware = NewSoftwareVersion( + version_string="v3.3.0.226_23031644", + download_url=TEST_DOWNLOAD_URL, + release_notes=TEST_RELEASE_NOTES, + ) + reolink_connect.firmware_update_available.return_value = new_firmware + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.UPDATE}.{entity_name}_firmware" + assert hass.states.get(entity_id).state == STATE_ON + + async def mock_update_firmware(*args, **kwargs) -> None: + await asyncio.sleep(0.000005) + + reolink_connect.update_firmware = mock_update_firmware + + # test install + with patch("homeassistant.components.reolink.update.POLL_PROGRESS", 0.000001): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + reolink_connect.session_active = False + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + + # still available + assert hass.states.get(entity_id).state == STATE_ON + + reolink_connect.session_active = True From 6e94466f47f563aa811c159b90688c038cb859ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 Nov 2024 10:54:45 +0100 Subject: [PATCH 0482/1070] Bump github/codeql-action from 3.27.3 to 3.27.4 (#130670) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 48e37717232ac..b9ccece34b9b3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.27.3 + uses: github/codeql-action/init@v3.27.4 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.27.3 + uses: github/codeql-action/analyze@v3.27.4 with: category: "/language:python" From 1277e8303806dd66b8655eff236c35d3f0ced07f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:13:21 +0100 Subject: [PATCH 0483/1070] Use BLOOD_GLUCOSE_CONCENTRATION device class in dexcom (#130526) --- homeassistant/components/dexcom/__init__.py | 16 +----- .../components/dexcom/config_flow.py | 43 ++------------- homeassistant/components/dexcom/const.py | 3 -- homeassistant/components/dexcom/sensor.py | 18 +++---- tests/components/dexcom/__init__.py | 7 ++- tests/components/dexcom/test_config_flow.py | 54 +------------------ tests/components/dexcom/test_sensor.py | 40 +------------- 7 files changed, 21 insertions(+), 160 deletions(-) diff --git a/homeassistant/components/dexcom/__init__.py b/homeassistant/components/dexcom/__init__.py index b9a3bdba12d95..e93e8e6635843 100644 --- a/homeassistant/components/dexcom/__init__.py +++ b/homeassistant/components/dexcom/__init__.py @@ -6,12 +6,12 @@ from pydexcom import AccountError, Dexcom, GlucoseReading, SessionError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_SERVER, DOMAIN, MG_DL, PLATFORMS, SERVER_OUS +from .const import CONF_SERVER, DOMAIN, PLATFORMS, SERVER_OUS _LOGGER = logging.getLogger(__name__) @@ -32,11 +32,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SessionError as error: raise ConfigEntryNotReady from error - if not entry.options: - hass.config_entries.async_update_entry( - entry, options={CONF_UNIT_OF_MEASUREMENT: MG_DL} - ) - async def async_update_data(): try: return await hass.async_add_executor_job(dexcom.get_current_glucose_reading) @@ -55,8 +50,6 @@ async def async_update_data(): hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -67,8 +60,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/dexcom/config_flow.py b/homeassistant/components/dexcom/config_flow.py index c5c830dedf69d..90917e0ce2c24 100644 --- a/homeassistant/components/dexcom/config_flow.py +++ b/homeassistant/components/dexcom/config_flow.py @@ -7,16 +7,10 @@ from pydexcom import AccountError, Dexcom, SessionError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) -from homeassistant.const import CONF_PASSWORD, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME -from homeassistant.core import callback +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import CONF_SERVER, DOMAIN, MG_DL, MMOL_L, SERVER_OUS, SERVER_US +from .const import CONF_SERVER, DOMAIN, SERVER_OUS, SERVER_US DATA_SCHEMA = vol.Schema( { @@ -62,34 +56,3 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> DexcomOptionsFlowHandler: - """Get the options flow for this handler.""" - return DexcomOptionsFlowHandler() - - -class DexcomOptionsFlowHandler(OptionsFlow): - """Handle a option flow for Dexcom.""" - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - data_schema = vol.Schema( - { - vol.Optional( - CONF_UNIT_OF_MEASUREMENT, - default=self.config_entry.options.get( - CONF_UNIT_OF_MEASUREMENT, MG_DL - ), - ): vol.In({MG_DL, MMOL_L}), - } - ) - return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/dexcom/const.py b/homeassistant/components/dexcom/const.py index 487a844eb2b18..66999e51e4b9f 100644 --- a/homeassistant/components/dexcom/const.py +++ b/homeassistant/components/dexcom/const.py @@ -5,9 +5,6 @@ DOMAIN = "dexcom" PLATFORMS = [Platform.SENSOR] -MMOL_L = "mmol/L" -MG_DL = "mg/dL" - CONF_SERVER = "server" SERVER_OUS = "EU" diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index 10b30f39fcb55..850678e7ac9a8 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME +from homeassistant.const import CONF_USERNAME, UnitOfBloodGlucoseConcentration from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -15,7 +15,7 @@ DataUpdateCoordinator, ) -from .const import DOMAIN, MG_DL +from .const import DOMAIN TRENDS = { 1: "rising_quickly", @@ -36,13 +36,10 @@ async def async_setup_entry( """Set up the Dexcom sensors.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] username = config_entry.data[CONF_USERNAME] - unit_of_measurement = config_entry.options[CONF_UNIT_OF_MEASUREMENT] async_add_entities( [ DexcomGlucoseTrendSensor(coordinator, username, config_entry.entry_id), - DexcomGlucoseValueSensor( - coordinator, username, config_entry.entry_id, unit_of_measurement - ), + DexcomGlucoseValueSensor(coordinator, username, config_entry.entry_id), ], ) @@ -73,6 +70,10 @@ def __init__( class DexcomGlucoseValueSensor(DexcomSensorEntity): """Representation of a Dexcom glucose value sensor.""" + _attr_device_class = SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION + _attr_native_unit_of_measurement = ( + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER + ) _attr_translation_key = "glucose_value" def __init__( @@ -80,18 +81,15 @@ def __init__( coordinator: DataUpdateCoordinator, username: str, entry_id: str, - unit_of_measurement: str, ) -> None: """Initialize the sensor.""" super().__init__(coordinator, username, entry_id, "value") - self._attr_native_unit_of_measurement = unit_of_measurement - self._key = "mg_dl" if unit_of_measurement == MG_DL else "mmol_l" @property def native_value(self): """Return the state of the sensor.""" if self.coordinator.data: - return getattr(self.coordinator.data, self._key) + return self.coordinator.data.mg_dl return None diff --git a/tests/components/dexcom/__init__.py b/tests/components/dexcom/__init__.py index adc9c56049a2e..10a742070d6eb 100644 --- a/tests/components/dexcom/__init__.py +++ b/tests/components/dexcom/__init__.py @@ -1,6 +1,7 @@ """Tests for the Dexcom integration.""" import json +from typing import Any from unittest.mock import patch from pydexcom import GlucoseReading @@ -20,14 +21,16 @@ GLUCOSE_READING = GlucoseReading(json.loads(load_fixture("data.json", "dexcom"))) -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, options: dict[str, Any] | None = None +) -> MockConfigEntry: """Set up the Dexcom integration in Home Assistant.""" entry = MockConfigEntry( domain=DOMAIN, title="test_username", unique_id="test_username", data=CONFIG, - options=None, + options=options, ) with ( patch( diff --git a/tests/components/dexcom/test_config_flow.py b/tests/components/dexcom/test_config_flow.py index e8893e21d0e25..0a7338c13da9b 100644 --- a/tests/components/dexcom/test_config_flow.py +++ b/tests/components/dexcom/test_config_flow.py @@ -5,15 +5,13 @@ from pydexcom import AccountError, SessionError from homeassistant import config_entries -from homeassistant.components.dexcom.const import DOMAIN, MG_DL, MMOL_L -from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME +from homeassistant.components.dexcom.const import DOMAIN +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import CONFIG -from tests.common import MockConfigEntry - async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -101,51 +99,3 @@ async def test_form_unknown_error(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} - - -async def test_option_flow_default(hass: HomeAssistant) -> None: - """Test config flow options.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - options=None, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"] == { - CONF_UNIT_OF_MEASUREMENT: MG_DL, - } - - -async def test_option_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - options={CONF_UNIT_OF_MEASUREMENT: MG_DL}, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_UNIT_OF_MEASUREMENT: MMOL_L}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_UNIT_OF_MEASUREMENT: MMOL_L, - } diff --git a/tests/components/dexcom/test_sensor.py b/tests/components/dexcom/test_sensor.py index 1b7f0b026ab46..5c0a5280ad61d 100644 --- a/tests/components/dexcom/test_sensor.py +++ b/tests/components/dexcom/test_sensor.py @@ -4,12 +4,7 @@ from pydexcom import SessionError -from homeassistant.components.dexcom.const import MMOL_L -from homeassistant.const import ( - CONF_UNIT_OF_MEASUREMENT, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity @@ -58,36 +53,3 @@ async def test_sensors_update_failed(hass: HomeAssistant) -> None: assert test_username_glucose_value.state == STATE_UNAVAILABLE test_username_glucose_trend = hass.states.get("sensor.test_username_glucose_trend") assert test_username_glucose_trend.state == STATE_UNAVAILABLE - - -async def test_sensors_options_changed(hass: HomeAssistant) -> None: - """Test we handle sensor unavailable.""" - entry = await init_integration(hass) - - test_username_glucose_value = hass.states.get("sensor.test_username_glucose_value") - assert test_username_glucose_value.state == str(GLUCOSE_READING.value) - test_username_glucose_trend = hass.states.get("sensor.test_username_glucose_trend") - assert test_username_glucose_trend.state == GLUCOSE_READING.trend_description - - with ( - patch( - "homeassistant.components.dexcom.Dexcom.get_current_glucose_reading", - return_value=GLUCOSE_READING, - ), - patch( - "homeassistant.components.dexcom.Dexcom.create_session", - return_value="test_session_id", - ), - ): - hass.config_entries.async_update_entry( - entry=entry, - options={CONF_UNIT_OF_MEASUREMENT: MMOL_L}, - ) - await hass.async_block_till_done() - - assert entry.options == {CONF_UNIT_OF_MEASUREMENT: MMOL_L} - - test_username_glucose_value = hass.states.get("sensor.test_username_glucose_value") - assert test_username_glucose_value.state == str(GLUCOSE_READING.mmol_l) - test_username_glucose_trend = hass.states.get("sensor.test_username_glucose_trend") - assert test_username_glucose_trend.state == GLUCOSE_READING.trend_description From a57233c152fa64ff0f512f47ba581ae5df333807 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:13:43 +0100 Subject: [PATCH 0484/1070] Improve type hints in roomba config flow (#130512) --- homeassistant/components/roomba/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index e48d2d9113956..d040074246adf 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -79,7 +79,7 @@ class RoombaConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 name: str | None = None - blid: str | None = None + blid: str host: str | None = None def __init__(self) -> None: From 747c05a263c2c2ea9a1b08061e9b511ecbd8ff7b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:13:58 +0100 Subject: [PATCH 0485/1070] Improve type hints in starline config flow (#130507) --- homeassistant/components/starline/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py index 5235bd5230b64..a899b562f3607 100644 --- a/homeassistant/components/starline/config_flow.py +++ b/homeassistant/components/starline/config_flow.py @@ -34,6 +34,7 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): _app_code: str _app_token: str _captcha_image: str + _phone_number: str def __init__(self) -> None: """Initialize flow.""" @@ -49,7 +50,6 @@ def __init__(self) -> None: self._slnet_token_expires = None self._captcha_sid: str | None = None self._captcha_code: str | None = None - self._phone_number = None self._auth = StarlineAuth() From 2788ddec3a374b736e2c13dc089106d31ef52a70 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:14:20 +0100 Subject: [PATCH 0486/1070] Improve type hints in aussie_broadband config flow (#130506) --- homeassistant/components/aussie_broadband/config_flow.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/aussie_broadband/config_flow.py b/homeassistant/components/aussie_broadband/config_flow.py index 5bc6ed1aa5cf9..72ff0b3b2b258 100644 --- a/homeassistant/components/aussie_broadband/config_flow.py +++ b/homeassistant/components/aussie_broadband/config_flow.py @@ -22,13 +22,14 @@ class AussieBroadbandConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _reauth_username: str + def __init__(self) -> None: """Initialize the config flow.""" self.data: dict = {} self.options: dict = {CONF_SERVICES: []} self.services: list[dict[str, Any]] = [] self.client: AussieBB | None = None - self._reauth_username: str | None = None async def async_auth(self, user_input: dict[str, str]) -> dict[str, str] | None: """Reusable Auth Helper.""" @@ -92,7 +93,7 @@ async def async_step_reauth_confirm( errors: dict[str, str] | None = None - if user_input and self._reauth_username: + if user_input: data = { CONF_USERNAME: self._reauth_username, CONF_PASSWORD: user_input[CONF_PASSWORD], From 5ba5ffdacda0b5e05a49f7df47e3ff3a2d9916bd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:19:30 +0100 Subject: [PATCH 0487/1070] Improve type hints in motionblinds_ble config flow (#130439) --- homeassistant/components/motionblinds_ble/config_flow.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/motionblinds_ble/config_flow.py b/homeassistant/components/motionblinds_ble/config_flow.py index d99096d3a09b1..30417c62c6538 100644 --- a/homeassistant/components/motionblinds_ble/config_flow.py +++ b/homeassistant/components/motionblinds_ble/config_flow.py @@ -48,11 +48,12 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Motionblinds Bluetooth.""" + _display_name: str + def __init__(self) -> None: """Initialize a ConfigFlow.""" self._discovery_info: BluetoothServiceInfoBleak | BLEDevice | None = None self._mac_code: str | None = None - self._display_name: str | None = None self._blind_type: MotionBlindType | None = None async def async_step_bluetooth( @@ -67,8 +68,8 @@ async def async_step_bluetooth( self._discovery_info = discovery_info self._mac_code = get_mac_from_local_name(discovery_info.name) - self._display_name = display_name = DISPLAY_NAME.format(mac_code=self._mac_code) - self.context["title_placeholders"] = {"name": display_name} + self._display_name = DISPLAY_NAME.format(mac_code=self._mac_code) + self.context["title_placeholders"] = {"name": self._display_name} return await self.async_step_confirm() @@ -113,7 +114,7 @@ async def async_step_confirm( assert self._discovery_info is not None return self.async_create_entry( - title=str(self._display_name), + title=self._display_name, data={ CONF_ADDRESS: self._discovery_info.address, CONF_LOCAL_NAME: self._discovery_info.name, From 7b1be8af30a0f949b5d951f7589076797a59a351 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:21:26 +0100 Subject: [PATCH 0488/1070] Improve type hints in smlight config flow (#130435) --- homeassistant/components/smlight/config_flow.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 32efc729dc251..92b543e04410c 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -34,10 +34,11 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMLIGHT Zigbee.""" + host: str + def __init__(self) -> None: """Initialize the config flow.""" self.client: Api2 - self.host: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -46,9 +47,8 @@ async def async_step_user( errors: dict[str, str] = {} if user_input is not None: - host = user_input[CONF_HOST] - self.client = Api2(host, session=async_get_clientsession(self.hass)) - self.host = host + self.host = user_input[CONF_HOST] + self.client = Api2(self.host, session=async_get_clientsession(self.hass)) try: if not await self._async_check_auth_required(user_input): @@ -138,9 +138,8 @@ async def async_step_reauth( ) -> ConfigFlowResult: """Handle reauth when API Authentication failed.""" - host = entry_data[CONF_HOST] - self.client = Api2(host, session=async_get_clientsession(self.hass)) - self.host = host + self.host = entry_data[CONF_HOST] + self.client = Api2(self.host, session=async_get_clientsession(self.hass)) return await self.async_step_reauth_confirm() From e45d4434e7821627e031b0fb3da55f7f41e0e26b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:25:19 +0100 Subject: [PATCH 0489/1070] Improve type hints in soundtouch config flow (#130431) --- homeassistant/components/soundtouch/config_flow.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/soundtouch/config_flow.py b/homeassistant/components/soundtouch/config_flow.py index 7e3fb2ca8c397..af45b8f6bdc75 100644 --- a/homeassistant/components/soundtouch/config_flow.py +++ b/homeassistant/components/soundtouch/config_flow.py @@ -1,6 +1,5 @@ """Config flow for Bose SoundTouch integration.""" -import logging from typing import Any from libsoundtouch import soundtouch_device @@ -14,8 +13,6 @@ from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - class SoundtouchConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Bose SoundTouch.""" @@ -25,7 +22,7 @@ class SoundtouchConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize a new SoundTouch config flow.""" self.host: str | None = None - self.name = None + self.name: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -79,7 +76,7 @@ async def async_step_zeroconf_confirm( return self.async_show_form( step_id="zeroconf_confirm", last_step=True, - description_placeholders={"name": self.name}, + description_placeholders={"name": self.name or "?"}, ) async def _async_get_device_id(self, raise_on_progress: bool = True) -> None: @@ -94,10 +91,10 @@ async def _async_get_device_id(self, raise_on_progress: bool = True) -> None: self.name = device.config.name - async def _async_create_soundtouch_entry(self): + async def _async_create_soundtouch_entry(self) -> ConfigFlowResult: """Finish config flow and create a SoundTouch config entry.""" return self.async_create_entry( - title=self.name, + title=self.name or "SoundTouch", data={ CONF_HOST: self.host, }, From 20b1e38d24da97b6179254a8eb094d5c7b78de8c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:26:38 +0100 Subject: [PATCH 0490/1070] Improve type hints in tolo config flow (#130421) --- homeassistant/components/tolo/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tolo/config_flow.py b/homeassistant/components/tolo/config_flow.py index 5cf91bdc3a81d..d5d7e33a5e008 100644 --- a/homeassistant/components/tolo/config_flow.py +++ b/homeassistant/components/tolo/config_flow.py @@ -23,7 +23,7 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - _discovered_host: str | None = None + _discovered_host: str @staticmethod def _check_device_availability(host: str) -> bool: From de1905a5297b856506dbcae6411f740bc057b8b7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:33:03 +0100 Subject: [PATCH 0491/1070] Use reauth helpers in system_bridge (#130422) --- .../components/system_bridge/config_flow.py | 16 ++++++---------- .../components/system_bridge/strings.json | 1 + 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index dc1736ea33725..590891fa3f28e 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -17,7 +17,7 @@ import voluptuous as vol from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -124,7 +124,6 @@ def __init__(self) -> None: """Initialize flow.""" self._name: str | None = None self._input: dict[str, Any] = {} - self._reauth = False async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -157,15 +156,13 @@ async def async_step_authenticate( user_input = {**self._input, **user_input} errors, info = await _async_get_info(self.hass, user_input) if not errors and info is not None: - # Check if already configured - existing_entry = await self.async_set_unique_id(info["uuid"]) + await self.async_set_unique_id(info["uuid"]) - if self._reauth and existing_entry: - self.hass.config_entries.async_update_entry( - existing_entry, data=user_input + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=user_input ) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") self._abort_if_unique_id_configured( updates={CONF_HOST: info["hostname"]} @@ -212,7 +209,6 @@ async def async_step_reauth( CONF_HOST: entry_data[CONF_HOST], CONF_PORT: entry_data[CONF_PORT], } - self._reauth = True return await self.async_step_authenticate() diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index b5ceba9bd8458..ef7495ef74fbe 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The identifier does not match the previous identifier", "unsupported_version": "Your version of System Bridge is not supported. Please upgrade to the latest version.", "unknown": "[%key:common::config_flow::error::unknown%]" }, From b24931c775cc0d5d6a4f5e61df6109a272a94fe4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:33:47 +0100 Subject: [PATCH 0492/1070] Remove checks for DeviceEntryDisabler and DeviceEntryType enum (#130367) --- homeassistant/helpers/device_registry.py | 25 ------------------------ 1 file changed, 25 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index faf4257577d35..0e56adc7377ed 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -38,7 +38,6 @@ check_if_deprecated_constant, dir_with_deprecated_constants, ) -from .frame import report from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType from .singleton import singleton @@ -827,17 +826,6 @@ def async_get_or_create( else: via_device_id = UNDEFINED - if isinstance(entry_type, str) and not isinstance(entry_type, DeviceEntryType): - report( # type: ignore[unreachable] - ( - "uses str for device registry entry_type. This is deprecated and" - " will stop working in Home Assistant 2022.3, it should be updated" - " to use DeviceEntryType instead" - ), - error_if_core=False, - ) - entry_type = DeviceEntryType(entry_type) - device = self.async_update_device( device.id, allow_collisions=True, @@ -924,19 +912,6 @@ def async_update_device( # noqa: C901 "Cannot define both merge_identifiers and new_identifiers" ) - if isinstance(disabled_by, str) and not isinstance( - disabled_by, DeviceEntryDisabler - ): - report( # type: ignore[unreachable] - ( - "uses str for device registry disabled_by. This is deprecated and" - " will stop working in Home Assistant 2022.3, it should be updated" - " to use DeviceEntryDisabler instead" - ), - error_if_core=False, - ) - disabled_by = DeviceEntryDisabler(disabled_by) - if ( suggested_area is not None and suggested_area is not UNDEFINED From b57b22f6e3ac03458a94fa0be9777dda74ebc3cb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:35:09 +0100 Subject: [PATCH 0493/1070] Drop restore_state backwards compatibility (#130411) --- homeassistant/helpers/restore_state.py | 16 ---------------- tests/helpers/test_restore_state.py | 16 ---------------- 2 files changed, 32 deletions(-) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index a2b4b3a9b9a21..fd1f84a85ffe1 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -17,7 +17,6 @@ from . import start from .entity import Entity from .event import async_track_time_interval -from .frame import report from .json import JSONEncoder from .singleton import singleton from .storage import Store @@ -116,21 +115,6 @@ async def async_save_persistent_states(cls, hass: HomeAssistant) -> None: """Dump states now.""" await async_get(hass).async_dump_states() - @classmethod - async def async_get_instance(cls, hass: HomeAssistant) -> RestoreStateData: - """Return the instance of this class.""" - # Nothing should actually be calling this anymore, but we'll keep it - # around for a while to avoid breaking custom components. - # - # In fact they should not be accessing this at all. - report( - "restore_state.RestoreStateData.async_get_instance is deprecated, " - "and not intended to be called by custom components; Please" - "refactor your code to use RestoreEntity instead;" - " restore_state.async_get(hass) can be used in the meantime", - ) - return async_get(hass) - def __init__(self, hass: HomeAssistant) -> None: """Initialize the restore state data class.""" self.hass: HomeAssistant = hass diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 865ee5efaf7ed..7adb3dd5b5e6b 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -6,8 +6,6 @@ from typing import Any from unittest.mock import Mock, patch -import pytest - from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError @@ -94,20 +92,6 @@ async def test_caching_data(hass: HomeAssistant) -> None: assert mock_write_data.called -async def test_async_get_instance_backwards_compatibility(hass: HomeAssistant) -> None: - """Test async_get_instance backwards compatibility.""" - await async_load(hass) - data = async_get(hass) - # When called from core it should raise - with pytest.raises(RuntimeError): - await RestoreStateData.async_get_instance(hass) - - # When called from a component it should not raise - # but it should report - with patch("homeassistant.helpers.restore_state.report"): - assert data is await RestoreStateData.async_get_instance(hass) - - async def test_periodic_write(hass: HomeAssistant) -> None: """Test that we write periodiclly but not after stop.""" data = async_get(hass) From 600f83ddabfa157d87dc93a44e22f4f72529af0c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:35:50 +0100 Subject: [PATCH 0494/1070] Finish migration from report to report_usage (#130412) --- homeassistant/components/http/__init__.py | 5 ++--- homeassistant/config_entries.py | 16 ++++++---------- homeassistant/helpers/event.py | 4 ++-- homeassistant/util/async_.py | 2 +- 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index a8721720dfb61..c9c75b0c04e1c 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -505,15 +505,14 @@ def register_static_path( self, url_path: str, path: str, cache_headers: bool = True ) -> None: """Register a folder or file to serve as a static path.""" - frame.report( + frame.report_usage( "calls hass.http.register_static_path which is deprecated because " "it does blocking I/O in the event loop, instead " "call `await hass.http.async_register_static_paths(" f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`; ' "This function will be removed in 2025.7", exclude_integrations={"http"}, - error_if_core=False, - error_if_integration=False, + core_behavior=frame.ReportBehavior.LOG, ) configs = [StaticPathConfig(url_path, path, cache_headers)] resources = self._make_static_resources(configs) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f1748c6b7fb89..09d09dbdf7522 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -63,7 +63,7 @@ RANDOM_MICROSECOND_MIN, async_call_later, ) -from .helpers.frame import ReportBehavior, report, report_usage +from .helpers.frame import ReportBehavior, report_usage from .helpers.json import json_bytes, json_bytes_sorted, json_fragment from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType from .loader import async_suggest_report_issue @@ -1191,14 +1191,13 @@ class FlowCancelledError(Exception): def _report_non_awaited_platform_forwards(entry: ConfigEntry, what: str) -> None: """Report non awaited platform forwards.""" - report( + report_usage( f"calls {what} for integration {entry.domain} with " f"title: {entry.title} and entry_id: {entry.entry_id}, " f"during setup without awaiting {what}, which can cause " "the setup lock to be released before the setup is done. " "This will stop working in Home Assistant 2025.1", - error_if_integration=False, - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) @@ -1266,10 +1265,8 @@ async def async_init( SOURCE_RECONFIGURE, } and "entry_id" not in context: # Deprecated in 2024.12, should fail in 2025.12 - report( + report_usage( f"initialises a {source} flow without a link to the config entry", - error_if_integration=False, - error_if_core=True, ) flow_id = ulid_util.ulid_now() @@ -2321,14 +2318,13 @@ async def async_forward_entry_setup( multiple platforms at once and is more efficient since it does not require a separate import executor job for each platform. """ - report( + report_usage( "calls async_forward_entry_setup for " f"integration, {entry.domain} with title: {entry.title} " f"and entry_id: {entry.entry_id}, which is deprecated and " "will stop working in Home Assistant 2025.6, " "await async_forward_entry_setups instead", - error_if_core=False, - error_if_integration=False, + core_behavior=ReportBehavior.LOG, ) if not entry.setup_lock.locked(): async with entry.setup_lock: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 61a798dbd7511..779cd8d5108bd 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -224,10 +224,10 @@ def async_track_state_change( Must be run within the event loop. """ - frame.report( + frame.report_usage( "calls `async_track_state_change` instead of `async_track_state_change_event`" " which is deprecated and will be removed in Home Assistant 2025.5", - error_if_core=False, + core_behavior=frame.ReportBehavior.LOG, ) if from_state is not None: diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index d010d8cb341e3..f8901d11114c3 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -39,7 +39,7 @@ def create_eager_task[_T]( # pylint: disable-next=import-outside-toplevel from homeassistant.helpers import frame - frame.report("attempted to create an asyncio task from a thread") + frame.report_usage("attempted to create an asyncio task from a thread") raise return Task(coro, loop=loop, name=name, eager_start=True) From b6d981fe9ecb495912c99322355edc3ede525dae Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:48:11 +0100 Subject: [PATCH 0495/1070] Improve type hints in Time-based One Time Password auth module (#130420) --- homeassistant/auth/mfa_modules/totp.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index e9055b45f05b8..3306f76217fec 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -177,17 +177,17 @@ def _validate_2fa(self, user_id: str, code: str) -> bool: class TotpSetupFlow(SetupFlow): """Handler for the setup flow.""" + _auth_module: TotpAuthModule + _ota_secret: str + _url: str + _image: str + def __init__( self, auth_module: TotpAuthModule, setup_schema: vol.Schema, user: User ) -> None: """Initialize the setup flow.""" super().__init__(auth_module, setup_schema, user.id) - # to fix typing complaint - self._auth_module: TotpAuthModule = auth_module self._user = user - self._ota_secret: str = "" - self._url: str | None = None - self._image: str | None = None async def async_step_init( self, user_input: dict[str, str] | None = None @@ -214,12 +214,11 @@ async def async_step_init( errors["base"] = "invalid_code" else: - hass = self._auth_module.hass ( self._ota_secret, self._url, self._image, - ) = await hass.async_add_executor_job( + ) = await self._auth_module.hass.async_add_executor_job( _generate_secret_and_qr_code, str(self._user.name), ) From 46ecdc680ce198051056ea3eab648fc4f13db468 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:59:11 +0100 Subject: [PATCH 0496/1070] Update velbus-aio to 2024.11.0 (#130695) --- homeassistant/components/velbus/manifest.json | 2 +- pyproject.toml | 2 -- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 5443afeef7728..cd93b07f748de 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2024.10.0"], + "requirements": ["velbus-aio==2024.11.0"], "usb": [ { "vid": "10CF", diff --git a/pyproject.toml b/pyproject.toml index 613a9608c878b..c1a586f4419e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -533,8 +533,6 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:onvif.client", # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", - # https://github.com/cereal2nd/velbus-aio/pull/126 - >2024.10.0 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:velbusaio.handler", # -- fixed for Python 3.13 # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2 diff --git a/requirements_all.txt b/requirements_all.txt index 96fad9deaa21a..1eda98cedc3a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2938,7 +2938,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.10.0 +velbus-aio==2024.11.0 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0a1908351e1ad..1eddd5f4c4522 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2345,7 +2345,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.10.0 +velbus-aio==2024.11.0 # homeassistant.components.venstar venstarcolortouch==0.19 From 4140999bdb4c8f8d3615b56ae8ce1c5a85091f9f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 13:52:44 +0100 Subject: [PATCH 0497/1070] Improve type hints in modern_forms config flow (#130698) --- homeassistant/components/modern_forms/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/modern_forms/config_flow.py b/homeassistant/components/modern_forms/config_flow.py index 33e814efb5143..6799dbf97d361 100644 --- a/homeassistant/components/modern_forms/config_flow.py +++ b/homeassistant/components/modern_forms/config_flow.py @@ -24,7 +24,7 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): host: str | None = None mac: str | None = None - name: str | None = None + name: str async def async_step_user( self, user_input: dict[str, Any] | None = None From ca7e73c42f056c39a4a37f2ad07fb357bf68e1eb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 13:53:42 +0100 Subject: [PATCH 0498/1070] Improve type hints in system_bridge config flow (#130697) --- homeassistant/components/system_bridge/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 590891fa3f28e..98396e52545b1 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -120,9 +120,10 @@ class SystemBridgeConfigFlow( VERSION = 1 MINOR_VERSION = 2 + _name: str + def __init__(self) -> None: """Initialize flow.""" - self._name: str | None = None self._input: dict[str, Any] = {} async def async_step_user( From dc09b7a532243ecf1105c73b4babc65179ee4492 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 15 Nov 2024 14:37:16 +0100 Subject: [PATCH 0499/1070] Remove old setuptools keys from metadata (#130699) --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c1a586f4419e0..c2ce019b95cc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,8 +95,6 @@ dependencies = [ hass = "homeassistant.__main__:main" [tool.setuptools] -platforms = ["any"] -zip-safe = false include-package-data = true [tool.setuptools.packages.find] From e772eef035d6ebf8843957369c83a15661e95bce Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 14:39:57 +0100 Subject: [PATCH 0500/1070] Prevent None strings in description_placeholders (#127103) --- homeassistant/components/emonitor/config_flow.py | 1 + homeassistant/components/kodi/config_flow.py | 1 + homeassistant/components/lookin/config_flow.py | 5 ++++- homeassistant/components/ridwell/config_flow.py | 3 +++ homeassistant/components/songpal/config_flow.py | 2 ++ homeassistant/components/workday/config_flow.py | 2 +- homeassistant/config_entries.py | 2 +- homeassistant/data_entry_flow.py | 4 ++-- 8 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/emonitor/config_flow.py b/homeassistant/components/emonitor/config_flow.py index b924c7df52246..833b80f9d47f8 100644 --- a/homeassistant/components/emonitor/config_flow.py +++ b/homeassistant/components/emonitor/config_flow.py @@ -92,6 +92,7 @@ async def async_step_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Attempt to confirm.""" + assert self.discovered_ip is not None if user_input is not None: return self.async_create_entry( title=self.discovered_info["title"], diff --git a/homeassistant/components/kodi/config_flow.py b/homeassistant/components/kodi/config_flow.py index ef0798220ddee..f87b94b23fdea 100644 --- a/homeassistant/components/kodi/config_flow.py +++ b/homeassistant/components/kodi/config_flow.py @@ -145,6 +145,7 @@ async def async_step_discovery_confirm( ) -> ConfigFlowResult: """Handle user-confirmation of discovered node.""" if user_input is None: + assert self._name is not None return self.async_show_form( step_id="discovery_confirm", description_placeholders={"name": self._name}, diff --git a/homeassistant/components/lookin/config_flow.py b/homeassistant/components/lookin/config_flow.py index e2d2c3f262527..aaf98a06fa85c 100644 --- a/homeassistant/components/lookin/config_flow.py +++ b/homeassistant/components/lookin/config_flow.py @@ -97,7 +97,10 @@ async def async_step_discovery_confirm( if user_input is None: return self.async_show_form( step_id="discovery_confirm", - description_placeholders={"name": self._name, "host": self._host}, + description_placeholders={ + "name": self._name or "LOOKin", + "host": self._host, + }, ) return self.async_create_entry( diff --git a/homeassistant/components/ridwell/config_flow.py b/homeassistant/components/ridwell/config_flow.py index a54d4debe7544..f03679c83152a 100644 --- a/homeassistant/components/ridwell/config_flow.py +++ b/homeassistant/components/ridwell/config_flow.py @@ -93,6 +93,9 @@ async def async_step_reauth_confirm( ) -> ConfigFlowResult: """Handle re-auth completion.""" if not user_input: + if TYPE_CHECKING: + assert self._username + return self.async_show_form( step_id="reauth_confirm", data_schema=STEP_REAUTH_CONFIRM_DATA_SCHEMA, diff --git a/homeassistant/components/songpal/config_flow.py b/homeassistant/components/songpal/config_flow.py index 762de39aa30ea..41cc076364224 100644 --- a/homeassistant/components/songpal/config_flow.py +++ b/homeassistant/components/songpal/config_flow.py @@ -24,6 +24,8 @@ class SongpalConfig: def __init__(self, name: str, host: str | None, endpoint: str) -> None: """Initialize Configuration.""" self.name = name + if TYPE_CHECKING: + assert host is not None self.host = host self.endpoint = endpoint diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 4d93fccb1a77e..727c4340ea3c7 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -372,7 +372,7 @@ async def async_step_init( errors=errors, description_placeholders={ "name": options[CONF_NAME], - "country": options.get(CONF_COUNTRY), + "country": options.get(CONF_COUNTRY, "-"), }, ) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 09d09dbdf7522..dd298ae378681 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2966,7 +2966,7 @@ def async_show_form( step_id: str | None = None, data_schema: vol.Schema | None = None, errors: dict[str, str] | None = None, - description_placeholders: Mapping[str, str | None] | None = None, + description_placeholders: Mapping[str, str] | None = None, last_step: bool | None = None, preview: str | None = None, ) -> ConfigFlowResult: diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 9d041c9b8d330..63baca56aebd3 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -155,7 +155,7 @@ class FlowResult(TypedDict, Generic[_FlowContextT, _HandlerT], total=False): context: _FlowContextT data_schema: vol.Schema | None data: Mapping[str, Any] - description_placeholders: Mapping[str, str | None] | None + description_placeholders: Mapping[str, str] | None description: str | None errors: dict[str, str] | None extra: str @@ -705,7 +705,7 @@ def async_show_form( step_id: str | None = None, data_schema: vol.Schema | None = None, errors: dict[str, str] | None = None, - description_placeholders: Mapping[str, str | None] | None = None, + description_placeholders: Mapping[str, str] | None = None, last_step: bool | None = None, preview: str | None = None, ) -> _FlowResultT: From 3c3a6dff04c335ce0dd7f7e143aacb58d95a4f51 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 15:05:04 +0100 Subject: [PATCH 0501/1070] Add translation checks for issue registry (#130593) --- tests/components/conftest.py | 64 +++++++++++++++++-- tests/components/repairs/test_init.py | 26 ++++++++ .../components/repairs/test_websocket_api.py | 59 +++++++++++++++-- tests/components/sensor/test_recorder.py | 11 ++++ tests/components/workday/test_repairs.py | 6 ++ tests/components/zwave_js/test_repairs.py | 5 ++ 6 files changed, 162 insertions(+), 9 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 2c03bb9d7fcf5..08bd16d1f7b8e 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -2,7 +2,8 @@ from __future__ import annotations -from collections.abc import Callable, Generator +import asyncio +from collections.abc import AsyncGenerator, Callable, Generator from importlib.util import find_spec from pathlib import Path import string @@ -684,20 +685,54 @@ async def _check_config_flow_result_translations( ) +async def _check_create_issue_translations( + issue_registry: ir.IssueRegistry, + issue: ir.IssueEntry, + translation_errors: dict[str, str], +) -> None: + if issue.translation_key is None: + # `translation_key` is only None on dismissed issues + return + await _validate_translation( + issue_registry.hass, + translation_errors, + "issues", + issue.domain, + f"{issue.translation_key}.title", + issue.translation_placeholders, + ) + if not issue.is_fixable: + # Description is required for non-fixable issues + await _validate_translation( + issue_registry.hass, + translation_errors, + "issues", + issue.domain, + f"{issue.translation_key}.description", + issue.translation_placeholders, + ) + + @pytest.fixture(autouse=True) -def check_translations(ignore_translations: str | list[str]) -> Generator[None]: +async def check_translations( + ignore_translations: str | list[str], +) -> AsyncGenerator[None]: """Check that translation requirements are met. Current checks: - data entry flow results (ConfigFlow/OptionsFlow/RepairFlow) + - issue registry entries """ if not isinstance(ignore_translations, list): ignore_translations = [ignore_translations] translation_errors = {k: "unused" for k in ignore_translations} + translation_coros = set() + # Keep reference to original functions _original_flow_manager_async_handle_step = FlowManager._async_handle_step + _original_issue_registry_async_create_issue = ir.IssueRegistry.async_get_or_create # Prepare override functions async def _flow_manager_async_handle_step( @@ -709,13 +744,32 @@ async def _flow_manager_async_handle_step( ) return result + def _issue_registry_async_create_issue( + self: ir.IssueRegistry, domain: str, issue_id: str, *args, **kwargs + ) -> None: + result = _original_issue_registry_async_create_issue( + self, domain, issue_id, *args, **kwargs + ) + translation_coros.add( + _check_create_issue_translations(self, result, translation_errors) + ) + return result + # Use override functions - with patch( - "homeassistant.data_entry_flow.FlowManager._async_handle_step", - _flow_manager_async_handle_step, + with ( + patch( + "homeassistant.data_entry_flow.FlowManager._async_handle_step", + _flow_manager_async_handle_step, + ), + patch( + "homeassistant.helpers.issue_registry.IssueRegistry.async_get_or_create", + _issue_registry_async_create_issue, + ), ): yield + await asyncio.gather(*translation_coros) + # Run final checks unused_ignore = [k for k, v in translation_errors.items() if v == "unused"] if unused_ignore: diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index edb6e50984160..e78563503f1ab 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -21,6 +21,16 @@ from tests.typing import WebSocketGenerator +@pytest.mark.parametrize( + "ignore_translations", + [ + [ + "component.test.issues.even_worse.title", + "component.test.issues.even_worse.description", + "component.test.issues.abc_123.title", + ] + ], +) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_create_update_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -160,6 +170,14 @@ async def test_create_issue_invalid_version( assert msg["result"] == {"issues": []} +@pytest.mark.parametrize( + "ignore_translations", + [ + [ + "component.test.issues.abc_123.title", + ] + ], +) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_ignore_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -329,6 +347,10 @@ async def test_ignore_issue( } +@pytest.mark.parametrize( + "ignore_translations", + ["component.fake_integration.issues.abc_123.title"], +) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_delete_issue( hass: HomeAssistant, @@ -483,6 +505,10 @@ async def test_non_compliant_platform( assert list(hass.data[DOMAIN]["platforms"].keys()) == ["fake_integration"] +@pytest.mark.parametrize( + "ignore_translations", + ["component.fake_integration.issues.abc_123.title"], +) @pytest.mark.freeze_time("2022-07-21 08:22:00") async def test_sync_methods( hass: HomeAssistant, diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index b23977842c60b..399292fb83f1d 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -151,6 +151,10 @@ def async_create_fix_flow( ) +@pytest.mark.parametrize( + "ignore_translations", + ["component.fake_integration.issues.abc_123.title"], +) async def test_dismiss_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -234,6 +238,10 @@ async def test_dismiss_issue( } +@pytest.mark.parametrize( + "ignore_translations", + ["component.fake_integration.issues.abc_123.title"], +) async def test_fix_non_existing_issue( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -281,10 +289,20 @@ async def test_fix_non_existing_issue( @pytest.mark.parametrize( - ("domain", "step", "description_placeholders"), + ("domain", "step", "description_placeholders", "ignore_translations"), [ - ("fake_integration", "custom_step", None), - ("fake_integration_default_handler", "confirm", {"abc": "123"}), + ( + "fake_integration", + "custom_step", + None, + ["component.fake_integration.issues.abc_123.title"], + ), + ( + "fake_integration_default_handler", + "confirm", + {"abc": "123"}, + ["component.fake_integration_default_handler.issues.abc_123.title"], + ), ], ) async def test_fix_issue( @@ -380,6 +398,10 @@ async def test_fix_issue_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED +@pytest.mark.parametrize( + "ignore_translations", + ["component.fake_integration.issues.abc_123.title"], +) async def test_get_progress_unauth( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -411,6 +433,10 @@ async def test_get_progress_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED +@pytest.mark.parametrize( + "ignore_translations", + ["component.fake_integration.issues.abc_123.title"], +) async def test_step_unauth( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -442,6 +468,16 @@ async def test_step_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED +@pytest.mark.parametrize( + "ignore_translations", + [ + [ + "component.test.issues.even_worse.title", + "component.test.issues.even_worse.description", + "component.test.issues.abc_123.title", + ] + ], +) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_list_issues( hass: HomeAssistant, @@ -535,7 +571,12 @@ async def test_list_issues( @pytest.mark.parametrize( "ignore_translations", - ["component.fake_integration.issues.abc_123.fix_flow.abort.not_given"], + [ + [ + "component.fake_integration.issues.abc_123.title", + "component.fake_integration.issues.abc_123.fix_flow.abort.not_given", + ] + ], ) async def test_fix_issue_aborted( hass: HomeAssistant, @@ -598,6 +639,16 @@ async def test_fix_issue_aborted( assert msg["result"]["issues"][0] == first_issue +@pytest.mark.parametrize( + "ignore_translations", + [ + [ + "component.test.issues.abc_123.title", + "component.test.issues.even_worse.title", + "component.test.issues.even_worse.description", + ] + ], +) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_get_issue_data( hass: HomeAssistant, hass_ws_client: WebSocketGenerator diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 0e8c2a5e188e3..aec6ec84f1b03 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -5432,6 +5432,17 @@ def _fetch_states() -> list[State]: assert ATTR_FRIENDLY_NAME in states[0].attributes +@pytest.mark.parametrize( + "ignore_translations", + [ + [ + "component.test.issues..title", + "component.test.issues..description", + "component.sensor.issues..title", + "component.sensor.issues..description", + ] + ], +) async def test_clean_up_repairs( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py index e25d4e0ca4526..adbae5676e66d 100644 --- a/tests/components/workday/test_repairs.py +++ b/tests/components/workday/test_repairs.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from homeassistant.components.workday.const import CONF_REMOVE_HOLIDAYS, DOMAIN from homeassistant.const import CONF_COUNTRY from homeassistant.core import HomeAssistant @@ -427,6 +429,10 @@ async def test_bad_date_holiday( assert issue +@pytest.mark.parametrize( + "ignore_translations", + ["component.workday.issues.issue_1.title"], +) async def test_other_fixable_issues( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index 2f10b70b48a48..d237a6e410a2c 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -3,6 +3,7 @@ from copy import deepcopy from unittest.mock import patch +import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node @@ -179,6 +180,10 @@ async def test_device_config_file_changed_ignore_step( assert msg["result"]["issues"][0].get("dismissed_version") is not None +@pytest.mark.parametrize( + "ignore_translations", + ["component.zwave_js.issues.invalid_issue.title"], +) async def test_invalid_issue( hass: HomeAssistant, hass_client: ClientSessionGenerator, From 821f9b8a41f78233d7d68d232cdd5eb49234536b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Nov 2024 15:05:59 +0100 Subject: [PATCH 0502/1070] Fix modern_forms config flow test logic (#130491) --- tests/components/modern_forms/__init__.py | 4 ++- .../snapshots/test_diagnostics.ambr | 2 +- .../modern_forms/test_config_flow.py | 34 +++++-------------- 3 files changed, 13 insertions(+), 27 deletions(-) diff --git a/tests/components/modern_forms/__init__.py b/tests/components/modern_forms/__init__.py index ae4e5bd9862db..5882eaf1ec9c3 100644 --- a/tests/components/modern_forms/__init__.py +++ b/tests/components/modern_forms/__init__.py @@ -62,7 +62,9 @@ async def init_integration( ) entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "192.168.1.123", CONF_MAC: "AA:BB:CC:DD:EE:FF"} + domain=DOMAIN, + data={CONF_HOST: "192.168.1.123", CONF_MAC: "AA:BB:CC:DD:EE:FF"}, + unique_id="AA:BB:CC:DD:EE:FF", ) entry.add_to_hass(hass) diff --git a/tests/components/modern_forms/snapshots/test_diagnostics.ambr b/tests/components/modern_forms/snapshots/test_diagnostics.ambr index 75794aaca1212..f8897a4a47fe9 100644 --- a/tests/components/modern_forms/snapshots/test_diagnostics.ambr +++ b/tests/components/modern_forms/snapshots/test_diagnostics.ambr @@ -17,7 +17,7 @@ 'pref_disable_polling': False, 'source': 'user', 'title': 'Mock Title', - 'unique_id': None, + 'unique_id': 'AA:BB:CC:DD:EE:FF', 'version': 1, }), 'device': dict({ diff --git a/tests/components/modern_forms/test_config_flow.py b/tests/components/modern_forms/test_config_flow.py index 1484b5d599248..5b10d4d729e63 100644 --- a/tests/components/modern_forms/test_config_flow.py +++ b/tests/components/modern_forms/test_config_flow.py @@ -113,7 +113,11 @@ async def test_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_HOST: "example.com"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "example.com"}, ) assert result.get("type") is FlowResultType.FORM @@ -193,24 +197,14 @@ async def test_user_device_exists_abort( await init_integration(hass, aioclient_mock, skip_setup=True) - await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={ - "host": "192.168.1.123", - "hostname": "example.local.", - "properties": {CONF_MAC: "AA:BB:CC:DD:EE:FF"}, - }, ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ - "host": "192.168.1.123", - "hostname": "example.local.", - "properties": {CONF_MAC: "AA:BB:CC:DD:EE:FF"}, - }, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.123"}, ) assert result.get("type") is FlowResultType.ABORT @@ -223,16 +217,6 @@ async def test_zeroconf_with_mac_device_exists_abort( """Test we abort zeroconf flow if a Modern Forms device already configured.""" await init_integration(hass, aioclient_mock, skip_setup=True) - await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ - "host": "192.168.1.123", - "hostname": "example.local.", - "properties": {CONF_MAC: "AA:BB:CC:DD:EE:FF"}, - }, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, From c3857661f112d90782c723a920389aaa09b376eb Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 15 Nov 2024 15:33:06 +0100 Subject: [PATCH 0503/1070] Bump nextdns to version 4.0.0 (#130701) --- homeassistant/components/nextdns/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index f3ed62a2f0c30..ab80c83357b61 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["nextdns"], "quality_scale": "platinum", - "requirements": ["nextdns==3.3.0"] + "requirements": ["nextdns==4.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1eda98cedc3a2..049310c344bf9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1454,7 +1454,7 @@ nextcloudmonitor==1.5.1 nextcord==2.6.0 # homeassistant.components.nextdns -nextdns==3.3.0 +nextdns==4.0.0 # homeassistant.components.nibe_heatpump nibe==2.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1eddd5f4c4522..f04da50271ed0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1214,7 +1214,7 @@ nextcloudmonitor==1.5.1 nextcord==2.6.0 # homeassistant.components.nextdns -nextdns==3.3.0 +nextdns==4.0.0 # homeassistant.components.nibe_heatpump nibe==2.11.0 From 9a07f5889036c7c2b1e6f098dc9c63aa9bd46a3a Mon Sep 17 00:00:00 2001 From: David Knowles Date: Fri, 15 Nov 2024 09:37:51 -0500 Subject: [PATCH 0504/1070] Inline hydrawise sensor value_fn definitions as lambdas (#130702) --- homeassistant/components/hydrawise/sensor.py | 101 ++++++------------- 1 file changed, 32 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 1d8c75d5437a7..96cc16832da06 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -4,9 +4,11 @@ from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import timedelta from typing import Any +from pydrawise.schema import ControllerWaterUseSummary + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -30,66 +32,8 @@ class HydrawiseSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[HydrawiseSensor], Any] -def _get_zone_watering_time(sensor: HydrawiseSensor) -> int: - if (current_run := sensor.zone.scheduled_runs.current_run) is not None: - return int(current_run.remaining_time.total_seconds() / 60) - return 0 - - -def _get_zone_next_cycle(sensor: HydrawiseSensor) -> datetime | None: - if (next_run := sensor.zone.scheduled_runs.next_run) is not None: - return dt_util.as_utc(next_run.start_time) - return None - - -def _get_zone_daily_active_water_use(sensor: HydrawiseSensor) -> float: - """Get active water use for the zone.""" - daily_water_summary = sensor.coordinator.data.daily_water_summary[ - sensor.controller.id - ] - return float(daily_water_summary.active_use_by_zone_id.get(sensor.zone.id, 0.0)) - - -def _get_zone_daily_active_water_time(sensor: HydrawiseSensor) -> float | None: - """Get active water time for the zone.""" - daily_water_summary = sensor.coordinator.data.daily_water_summary[ - sensor.controller.id - ] - return daily_water_summary.active_time_by_zone_id.get( - sensor.zone.id, timedelta() - ).total_seconds() - - -def _get_controller_daily_active_water_use(sensor: HydrawiseSensor) -> float | None: - """Get active water use for the controller.""" - daily_water_summary = sensor.coordinator.data.daily_water_summary[ - sensor.controller.id - ] - return daily_water_summary.total_active_use - - -def _get_controller_daily_inactive_water_use(sensor: HydrawiseSensor) -> float | None: - """Get inactive water use for the controller.""" - daily_water_summary = sensor.coordinator.data.daily_water_summary[ - sensor.controller.id - ] - return daily_water_summary.total_inactive_use - - -def _get_controller_daily_active_water_time(sensor: HydrawiseSensor) -> float: - """Get active water time for the controller.""" - daily_water_summary = sensor.coordinator.data.daily_water_summary[ - sensor.controller.id - ] - return daily_water_summary.total_active_time.total_seconds() - - -def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | None: - """Get inactive water use for the controller.""" - daily_water_summary = sensor.coordinator.data.daily_water_summary[ - sensor.controller.id - ] - return daily_water_summary.total_use +def _get_water_use(sensor: HydrawiseSensor) -> ControllerWaterUseSummary: + return sensor.coordinator.data.daily_water_summary[sensor.controller.id] WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = ( @@ -98,7 +42,9 @@ def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | No translation_key="daily_active_water_time", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, - value_fn=_get_controller_daily_active_water_time, + value_fn=lambda sensor: _get_water_use( + sensor + ).total_active_time.total_seconds(), ), ) @@ -109,7 +55,11 @@ def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | No translation_key="daily_active_water_time", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, - value_fn=_get_zone_daily_active_water_time, + value_fn=lambda sensor: ( + _get_water_use(sensor) + .active_time_by_zone_id.get(sensor.zone.id, timedelta()) + .total_seconds() + ), ), ) @@ -119,21 +69,21 @@ def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | No translation_key="daily_total_water_use", device_class=SensorDeviceClass.VOLUME, suggested_display_precision=1, - value_fn=_get_controller_daily_total_water_use, + value_fn=lambda sensor: _get_water_use(sensor).total_use, ), HydrawiseSensorEntityDescription( key="daily_active_water_use", translation_key="daily_active_water_use", device_class=SensorDeviceClass.VOLUME, suggested_display_precision=1, - value_fn=_get_controller_daily_active_water_use, + value_fn=lambda sensor: _get_water_use(sensor).total_active_use, ), HydrawiseSensorEntityDescription( key="daily_inactive_water_use", translation_key="daily_inactive_water_use", device_class=SensorDeviceClass.VOLUME, suggested_display_precision=1, - value_fn=_get_controller_daily_inactive_water_use, + value_fn=lambda sensor: _get_water_use(sensor).total_inactive_use, ), ) @@ -143,7 +93,9 @@ def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | No translation_key="daily_active_water_use", device_class=SensorDeviceClass.VOLUME, suggested_display_precision=1, - value_fn=_get_zone_daily_active_water_use, + value_fn=lambda sensor: float( + _get_water_use(sensor).active_use_by_zone_id.get(sensor.zone.id, 0.0) + ), ), ) @@ -152,13 +104,24 @@ def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | No key="next_cycle", translation_key="next_cycle", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=_get_zone_next_cycle, + value_fn=lambda sensor: ( + dt_util.as_utc(sensor.zone.scheduled_runs.next_run.start_time) + if sensor.zone.scheduled_runs.next_run is not None + else None + ), ), HydrawiseSensorEntityDescription( key="watering_time", translation_key="watering_time", native_unit_of_measurement=UnitOfTime.MINUTES, - value_fn=_get_zone_watering_time, + value_fn=lambda sensor: ( + int( + sensor.zone.scheduled_runs.current_run.remaining_time.total_seconds() + / 60 + ) + if sensor.zone.scheduled_runs.current_run is not None + else 0 + ), ), ) From cd79a606d76f4399b3a78c9f991de13d9f3290f8 Mon Sep 17 00:00:00 2001 From: Alistair Galbraith Date: Fri, 15 Nov 2024 07:08:43 -0800 Subject: [PATCH 0505/1070] Fix scene loading issue (#130627) --- homeassistant/components/hue/scene.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index 6808ddb53530f..1d83804820d94 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -130,10 +130,15 @@ class HueSceneEntity(HueSceneEntityBase): @property def is_dynamic(self) -> bool: """Return if this scene has a dynamic color palette.""" - if self.resource.palette.color and len(self.resource.palette.color) > 1: + if ( + self.resource.palette + and self.resource.palette.color + and len(self.resource.palette.color) > 1 + ): return True if ( - self.resource.palette.color_temperature + self.resource.palette + and self.resource.palette.color_temperature and len(self.resource.palette.color_temperature) > 1 ): return True From 58087d67d1bba3c242f4a5e81d43adecc6bac52a Mon Sep 17 00:00:00 2001 From: dotvav Date: Fri, 15 Nov 2024 16:09:33 +0100 Subject: [PATCH 0506/1070] Add HVACAction state to palazzetti climate (#130502) --- homeassistant/components/palazzetti/climate.py | 13 +++++++++++-- .../palazzetti/snapshots/test_climate.ambr | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py index aff988051f338..055b3b4017213 100644 --- a/homeassistant/components/palazzetti/climate.py +++ b/homeassistant/components/palazzetti/climate.py @@ -7,6 +7,7 @@ from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature @@ -82,8 +83,16 @@ def available(self) -> bool: @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat or off mode.""" - is_heating = bool(self.coordinator.client.is_heating) - return HVACMode.HEAT if is_heating else HVACMode.OFF + return HVACMode.HEAT if self.coordinator.client.is_on else HVACMode.OFF + + @property + def hvac_action(self) -> HVACAction: + """Return hvac action ie. heating or idle.""" + return ( + HVACAction.HEATING + if self.coordinator.client.is_heating + else HVACAction.IDLE + ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" diff --git a/tests/components/palazzetti/snapshots/test_climate.ambr b/tests/components/palazzetti/snapshots/test_climate.ambr index eb3b323272e3d..e7cea3749a1ce 100644 --- a/tests/components/palazzetti/snapshots/test_climate.ambr +++ b/tests/components/palazzetti/snapshots/test_climate.ambr @@ -66,6 +66,7 @@ 'auto', ]), 'friendly_name': 'Stove', + 'hvac_action': , 'hvac_modes': list([ , , From 23bac6755006459ba01818c1a43dd239a2d7e3bd Mon Sep 17 00:00:00 2001 From: Nikolay Vasilchuk Date: Fri, 15 Nov 2024 18:21:23 +0300 Subject: [PATCH 0507/1070] Add starline run sensor (#130444) --- homeassistant/components/starline/binary_sensor.py | 5 +++++ homeassistant/components/starline/icons.json | 3 +++ homeassistant/components/starline/strings.json | 3 +++ 3 files changed, 11 insertions(+) diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index 0383fc8ade63d..69f0ae06d02ae 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -41,6 +41,11 @@ translation_key="doors", device_class=BinarySensorDeviceClass.LOCK, ), + BinarySensorEntityDescription( + key="run", + translation_key="is_running", + device_class=BinarySensorDeviceClass.RUNNING, + ), BinarySensorEntityDescription( key="hfree", translation_key="handsfree", diff --git a/homeassistant/components/starline/icons.json b/homeassistant/components/starline/icons.json index 8a4f85a89bf7c..e240978ce74fd 100644 --- a/homeassistant/components/starline/icons.json +++ b/homeassistant/components/starline/icons.json @@ -12,6 +12,9 @@ }, "moving_ban": { "default": "mdi:car-off" + }, + "is_running": { + "default": "mdi:speedometer" } }, "button": { diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index 14a8ed5a03545..a330354e5a9c2 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -63,6 +63,9 @@ }, "moving_ban": { "name": "Moving ban" + }, + "is_running": { + "name": "Running" } }, "device_tracker": { From ab5ddb8edfb72d5f5915574f642eba93afc5abdc Mon Sep 17 00:00:00 2001 From: Tristan Bastian Date: Fri, 15 Nov 2024 17:02:31 +0100 Subject: [PATCH 0508/1070] Allow reconnecting wireless omada clients (#128491) --- .../components/tplink_omada/__init__.py | 26 ++++++++++++++++--- .../components/tplink_omada/icons.json | 5 ++++ .../components/tplink_omada/services.yaml | 7 +++++ .../components/tplink_omada/strings.json | 12 +++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/tplink_omada/services.yaml diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 573df44122c13..2d33a890510c8 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -11,9 +11,9 @@ UnsupportedControllerVersion, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -60,6 +60,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo entry.runtime_data = controller + async def handle_reconnect_client(call: ServiceCall) -> None: + """Handle the service action call.""" + mac: str | None = call.data.get("mac") + if not mac: + return + + await site_client.reconnect_client(mac) + + hass.services.async_register(DOMAIN, "reconnect_client", handle_reconnect_client) + _remove_old_devices(hass, entry, controller.devices_coordinator.data) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -69,7 +79,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo async def async_unload_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) == 1: + # This is the last loaded instance of Omada, deregister any services + hass.services.async_remove(DOMAIN, "reconnect_client") + + return unload_ok def _remove_old_devices( diff --git a/homeassistant/components/tplink_omada/icons.json b/homeassistant/components/tplink_omada/icons.json index c681b5e1f81a1..94f0a6b9764ee 100644 --- a/homeassistant/components/tplink_omada/icons.json +++ b/homeassistant/components/tplink_omada/icons.json @@ -27,5 +27,10 @@ "default": "mdi:memory" } } + }, + "services": { + "reconnect_client": { + "service": "mdi:sync" + } } } diff --git a/homeassistant/components/tplink_omada/services.yaml b/homeassistant/components/tplink_omada/services.yaml new file mode 100644 index 0000000000000..19a64ea8625aa --- /dev/null +++ b/homeassistant/components/tplink_omada/services.yaml @@ -0,0 +1,7 @@ +reconnect_client: + fields: + mac: + required: true + example: "01-23-45-67-89-AB" + selector: + text: diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 7fcede3fb1221..73cea692dbff5 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -87,5 +87,17 @@ "name": "Memory usage" } } + }, + "services": { + "reconnect_client": { + "name": "Reconnect wireless client", + "description": "Tries to get wireless client to reconnect to Omada Network.", + "fields": { + "mac": { + "name": "MAC address", + "description": "MAC address of the device." + } + } + } } } From 92aa2f700d894777f38930ee46461f286d0036ae Mon Sep 17 00:00:00 2001 From: dnikles Date: Fri, 15 Nov 2024 11:08:10 -0500 Subject: [PATCH 0509/1070] Add two WiiM models to linkplay (#130707) --- homeassistant/components/linkplay/utils.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py index 36a492f84645f..b8dc185ded202 100644 --- a/homeassistant/components/linkplay/utils.py +++ b/homeassistant/components/linkplay/utils.py @@ -13,6 +13,7 @@ MANUFACTURER_ARTSOUND: Final[str] = "ArtSound" MANUFACTURER_ARYLIC: Final[str] = "Arylic" MANUFACTURER_IEAST: Final[str] = "iEAST" +MANUFACTURER_WIIM: Final[str] = "WiiM" MANUFACTURER_GENERIC: Final[str] = "Generic" MODELS_ARTSOUND_SMART_ZONE4: Final[str] = "Smart Zone 4 AMP" MODELS_ARTSOUND_SMART_HYDE: Final[str] = "Smart Hyde" @@ -24,6 +25,8 @@ MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4" MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3" MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5" +MODELS_WIIM_AMP: Final[str] = "WiiM Amp" +MODELS_WIIM_MINI: Final[str] = "WiiM Mini" MODELS_GENERIC: Final[str] = "Generic" @@ -50,6 +53,10 @@ def get_info_from_project(project: str) -> tuple[str, str]: return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3 case "iEAST-02": return MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5 + case "WiiM_Amp_4layer": + return MANUFACTURER_WIIM, MODELS_WIIM_AMP + case "Muzo_Mini": + return MANUFACTURER_WIIM, MODELS_WIIM_MINI case _: return MANUFACTURER_GENERIC, MODELS_GENERIC From a1f5e4f37aaddd86438313ad805daf25aa9c67d5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 16 Nov 2024 05:22:06 +1300 Subject: [PATCH 0510/1070] Do not create ESPHome Dashboard update entity if no configuration found (#129751) --- homeassistant/components/esphome/update.py | 19 ++++++--- tests/components/esphome/test_update.py | 47 +++++++++++++++++----- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 5e571399ecb03..2b5930517424f 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -61,6 +61,8 @@ async def async_setup_entry( if (dashboard := async_get_dashboard(hass)) is None: return entry_data = DomainData.get(hass).get_entry_data(entry) + assert entry_data.device_info is not None + device_name = entry_data.device_info.name unsubs: list[CALLBACK_TYPE] = [] @callback @@ -72,13 +74,22 @@ def _async_setup_update_entity() -> None: if not entry_data.available or not dashboard.last_update_success: return + # Do not add Dashboard Entity if this device is not known to the ESPHome dashboard. + if dashboard.data is None or dashboard.data.get(device_name) is None: + return + for unsub in unsubs: unsub() unsubs.clear() async_add_entities([ESPHomeDashboardUpdateEntity(entry_data, dashboard)]) - if entry_data.available and dashboard.last_update_success: + if ( + entry_data.available + and dashboard.last_update_success + and dashboard.data is not None + and dashboard.data.get(device_name) + ): _async_setup_update_entity() return @@ -133,10 +144,8 @@ def _update_attrs(self) -> None: self._attr_supported_features = NO_FEATURES self._attr_installed_version = device_info.esphome_version device = coordinator.data.get(device_info.name) - if device is None: - self._attr_latest_version = None - else: - self._attr_latest_version = device["current_version"] + assert device is not None + self._attr_latest_version = device["current_version"] @callback def _handle_coordinator_update(self) -> None: diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 7593ab21838d2..5060471f5d2b9 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -31,7 +31,6 @@ STATE_OFF, STATE_ON, STATE_UNAVAILABLE, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -83,11 +82,6 @@ def stub_reconnect(): "supported_features": 0, }, ), - ( - [], - STATE_UNKNOWN, # dashboard is available but device is unknown - {"supported_features": 0}, - ), ], ) async def test_update_entity( @@ -408,11 +402,7 @@ async def test_update_becomes_available_at_runtime( ) await hass.async_block_till_done() state = hass.states.get("update.test_firmware") - assert state is not None - features = state.attributes[ATTR_SUPPORTED_FEATURES] - # There are no devices on the dashboard so no - # way to tell the version so install is disabled - assert features is UpdateEntityFeature(0) + assert state is None # A device gets added to the dashboard mock_dashboard["configured"] = [ @@ -433,6 +423,41 @@ async def test_update_becomes_available_at_runtime( assert features is UpdateEntityFeature.INSTALL +async def test_update_entity_not_present_with_dashboard_but_unknown_device( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + mock_dashboard: dict[str, Any], +) -> None: + """Test ESPHome update entity does not get created if the device is unknown to the dashboard.""" + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + + mock_dashboard["configured"] = [ + { + "name": "other-test", + "current_version": "2023.2.0-dev", + "configuration": "other-test.yaml", + } + ] + + state = hass.states.get("update.test_firmware") + assert state is None + + await async_get_dashboard(hass).async_refresh() + await hass.async_block_till_done() + + state = hass.states.get("update.none_firmware") + assert state is None + + async def test_generic_device_update_entity( hass: HomeAssistant, mock_client: APIClient, From 50cc6b4e014208aaab0e55187493062b726a3df3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 15 Nov 2024 17:37:57 +0100 Subject: [PATCH 0511/1070] Use shorthand attribute for extra state attributes in statistics (#129353) --- homeassistant/components/statistics/sensor.py | 27 +++----- tests/components/statistics/test_sensor.py | 61 ++++++++++++++++++- 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 50d07d4e46686..b6f1844f774b3 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -364,7 +364,7 @@ def __init__( self.states: deque[float | bool] = deque(maxlen=self._samples_max_buffer_size) self.ages: deque[datetime] = deque(maxlen=self._samples_max_buffer_size) - self.attributes: dict[str, StateType] = {} + self._attr_extra_state_attributes = {} self._state_characteristic_fn: Callable[[], float | int | datetime | None] = ( self._callable_characteristic_fn(self._state_characteristic) @@ -462,10 +462,10 @@ def _add_state_to_queue(self, new_state: State) -> None: # Here we make a copy the current value, which is okay. self._attr_available = new_state.state != STATE_UNAVAILABLE if new_state.state == STATE_UNAVAILABLE: - self.attributes[STAT_SOURCE_VALUE_VALID] = None + self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = None return if new_state.state in (STATE_UNKNOWN, None, ""): - self.attributes[STAT_SOURCE_VALUE_VALID] = False + self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False return try: @@ -475,9 +475,9 @@ def _add_state_to_queue(self, new_state: State) -> None: else: self.states.append(float(new_state.state)) self.ages.append(new_state.last_reported) - self.attributes[STAT_SOURCE_VALUE_VALID] = True + self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = True except ValueError: - self.attributes[STAT_SOURCE_VALUE_VALID] = False + self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False _LOGGER.error( "%s: parsing error. Expected number or binary state, but received '%s'", self.entity_id, @@ -584,13 +584,6 @@ def _calculate_state_class(self, new_state: State) -> SensorStateClass | None: return None return SensorStateClass.MEASUREMENT - @property - def extra_state_attributes(self) -> dict[str, StateType] | None: - """Return the state attributes of the sensor.""" - return { - key: value for key, value in self.attributes.items() if value is not None - } - def _purge_old_states(self, max_age: timedelta) -> None: """Remove states which are older than a given age.""" now = dt_util.utcnow() @@ -657,7 +650,7 @@ def _async_purge_update_and_schedule(self) -> None: if self._samples_max_age is not None: self._purge_old_states(self._samples_max_age) - self._update_attributes() + self._update_extra_state_attributes() self._update_value() # If max_age is set, ensure to update again after the defined interval. @@ -738,22 +731,22 @@ async def _initialize_from_database(self) -> None: self.async_write_ha_state() _LOGGER.debug("%s: initializing from database completed", self.entity_id) - def _update_attributes(self) -> None: + def _update_extra_state_attributes(self) -> None: """Calculate and update the various attributes.""" if self._samples_max_buffer_size is not None: - self.attributes[STAT_BUFFER_USAGE_RATIO] = round( + self._attr_extra_state_attributes[STAT_BUFFER_USAGE_RATIO] = round( len(self.states) / self._samples_max_buffer_size, 2 ) if self._samples_max_age is not None: if len(self.states) >= 1: - self.attributes[STAT_AGE_COVERAGE_RATIO] = round( + self._attr_extra_state_attributes[STAT_AGE_COVERAGE_RATIO] = round( (self.ages[-1] - self.ages[0]).total_seconds() / self._samples_max_age.total_seconds(), 2, ) else: - self.attributes[STAT_AGE_COVERAGE_RATIO] = None + self._attr_extra_state_attributes[STAT_AGE_COVERAGE_RATIO] = 0 def _update_value(self) -> None: """Front to call the right statistical characteristics functions. diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 7e2bc1cb16b1c..1dff13bb21afc 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -118,7 +118,6 @@ async def test_sensor_defaults_numeric(hass: HomeAssistant) -> None: assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) assert state.attributes.get("source_value_valid") is True assert "age_coverage_ratio" not in state.attributes - # Source sensor turns unavailable, then available with valid value, # statistics sensor should follow state = hass.states.get("sensor.test") @@ -576,7 +575,7 @@ async def test_age_limit_expiry(hass: HomeAssistant) -> None: assert state is not None assert state.state == STATE_UNKNOWN assert state.attributes.get("buffer_usage_ratio") == round(0 / 20, 2) - assert state.attributes.get("age_coverage_ratio") is None + assert state.attributes.get("age_coverage_ratio") == 0 async def test_age_limit_expiry_with_keep_last_sample(hass: HomeAssistant) -> None: @@ -2032,3 +2031,61 @@ async def test_not_valid_device_class(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + +async def test_attributes_remains(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test attributes are always present.""" + for value in VALUES_NUMERIC: + hass.states.async_set( + "sensor.test_monitored", + str(value), + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + current_time = dt_util.utcnow() + with freeze_time(current_time) as freezer: + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "max_age": {"seconds": 10}, + }, + ] + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == str(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2)) + assert state.attributes == { + "age_coverage_ratio": 0.0, + "friendly_name": "test", + "icon": "mdi:calculator", + "source_value_valid": True, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": "°C", + } + + freezer.move_to(current_time + timedelta(minutes=1)) + async_fire_time_changed(hass) + + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes == { + "age_coverage_ratio": 0, + "friendly_name": "test", + "icon": "mdi:calculator", + "source_value_valid": True, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": "°C", + } From e26142949df39507ff3aa6001e7c8a7379654213 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 15 Nov 2024 17:38:30 +0100 Subject: [PATCH 0512/1070] Add action for using transformation items to Habitica (#129606) --- homeassistant/components/habitica/const.py | 5 + homeassistant/components/habitica/icons.json | 3 + homeassistant/components/habitica/services.py | 96 ++++ .../components/habitica/services.yaml | 22 + .../components/habitica/strings.json | 35 ++ .../habitica/fixtures/party_members.json | 442 ++++++++++++++++++ tests/components/habitica/fixtures/user.json | 2 + tests/components/habitica/test_services.py | 245 +++++++++- 8 files changed, 849 insertions(+), 1 deletion(-) create mode 100644 tests/components/habitica/fixtures/party_members.json diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index ae98cb13dcb52..1fcc4b36053ee 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -26,6 +26,8 @@ ATTR_SKILL = "skill" ATTR_TASK = "task" ATTR_DIRECTION = "direction" +ATTR_TARGET = "target" +ATTR_ITEM = "item" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" SERVICE_ACCEPT_QUEST = "accept_quest" @@ -36,6 +38,9 @@ SERVICE_SCORE_HABIT = "score_habit" SERVICE_SCORE_REWARD = "score_reward" +SERVICE_TRANSFORMATION = "transformation" + + WARRIOR = "warrior" ROGUE = "rogue" HEALER = "healer" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index d33b9c60c966b..ca0ae604f1440 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -187,6 +187,9 @@ }, "score_reward": { "service": "mdi:sack" + }, + "transformation": { + "service": "mdi:flask-round-bottom" } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index a50e5f1e6e3ed..7f2d66e4690c3 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -27,8 +27,10 @@ ATTR_CONFIG_ENTRY, ATTR_DATA, ATTR_DIRECTION, + ATTR_ITEM, ATTR_PATH, ATTR_SKILL, + ATTR_TARGET, ATTR_TASK, DOMAIN, EVENT_API_CALL_SUCCESS, @@ -42,6 +44,7 @@ SERVICE_SCORE_HABIT, SERVICE_SCORE_REWARD, SERVICE_START_QUEST, + SERVICE_TRANSFORMATION, ) from .types import HabiticaConfigEntry @@ -77,6 +80,14 @@ } ) +SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_ITEM): cv.string, + vol.Required(ATTR_TARGET): cv.string, + } +) + def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: """Return config entry or raise if not found or not loaded.""" @@ -294,6 +305,83 @@ async def score_task(call: ServiceCall) -> ServiceResponse: await coordinator.async_request_refresh() return response + async def transformation(call: ServiceCall) -> ServiceResponse: + """User a transformation item on a player character.""" + + entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + ITEMID_MAP = { + "snowball": {"itemId": "snowball"}, + "spooky_sparkles": {"itemId": "spookySparkles"}, + "seafoam": {"itemId": "seafoam"}, + "shiny_seed": {"itemId": "shinySeed"}, + } + # check if target is self + if call.data[ATTR_TARGET] in ( + coordinator.data.user["id"], + coordinator.data.user["profile"]["name"], + coordinator.data.user["auth"]["local"]["username"], + ): + target_id = coordinator.data.user["id"] + else: + # check if target is a party member + try: + party = await coordinator.api.groups.party.members.get() + except ClientResponseError as e: + if e.status == HTTPStatus.TOO_MANY_REQUESTS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + if e.status == HTTPStatus.NOT_FOUND: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="party_not_found", + ) from e + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + ) from e + try: + target_id = next( + member["id"] + for member in party + if call.data[ATTR_TARGET].lower() + in ( + member["id"], + member["auth"]["local"]["username"].lower(), + member["profile"]["name"].lower(), + ) + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="target_not_found", + translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"}, + ) from e + try: + response: dict[str, Any] = await coordinator.api.user.class_.cast[ + ITEMID_MAP[call.data[ATTR_ITEM]]["itemId"] + ].post(targetId=target_id) + except ClientResponseError as e: + if e.status == HTTPStatus.TOO_MANY_REQUESTS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + if e.status == HTTPStatus.UNAUTHORIZED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"item": call.data[ATTR_ITEM]}, + ) from e + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + ) from e + else: + return response + hass.services.async_register( DOMAIN, SERVICE_API_CALL, @@ -323,3 +411,11 @@ async def score_task(call: ServiceCall) -> ServiceResponse: schema=SERVICE_SCORE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) + + hass.services.async_register( + DOMAIN, + SERVICE_TRANSFORMATION, + transformation, + schema=SERVICE_TRANSFORMATION_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index b539f6c65bf92..a89c935b63016 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -72,3 +72,25 @@ score_reward: fields: config_entry: *config_entry task: *task +transformation: + fields: + config_entry: + required: true + selector: + config_entry: + integration: habitica + item: + required: true + selector: + select: + options: + - "snowball" + - "spooky_sparkles" + - "seafoam" + - "shiny_seed" + mode: dropdown + translation_key: "transformation_item_select" + target: + required: true + selector: + text: diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index ac1faf5fcef1d..d32e4a048c733 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -321,6 +321,15 @@ }, "quest_not_found": { "message": "Unable to complete action, quest or group not found" + }, + "target_not_found": { + "message": "Unable to find target {target} in your party" + }, + "party_not_found": { + "message": "Unable to find target, you are currently not in a party. You can only target yourself" + }, + "item_not_found": { + "message": "Unable to use {item}, you don't own this item." } }, "issues": { @@ -461,6 +470,24 @@ "description": "The name (or task ID) of the custom reward." } } + }, + "transformation": { + "name": "Use a transformation item", + "description": "Use a transformation item from your Habitica character's inventory on a member of your party or yourself.", + "fields": { + "config_entry": { + "name": "Select character", + "description": "Choose the Habitica character to use the transformation item." + }, + "item": { + "name": "Transformation item", + "description": "Select the transformation item you want to use. Item must be in the characters inventory." + }, + "target": { + "name": "Target character", + "description": "The name of the character you want to use the transformation item on. You can also specify the players username or user ID." + } + } } }, "selector": { @@ -471,6 +498,14 @@ "backstab": "Rogue: Backstab", "smash": "Warrior: Brutal smash" } + }, + "transformation_item_select": { + "options": { + "snowball": "Snowball", + "spooky_sparkles": "Spooky sparkles", + "seafoam": "Seafoam", + "shiny_seed": "Shiny seed" + } } } } diff --git a/tests/components/habitica/fixtures/party_members.json b/tests/components/habitica/fixtures/party_members.json new file mode 100644 index 0000000000000..e1bb31e6d81d9 --- /dev/null +++ b/tests/components/habitica/fixtures/party_members.json @@ -0,0 +1,442 @@ +{ + "success": true, + "data": [ + { + "_id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "auth": { + "local": { + "username": "test-username" + }, + "timestamps": { + "created": "2024-10-19T18:43:39.782Z", + "loggedin": "2024-10-31T16:13:35.048Z", + "updated": "2024-10-31T16:15:56.552Z" + } + }, + "achievements": { + "ultimateGearSets": { + "healer": false, + "wizard": false, + "rogue": false, + "warrior": false + }, + "streak": 0, + "challenges": [], + "perfect": 1, + "quests": {}, + "purchasedEquipment": true, + "completedTask": true, + "partyUp": true + }, + "backer": {}, + "contributor": {}, + "flags": { + "verifiedUsername": true, + "classSelected": true + }, + "items": { + "gear": { + "owned": { + "headAccessory_special_blackHeadband": true, + "headAccessory_special_blueHeadband": true, + "headAccessory_special_greenHeadband": true, + "headAccessory_special_pinkHeadband": true, + "headAccessory_special_redHeadband": true, + "headAccessory_special_whiteHeadband": true, + "headAccessory_special_yellowHeadband": true, + "eyewear_special_blackTopFrame": true, + "eyewear_special_blueTopFrame": true, + "eyewear_special_greenTopFrame": true, + "eyewear_special_pinkTopFrame": true, + "eyewear_special_redTopFrame": true, + "eyewear_special_whiteTopFrame": true, + "eyewear_special_yellowTopFrame": true, + "eyewear_special_blackHalfMoon": true, + "eyewear_special_blueHalfMoon": true, + "eyewear_special_greenHalfMoon": true, + "eyewear_special_pinkHalfMoon": true, + "eyewear_special_redHalfMoon": true, + "eyewear_special_whiteHalfMoon": true, + "eyewear_special_yellowHalfMoon": true, + "armor_special_bardRobes": true, + "weapon_special_fall2024Warrior": true, + "shield_special_fall2024Warrior": true, + "head_special_fall2024Warrior": true, + "armor_special_fall2024Warrior": true, + "back_mystery_201402": true, + "body_mystery_202003": true, + "head_special_bardHat": true, + "weapon_wizard_0": true + }, + "equipped": { + "weapon": "weapon_special_fall2024Warrior", + "armor": "armor_special_fall2024Warrior", + "head": "head_special_fall2024Warrior", + "shield": "shield_special_fall2024Warrior", + "back": "back_mystery_201402", + "headAccessory": "headAccessory_special_pinkHeadband", + "eyewear": "eyewear_special_pinkHalfMoon", + "body": "body_mystery_202003" + }, + "costume": { + "armor": "armor_base_0", + "head": "head_base_0", + "shield": "shield_base_0" + } + }, + "special": { + "snowball": 99, + "spookySparkles": 99, + "shinySeed": 99, + "seafoam": 99, + "valentine": 0, + "valentineReceived": [], + "nye": 0, + "nyeReceived": [], + "greeting": 0, + "greetingReceived": [], + "thankyou": 0, + "thankyouReceived": [], + "birthday": 0, + "birthdayReceived": [], + "congrats": 0, + "congratsReceived": [], + "getwell": 0, + "getwellReceived": [], + "goodluck": 0, + "goodluckReceived": [] + }, + "pets": { + "Rat-Shade": 1, + "Gryphatrice-Jubilant": 1 + }, + "currentPet": "Gryphatrice-Jubilant", + "eggs": { + "Cactus": 1, + "Fox": 2, + "Wolf": 1 + }, + "hatchingPotions": { + "CottonCandyBlue": 1, + "RoyalPurple": 1 + }, + "food": { + "Meat": 2, + "Chocolate": 1, + "CottonCandyPink": 1, + "Candy_Zombie": 1 + }, + "mounts": { + "Velociraptor-Base": true, + "Gryphon-Gryphatrice": true + }, + "currentMount": "Gryphon-Gryphatrice", + "quests": { + "dustbunnies": 1, + "vice1": 1, + "atom1": 1, + "moonstone1": 1, + "goldenknight1": 1, + "basilist": 1 + }, + "lastDrop": { + "date": "2024-10-31T16:13:34.952Z", + "count": 0 + } + }, + "party": { + "quest": { + "progress": { + "up": 0, + "down": 0, + "collectedItems": 0, + "collect": {} + }, + "RSVPNeeded": false, + "key": "dustbunnies" + }, + "order": "level", + "orderAscending": "ascending", + "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + }, + "preferences": { + "size": "slim", + "hair": { + "color": "red", + "base": 3, + "bangs": 1, + "beard": 0, + "mustache": 0, + "flower": 1 + }, + "skin": "915533", + "shirt": "blue", + "chair": "handleless_pink", + "costume": false, + "sleep": false, + "disableClasses": false, + "tasks": { + "groupByChallenge": false, + "confirmScoreNotes": false, + "mirrorGroupTasks": [], + "activeFilter": { + "habit": "all", + "daily": "all", + "todo": "remaining", + "reward": "all" + } + }, + "background": "violet" + }, + "profile": { + "name": "test-user" + }, + "stats": { + "hp": 50, + "mp": 150.8, + "exp": 127, + "gp": 19.08650199252128, + "lvl": 99, + "class": "wizard", + "points": 0, + "str": 0, + "con": 0, + "int": 0, + "per": 0, + "buffs": { + "str": 50, + "int": 50, + "per": 50, + "con": 50, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "training": { + "int": 0, + "per": 0, + "str": 0, + "con": 0 + }, + "toNextLevel": 3580, + "maxHealth": 50, + "maxMP": 228 + }, + "inbox": { + "optOut": false + }, + "loginIncentives": 6, + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303" + }, + { + "_id": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + "auth": { + "local": { + "username": "test-partymember-username" + }, + "timestamps": { + "created": "2024-10-10T15:57:01.106Z", + "loggedin": "2024-10-30T19:37:01.970Z", + "updated": "2024-10-30T19:38:25.968Z" + } + }, + "achievements": { + "ultimateGearSets": { + "healer": false, + "wizard": false, + "rogue": false, + "warrior": false + }, + "streak": 0, + "challenges": [], + "perfect": 1, + "quests": {}, + "completedTask": true, + "partyUp": true, + "snowball": 1, + "spookySparkles": 1, + "seafoam": 1, + "shinySeed": 1 + }, + "backer": {}, + "contributor": {}, + "flags": { + "verifiedUsername": true, + "classSelected": false + }, + "items": { + "gear": { + "equipped": { + "armor": "armor_base_0", + "head": "head_base_0", + "shield": "shield_base_0" + }, + "costume": { + "armor": "armor_base_0", + "head": "head_base_0", + "shield": "shield_base_0" + }, + "owned": { + "headAccessory_special_blackHeadband": true, + "headAccessory_special_blueHeadband": true, + "headAccessory_special_greenHeadband": true, + "headAccessory_special_pinkHeadband": true, + "headAccessory_special_redHeadband": true, + "headAccessory_special_whiteHeadband": true, + "headAccessory_special_yellowHeadband": true, + "eyewear_special_blackTopFrame": true, + "eyewear_special_blueTopFrame": true, + "eyewear_special_greenTopFrame": true, + "eyewear_special_pinkTopFrame": true, + "eyewear_special_redTopFrame": true, + "eyewear_special_whiteTopFrame": true, + "eyewear_special_yellowTopFrame": true, + "eyewear_special_blackHalfMoon": true, + "eyewear_special_blueHalfMoon": true, + "eyewear_special_greenHalfMoon": true, + "eyewear_special_pinkHalfMoon": true, + "eyewear_special_redHalfMoon": true, + "eyewear_special_whiteHalfMoon": true, + "eyewear_special_yellowHalfMoon": true, + "armor_special_bardRobes": true + } + }, + "special": { + "snowball": 0, + "spookySparkles": 0, + "shinySeed": 0, + "seafoam": 0, + "valentine": 0, + "valentineReceived": [], + "nye": 0, + "nyeReceived": [], + "greeting": 0, + "greetingReceived": [], + "thankyou": 0, + "thankyouReceived": [], + "birthday": 0, + "birthdayReceived": [], + "congrats": 0, + "congratsReceived": [], + "getwell": 0, + "getwellReceived": [], + "goodluck": 0, + "goodluckReceived": [] + }, + "lastDrop": { + "count": 0, + "date": "2024-10-30T19:37:01.838Z" + }, + "currentPet": "", + "currentMount": "", + "pets": {}, + "eggs": { + "BearCub": 1, + "Cactus": 1 + }, + "hatchingPotions": { + "Skeleton": 1 + }, + "food": { + "Candy_Red": 1 + }, + "mounts": {}, + "quests": { + "dustbunnies": 1 + } + }, + "party": { + "quest": { + "progress": { + "up": 0, + "down": 0, + "collectedItems": 0, + "collect": {} + }, + "RSVPNeeded": true, + "key": "dustbunnies" + }, + "order": "level", + "orderAscending": "ascending", + "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + }, + "preferences": { + "size": "slim", + "hair": { + "color": "red", + "base": 3, + "bangs": 1, + "beard": 0, + "mustache": 0, + "flower": 1 + }, + "skin": "915533", + "shirt": "blue", + "chair": "none", + "costume": false, + "sleep": false, + "disableClasses": false, + "tasks": { + "groupByChallenge": false, + "confirmScoreNotes": false, + "mirrorGroupTasks": [], + "activeFilter": { + "habit": "all", + "daily": "all", + "todo": "remaining", + "reward": "all" + } + }, + "background": "violet" + }, + "profile": { + "name": "test-partymember-displayname" + }, + "stats": { + "buffs": { + "str": 1, + "int": 1, + "per": 1, + "con": 1, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": true, + "snowball": false, + "spookySparkles": false + }, + "training": { + "int": 0, + "per": 0, + "str": 0, + "con": 0 + }, + "hp": 50, + "mp": 24, + "exp": 24, + "gp": 4, + "lvl": 1, + "class": "warrior", + "points": 0, + "str": 0, + "con": 0, + "int": 0, + "per": 0, + "toNextLevel": 25, + "maxHealth": 50, + "maxMP": 32 + }, + "inbox": { + "optOut": false + }, + "loginIncentives": 1, + "id": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7" + } + ], + "notifications": [], + "userV": 96, + "appVersion": "5.29.0" +} diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index 569c5b81a023e..e1b77cd31f21b 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -2,6 +2,7 @@ "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, + "auth": { "local": { "username": "test-username" } }, "stats": { "buffs": { "str": 26, @@ -65,6 +66,7 @@ }, "needsCron": true, "lastCron": "2024-09-21T22:01:55.586Z", + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", "items": { "gear": { "equipped": { diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 403779bcbfbb5..cd363eba3b572 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -10,7 +10,9 @@ from homeassistant.components.habitica.const import ( ATTR_CONFIG_ENTRY, ATTR_DIRECTION, + ATTR_ITEM, ATTR_SKILL, + ATTR_TARGET, ATTR_TASK, DEFAULT_URL, DOMAIN, @@ -23,12 +25,13 @@ SERVICE_SCORE_HABIT, SERVICE_SCORE_REWARD, SERVICE_START_QUEST, + SERVICE_TRANSFORMATION, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from .conftest import mock_called_with +from .conftest import load_json_object_fixture, mock_called_with from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -62,6 +65,15 @@ async def load_entry( assert config_entry.state is ConfigEntryState.LOADED +@pytest.fixture(autouse=True) +def uuid_mock() -> Generator[None]: + """Mock the UUID.""" + with patch( + "uuid.uuid4", return_value="5d1935ff-80c8-443c-b2e9-733c66b44745" + ) as uuid_mock: + yield uuid_mock.return_value + + @pytest.mark.parametrize( ("service_data", "item", "target_id"), [ @@ -546,3 +558,234 @@ async def test_score_task_exceptions( return_response=True, blocking=True, ) + + +@pytest.mark.parametrize( + ("service_data", "item", "target_id"), + [ + ( + { + ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ATTR_ITEM: "spooky_sparkles", + }, + "spookySparkles", + "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ), + ( + { + ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ATTR_ITEM: "shiny_seed", + }, + "shinySeed", + "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ), + ( + { + ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ATTR_ITEM: "seafoam", + }, + "seafoam", + "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ), + ( + { + ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ATTR_ITEM: "snowball", + }, + "snowball", + "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ), + ( + { + ATTR_TARGET: "test-user", + ATTR_ITEM: "spooky_sparkles", + }, + "spookySparkles", + "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ), + ( + { + ATTR_TARGET: "test-username", + ATTR_ITEM: "spooky_sparkles", + }, + "spookySparkles", + "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ), + ( + { + ATTR_TARGET: "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + ATTR_ITEM: "spooky_sparkles", + }, + "spookySparkles", + "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + "spookySparkles", + "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + ), + ( + { + ATTR_TARGET: "test-partymember-displayname", + ATTR_ITEM: "spooky_sparkles", + }, + "spookySparkles", + "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + ), + ], + ids=[], +) +async def test_transformation( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service_data: dict[str, Any], + item: str, + target_id: str, +) -> None: + """Test Habitica user transformation item action.""" + mock_habitica.get( + f"{DEFAULT_URL}/api/v3/groups/party/members", + json=load_json_object_fixture("party_members.json", DOMAIN), + ) + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", + json={"success": True, "data": {}}, + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_TRANSFORMATION, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + + assert mock_called_with( + mock_habitica, + "post", + f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", + ) + + +@pytest.mark.parametrize( + ( + "service_data", + "http_status_members", + "http_status_cast", + "expected_exception", + "expected_exception_msg", + ), + [ + ( + { + ATTR_TARGET: "user-not-found", + ATTR_ITEM: "spooky_sparkles", + }, + HTTPStatus.OK, + HTTPStatus.OK, + ServiceValidationError, + "Unable to find target 'user-not-found' in your party", + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + HTTPStatus.TOO_MANY_REQUESTS, + HTTPStatus.OK, + ServiceValidationError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + HTTPStatus.NOT_FOUND, + HTTPStatus.OK, + ServiceValidationError, + "Unable to find target, you are currently not in a party. You can only target yourself", + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + HTTPStatus.BAD_REQUEST, + HTTPStatus.OK, + HomeAssistantError, + "Unable to connect to Habitica, try again later", + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + HTTPStatus.OK, + HTTPStatus.TOO_MANY_REQUESTS, + ServiceValidationError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + HTTPStatus.OK, + HTTPStatus.UNAUTHORIZED, + ServiceValidationError, + "Unable to use spooky_sparkles, you don't own this item", + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + HTTPStatus.OK, + HTTPStatus.BAD_REQUEST, + HomeAssistantError, + "Unable to connect to Habitica, try again later", + ), + ], +) +@pytest.mark.usefixtures("mock_habitica") +async def test_transformation_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service_data: dict[str, Any], + http_status_members: HTTPStatus, + http_status_cast: HTTPStatus, + expected_exception: Exception, + expected_exception_msg: str, +) -> None: + """Test Habitica transformation action exceptions.""" + mock_habitica.get( + f"{DEFAULT_URL}/api/v3/groups/party/members", + json=load_json_object_fixture("party_members.json", DOMAIN), + status=http_status_members, + ) + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/user/class/cast/spookySparkles?targetId=ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + json={"success": True, "data": {}}, + status=http_status_cast, + ) + + with pytest.raises(expected_exception, match=expected_exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_TRANSFORMATION, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) From 5b0d8eb75e3eed0177ae6d4e3ee34c5333529b32 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Fri, 15 Nov 2024 19:03:37 +0100 Subject: [PATCH 0513/1070] Add sensor platform to eq3btsmart (#130438) --- .../components/eq3btsmart/__init__.py | 1 + homeassistant/components/eq3btsmart/const.py | 2 + .../components/eq3btsmart/icons.json | 10 ++- homeassistant/components/eq3btsmart/sensor.py | 84 +++++++++++++++++++ .../components/eq3btsmart/strings.json | 8 ++ 5 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/eq3btsmart/sensor.py diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index 84b27161edd34..4493f944db3cf 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -22,6 +22,7 @@ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.NUMBER, + Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py index 78292940e60ea..a5f7ea2ff9518 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -29,6 +29,8 @@ ENTITY_KEY_OFFSET = "offset" ENTITY_KEY_WINDOW_OPEN_TEMPERATURE = "window_open_temperature" ENTITY_KEY_WINDOW_OPEN_TIMEOUT = "window_open_timeout" +ENTITY_KEY_VALVE = "valve" +ENTITY_KEY_AWAY_UNTIL = "away_until" GET_DEVICE_TIMEOUT = 5 # seconds diff --git a/homeassistant/components/eq3btsmart/icons.json b/homeassistant/components/eq3btsmart/icons.json index e6eb7532f37c6..892352c2ea471 100644 --- a/homeassistant/components/eq3btsmart/icons.json +++ b/homeassistant/components/eq3btsmart/icons.json @@ -25,11 +25,19 @@ "default": "mdi:timer-refresh" } }, + "sensor": { + "away_until": { + "default": "mdi:home-export-outline" + }, + "valve": { + "default": "mdi:pipe-valve" + } + }, "switch": { "away": { "default": "mdi:home-account", "state": { - "on": "mdi:home-export" + "on": "mdi:home-export-outline" } }, "lock": { diff --git a/homeassistant/components/eq3btsmart/sensor.py b/homeassistant/components/eq3btsmart/sensor.py new file mode 100644 index 0000000000000..bd2605042f42c --- /dev/null +++ b/homeassistant/components/eq3btsmart/sensor.py @@ -0,0 +1,84 @@ +"""Platform for eq3 sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING + +from eq3btsmart.models import Status + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.components.sensor.const import SensorStateClass +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Eq3ConfigEntry +from .const import ENTITY_KEY_AWAY_UNTIL, ENTITY_KEY_VALVE +from .entity import Eq3Entity + + +@dataclass(frozen=True, kw_only=True) +class Eq3SensorEntityDescription(SensorEntityDescription): + """Entity description for eq3 sensor entities.""" + + value_func: Callable[[Status], int | datetime | None] + + +SENSOR_ENTITY_DESCRIPTIONS = [ + Eq3SensorEntityDescription( + key=ENTITY_KEY_VALVE, + translation_key=ENTITY_KEY_VALVE, + value_func=lambda status: status.valve, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + Eq3SensorEntityDescription( + key=ENTITY_KEY_AWAY_UNTIL, + translation_key=ENTITY_KEY_AWAY_UNTIL, + value_func=lambda status: ( + status.away_until.value if status.away_until else None + ), + device_class=SensorDeviceClass.DATE, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: Eq3ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the entry.""" + + async_add_entities( + Eq3SensorEntity(entry, entity_description) + for entity_description in SENSOR_ENTITY_DESCRIPTIONS + ) + + +class Eq3SensorEntity(Eq3Entity, SensorEntity): + """Base class for eq3 sensor entities.""" + + entity_description: Eq3SensorEntityDescription + + def __init__( + self, entry: Eq3ConfigEntry, entity_description: Eq3SensorEntityDescription + ) -> None: + """Initialize the entity.""" + + super().__init__(entry, entity_description.key) + self.entity_description = entity_description + + @property + def native_value(self) -> int | datetime | None: + """Return the value reported by the sensor.""" + + if TYPE_CHECKING: + assert self._thermostat.status is not None + + return self.entity_description.value_func(self._thermostat.status) diff --git a/homeassistant/components/eq3btsmart/strings.json b/homeassistant/components/eq3btsmart/strings.json index acfd5082f4591..ab363f4d7528b 100644 --- a/homeassistant/components/eq3btsmart/strings.json +++ b/homeassistant/components/eq3btsmart/strings.json @@ -42,6 +42,14 @@ "name": "Window open timeout" } }, + "sensor": { + "away_until": { + "name": "Away until" + }, + "valve": { + "name": "Valve" + } + }, "switch": { "lock": { "name": "Lock" From 6279979d506f1c55f7614e333a79c7c226dc8563 Mon Sep 17 00:00:00 2001 From: YogevBokobza Date: Fri, 15 Nov 2024 21:03:20 +0200 Subject: [PATCH 0514/1070] Switcher add current current temperature sensor (#130653) Co-authored-by: Franck Nijhof --- .../components/switcher_kis/icons.json | 3 +++ homeassistant/components/switcher_kis/sensor.py | 12 ++++++++++++ .../components/switcher_kis/strings.json | 3 +++ tests/components/switcher_kis/consts.py | 6 ++++++ tests/components/switcher_kis/test_sensor.py | 17 ++++++++++++++--- 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switcher_kis/icons.json b/homeassistant/components/switcher_kis/icons.json index 6ca8e0e83516f..bd770d3e656ec 100644 --- a/homeassistant/components/switcher_kis/icons.json +++ b/homeassistant/components/switcher_kis/icons.json @@ -20,6 +20,9 @@ }, "auto_shutdown": { "default": "mdi:progress-clock" + }, + "temperature": { + "default": "mdi:thermometer" } } }, diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index 9ff3d6dfaae3f..0ed60e5a721fb 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -46,9 +46,16 @@ entity_registry_enabled_default=False, ), ] +TEMPERATURE_SENSORS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="temperature", + translation_key="temperature", + ), +] POWER_PLUG_SENSORS = POWER_SENSORS WATER_HEATER_SENSORS = [*POWER_SENSORS, *TIME_SENSORS] +THERMOSTAT_SENSORS = TEMPERATURE_SENSORS async def async_setup_entry( @@ -71,6 +78,11 @@ def async_add_sensors(coordinator: SwitcherDataUpdateCoordinator) -> None: SwitcherSensorEntity(coordinator, description) for description in WATER_HEATER_SENSORS ) + elif coordinator.data.device_type.category == DeviceCategory.THERMOSTAT: + async_add_entities( + SwitcherSensorEntity(coordinator, description) + for description in THERMOSTAT_SENSORS + ) config_entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_sensors) diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index 798a43c981cd4..844cbb4ca9895 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -59,6 +59,9 @@ }, "auto_shutdown": { "name": "Auto shutdown" + }, + "temperature": { + "name": "Current temperature" } } }, diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index fe77ee0236b95..e9d96673e245d 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -219,3 +219,9 @@ ) DUMMY_SWITCHER_DEVICES = [DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE] + +DUMMY_SWITCHER_SENSORS_DEVICES = [ + DUMMY_PLUG_DEVICE, + DUMMY_WATER_HEATER_DEVICE, + DUMMY_THERMOSTAT_DEVICE, +] diff --git a/tests/components/switcher_kis/test_sensor.py b/tests/components/switcher_kis/test_sensor.py index 8ccc33f2d37db..f99d91bd9a3bf 100644 --- a/tests/components/switcher_kis/test_sensor.py +++ b/tests/components/switcher_kis/test_sensor.py @@ -7,7 +7,12 @@ from homeassistant.util import slugify from . import init_integration -from .consts import DUMMY_PLUG_DEVICE, DUMMY_SWITCHER_DEVICES, DUMMY_WATER_HEATER_DEVICE +from .consts import ( + DUMMY_PLUG_DEVICE, + DUMMY_SWITCHER_SENSORS_DEVICES, + DUMMY_THERMOSTAT_DEVICE, + DUMMY_WATER_HEATER_DEVICE, +) DEVICE_SENSORS_TUPLE = ( ( @@ -25,17 +30,23 @@ ("remaining_time", "remaining_time"), ], ), + ( + DUMMY_THERMOSTAT_DEVICE, + [ + ("current_temperature", "temperature"), + ], + ), ) -@pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_DEVICES], indirect=True) +@pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_SENSORS_DEVICES], indirect=True) async def test_sensor_platform(hass: HomeAssistant, mock_bridge) -> None: """Test sensor platform.""" entry = await init_integration(hass) assert mock_bridge assert mock_bridge.is_running is True - assert len(entry.runtime_data) == 2 + assert len(entry.runtime_data) == 3 for device, sensors in DEVICE_SENSORS_TUPLE: for sensor, field in sensors: From 57212bbf579ee5a59dfc811e5e0e027948ccdcd0 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 15 Nov 2024 20:06:57 +0100 Subject: [PATCH 0515/1070] KNX: Cache last telegram for each group address (#130566) --- homeassistant/components/knx/const.py | 4 +-- homeassistant/components/knx/telegrams.py | 7 +++++ homeassistant/components/knx/websocket.py | 22 ++++++++++++++++ tests/components/knx/test_config_flow.py | 4 +-- tests/components/knx/test_websocket.py | 31 +++++++++++++++++++++++ 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index e22546d380696..7a9dfc3454687 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -52,8 +52,8 @@ DEFAULT_ROUTING_IA: Final = "0.0.240" CONF_KNX_TELEGRAM_LOG_SIZE: Final = "telegram_log_size" -TELEGRAM_LOG_DEFAULT: Final = 200 -TELEGRAM_LOG_MAX: Final = 5000 # ~2 MB or ~5 hours of reasonable bus load +TELEGRAM_LOG_DEFAULT: Final = 1000 +TELEGRAM_LOG_MAX: Final = 25000 # ~10 MB or ~25 hours of reasonable bus load ## # Secure constants diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index f4b31fd11f950..dcd5f4776796d 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -75,6 +75,7 @@ def __init__( ) ) self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size) + self.last_ga_telegrams: dict[str, TelegramDict] = {} async def load_history(self) -> None: """Load history from store.""" @@ -88,6 +89,9 @@ async def load_history(self) -> None: if isinstance(telegram["payload"], list): telegram["payload"] = tuple(telegram["payload"]) # type: ignore[unreachable] self.recent_telegrams.extend(telegrams) + self.last_ga_telegrams = { + t["destination"]: t for t in telegrams if t["payload"] is not None + } async def save_history(self) -> None: """Save history to store.""" @@ -98,6 +102,9 @@ def _xknx_telegram_cb(self, telegram: Telegram) -> None: """Handle incoming and outgoing telegrams from xknx.""" telegram_dict = self.telegram_to_dict(telegram) self.recent_telegrams.append(telegram_dict) + if telegram_dict["payload"] is not None: + # exclude GroupValueRead telegrams + self.last_ga_telegrams[telegram_dict["destination"]] = telegram_dict async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM, telegram, telegram_dict) def telegram_to_dict(self, telegram: Telegram) -> TelegramDict: diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 6cb2218b22103..9ba3e0ccff6f0 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -47,6 +47,7 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_project_file_process) websocket_api.async_register_command(hass, ws_project_file_remove) websocket_api.async_register_command(hass, ws_group_monitor_info) + websocket_api.async_register_command(hass, ws_group_telegrams) websocket_api.async_register_command(hass, ws_subscribe_telegram) websocket_api.async_register_command(hass, ws_get_knx_project) websocket_api.async_register_command(hass, ws_validate_entity) @@ -287,6 +288,27 @@ def ws_group_monitor_info( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/group_telegrams", + } +) +@provide_knx +@callback +def ws_group_telegrams( + hass: HomeAssistant, + knx: KNXModule, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get group telegrams command.""" + connection.send_result( + msg["id"], + knx.telegrams.last_ga_telegrams, + ) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 78751c7e641c3..2187721a5184f 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -913,7 +913,7 @@ async def test_form_with_automatic_connection_handling( CONF_KNX_ROUTE_BACK: False, CONF_KNX_TUNNEL_ENDPOINT_IA: None, CONF_KNX_STATE_UPDATER: True, - CONF_KNX_TELEGRAM_LOG_SIZE: 200, + CONF_KNX_TELEGRAM_LOG_SIZE: 1000, } knx_setup.assert_called_once() @@ -1210,7 +1210,7 @@ async def test_options_flow_connection_type( CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_USER_ID: None, CONF_KNX_SECURE_USER_PASSWORD: None, - CONF_KNX_TELEGRAM_LOG_SIZE: 200, + CONF_KNX_TELEGRAM_LOG_SIZE: 1000, } diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index b3e4b7aaa382a..a34f126e4f4ca 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -180,6 +180,37 @@ async def test_knx_group_monitor_info_command( assert res["result"]["recent_telegrams"] == [] +async def test_knx_group_telegrams_command( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator +) -> None: + """Test knx/group_telegrams command.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "knx/group_telegrams"}) + res = await client.receive_json() + assert res["success"], res + assert res["result"] == {} + + # # get some telegrams to populate the cache + await knx.receive_write("1/1/1", True) + await knx.receive_read("2/2/2") # read telegram shall be ignored + await knx.receive_write("3/3/3", 0x34) + + await client.send_json_auto_id({"type": "knx/group_telegrams"}) + res = await client.receive_json() + assert res["success"], res + assert len(res["result"]) == 2 + assert "1/1/1" in res["result"] + assert res["result"]["1/1/1"]["destination"] == "1/1/1" + assert "3/3/3" in res["result"] + assert res["result"]["3/3/3"]["payload"] == 52 + assert res["result"]["3/3/3"]["telegramtype"] == "GroupValueWrite" + assert res["result"]["3/3/3"]["source"] == "1.2.3" + assert res["result"]["3/3/3"]["direction"] == "Incoming" + assert res["result"]["3/3/3"]["timestamp"] is not None + + async def test_knx_subscribe_telegrams_command_recent_telegrams( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: From 9b989ff3d5c002752905729c36a5fb3d9c540042 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 15 Nov 2024 22:57:29 +0100 Subject: [PATCH 0516/1070] Bump ruff to 0.7.4 (#130716) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 56fbabe808780..5fcd7eb5f8007 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.3 + rev: v0.7.4 hooks: - id: ruff args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 23f584dd0dec0..85e7bfc4edaca 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.3.0 -ruff==0.7.3 +ruff==0.7.4 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 0fa0a1a89faa4..0b10b72cfdd37 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -22,7 +22,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.3 \ + stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.4 \ PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.1 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" From a047abd51088ae60e54ce423371f47f3b52cc3f3 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sat, 16 Nov 2024 08:02:37 +0100 Subject: [PATCH 0517/1070] Fix and bump codecov-action to 5.0.2 (#130729) --- .github/workflows/ci.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4be2200c698ee..dc9270ebe9a1b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1248,12 +1248,11 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.0.0 + uses: codecov/codecov-action@v5.0.2 with: fail_ci_if_error: true flags: full-suite token: ${{ secrets.CODECOV_TOKEN }} - version: v0.6.0 pytest-partial: runs-on: ubuntu-24.04 @@ -1387,8 +1386,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.0.0 + uses: codecov/codecov-action@v5.0.2 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} - version: v0.6.0 From 0ada59a4fedf2c2c1b35543857e627594b0dbd1b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 16 Nov 2024 15:06:41 +0100 Subject: [PATCH 0518/1070] Update twentemilieu to 2.1.0 (#130752) --- homeassistant/components/twentemilieu/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index aef70aa6a1015..8ba4f3b760e73 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["twentemilieu"], "quality_scale": "platinum", - "requirements": ["twentemilieu==2.0.1"] + "requirements": ["twentemilieu==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 049310c344bf9..d27295e25a7f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2882,7 +2882,7 @@ ttn_client==1.2.0 tuya-device-sharing-sdk==0.2.1 # homeassistant.components.twentemilieu -twentemilieu==2.0.1 +twentemilieu==2.1.0 # homeassistant.components.twilio twilio==6.32.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f04da50271ed0..692976d453d56 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2295,7 +2295,7 @@ ttn_client==1.2.0 tuya-device-sharing-sdk==0.2.1 # homeassistant.components.twentemilieu -twentemilieu==2.0.1 +twentemilieu==2.1.0 # homeassistant.components.twilio twilio==6.32.0 From c219b512eb0e55fa4c12f5172982a58c2fb17cca Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 16 Nov 2024 17:40:01 +0100 Subject: [PATCH 0519/1070] Fix file uploads in MQTT config flow not processed in executor (#130746) Process file uploads in MQTT config flow in executor --- homeassistant/components/mqtt/config_flow.py | 25 +++++++++++--------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 6e6b44cd4b886..69306a1c3830b 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -33,7 +33,7 @@ CONF_PROTOCOL, CONF_USERNAME, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import config_validation as cv from homeassistant.helpers.hassio import is_hassio @@ -735,6 +735,16 @@ def _validate( ) +async def _get_uploaded_file(hass: HomeAssistant, id: str) -> str: + """Get file content from uploaded file.""" + + def _proces_uploaded_file() -> str: + with process_uploaded_file(hass, id) as file_path: + return file_path.read_text(encoding=DEFAULT_ENCODING) + + return await hass.async_add_executor_job(_proces_uploaded_file) + + async def async_get_broker_settings( flow: ConfigFlow | OptionsFlow, fields: OrderedDict[Any, Any], @@ -793,8 +803,7 @@ async def _async_validate_broker_settings( return False certificate_id: str | None = user_input.get(CONF_CERTIFICATE) if certificate_id: - with process_uploaded_file(hass, certificate_id) as certificate_file: - certificate = certificate_file.read_text(encoding=DEFAULT_ENCODING) + certificate = await _get_uploaded_file(hass, certificate_id) # Return to form for file upload CA cert or client cert and key if ( @@ -810,15 +819,9 @@ async def _async_validate_broker_settings( return False if client_certificate_id: - with process_uploaded_file( - hass, client_certificate_id - ) as client_certificate_file: - client_certificate = client_certificate_file.read_text( - encoding=DEFAULT_ENCODING - ) + client_certificate = await _get_uploaded_file(hass, client_certificate_id) if client_key_id: - with process_uploaded_file(hass, client_key_id) as key_file: - client_key = key_file.read_text(encoding=DEFAULT_ENCODING) + client_key = await _get_uploaded_file(hass, client_key_id) certificate_data: dict[str, Any] = {} if certificate: From acfc4711cde434aef6e0a659bdb522e7e8f603b4 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sat, 16 Nov 2024 11:40:49 -0500 Subject: [PATCH 0520/1070] Fix Sonos get_queue action may fail if track metadata is missing (#130756) initial commit --- homeassistant/components/sonos/media_player.py | 6 +++--- tests/components/sonos/fixtures/sonos_queue.json | 12 ++++++++++++ .../sonos/snapshots/test_media_player.ambr | 6 ++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 7711a1e88eaa4..8d0917c5dbaee 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -782,9 +782,9 @@ def get_queue(self) -> list[dict]: queue: list[DidlMusicTrack] = self.coordinator.soco.get_queue(max_items=0) return [ { - ATTR_MEDIA_TITLE: track.title, - ATTR_MEDIA_ALBUM_NAME: track.album, - ATTR_MEDIA_ARTIST: track.creator, + ATTR_MEDIA_TITLE: getattr(track, "title", None), + ATTR_MEDIA_ALBUM_NAME: getattr(track, "album", None), + ATTR_MEDIA_ARTIST: getattr(track, "creator", None), ATTR_MEDIA_CONTENT_ID: track.get_uri(), } for track in queue diff --git a/tests/components/sonos/fixtures/sonos_queue.json b/tests/components/sonos/fixtures/sonos_queue.json index 50689a00e1d36..ffe08fc2b0824 100644 --- a/tests/components/sonos/fixtures/sonos_queue.json +++ b/tests/components/sonos/fixtures/sonos_queue.json @@ -26,5 +26,17 @@ "protocol_info": "file:*:audio/mpegurl:*" } ] + }, + { + "title": "Track with no album or creator", + "item_id": "Q:0/3", + "parent_id": "Q:0", + "original_track_number": 1, + "resources": [ + { + "uri": "x-file-cifs://192.168.42.10/music/TrackWithNoAlbumOrCreator.mp3", + "protocol_info": "file:*:audio/mpegurl:*" + } + ] } ] diff --git a/tests/components/sonos/snapshots/test_media_player.ambr b/tests/components/sonos/snapshots/test_media_player.ambr index f382d341de667..8ef298de3db19 100644 --- a/tests/components/sonos/snapshots/test_media_player.ambr +++ b/tests/components/sonos/snapshots/test_media_player.ambr @@ -71,6 +71,12 @@ 'media_content_id': 'x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/01%20Come%20Together.mp3', 'media_title': 'Come Together', }), + dict({ + 'media_album_name': None, + 'media_artist': None, + 'media_content_id': 'x-file-cifs://192.168.42.10/music/TrackWithNoAlbumOrCreator.mp3', + 'media_title': 'Track with no album or creator', + }), ]), }) # --- From 9711816542b581d7c40a3d69668c3711d5af7c23 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 16 Nov 2024 11:42:10 -0500 Subject: [PATCH 0521/1070] Increase Hydrawise polling time to 5 minutes (#130759) --- homeassistant/components/hydrawise/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index 633c00ce65907..6d846dd612702 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -10,7 +10,7 @@ MANUFACTURER = "Hydrawise" -MAIN_SCAN_INTERVAL = timedelta(seconds=60) +MAIN_SCAN_INTERVAL = timedelta(minutes=5) WATER_USE_SCAN_INTERVAL = timedelta(minutes=60) SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" From fece83d8827ef6068ff08426ae4a4d8187fbf23b Mon Sep 17 00:00:00 2001 From: Patrick <14628713+patman15@users.noreply.github.com> Date: Sun, 17 Nov 2024 00:49:30 +0100 Subject: [PATCH 0522/1070] Fix and bump apsystems-ez1 to 2.4.0 (#130740) --- homeassistant/components/apsystems/__init__.py | 1 + homeassistant/components/apsystems/manifest.json | 2 +- homeassistant/components/apsystems/switch.py | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/apsystems/__init__.py b/homeassistant/components/apsystems/__init__.py index 372ce52e04981..c437f5584db64 100644 --- a/homeassistant/components/apsystems/__init__.py +++ b/homeassistant/components/apsystems/__init__.py @@ -38,6 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> ip_address=entry.data[CONF_IP_ADDRESS], port=entry.data.get(CONF_PORT, DEFAULT_PORT), timeout=8, + enable_debounce=True, ) coordinator = ApSystemsDataCoordinator(hass, api) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/apsystems/manifest.json b/homeassistant/components/apsystems/manifest.json index 9376d21ba2890..a58530b05e238 100644 --- a/homeassistant/components/apsystems/manifest.json +++ b/homeassistant/components/apsystems/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/apsystems", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["apsystems-ez1==2.2.1"] + "requirements": ["apsystems-ez1==2.4.0"] } diff --git a/homeassistant/components/apsystems/switch.py b/homeassistant/components/apsystems/switch.py index 93a21ec9f05aa..739148454451d 100644 --- a/homeassistant/components/apsystems/switch.py +++ b/homeassistant/components/apsystems/switch.py @@ -5,6 +5,7 @@ from typing import Any from aiohttp.client_exceptions import ClientConnectionError +from APsystemsEZ1 import InverterReturnedError from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant @@ -40,7 +41,7 @@ async def async_update(self) -> None: """Update switch status and availability.""" try: status = await self._api.get_device_power_status() - except (TimeoutError, ClientConnectionError): + except (TimeoutError, ClientConnectionError, InverterReturnedError): self._attr_available = False else: self._attr_available = True diff --git a/requirements_all.txt b/requirements_all.txt index d27295e25a7f9..d86b8e82e72c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -480,7 +480,7 @@ apprise==1.9.0 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.2.1 +apsystems-ez1==2.4.0 # homeassistant.components.aqualogic aqualogic==2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 692976d453d56..03d60058e7439 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -453,7 +453,7 @@ apprise==1.9.0 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.2.1 +apsystems-ez1==2.4.0 # homeassistant.components.aranet aranet4==2.4.0 From d8dd6d6abea3e35e77f06ca6b744f492b7a15adb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 17 Nov 2024 01:58:25 +0100 Subject: [PATCH 0523/1070] Fix unexpected stop of media playback via ffmpeg proxy for ESPhome devices (#130788) disable writing progress stats to stderr in ffmpeg command --- homeassistant/components/esphome/ffmpeg_proxy.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/esphome/ffmpeg_proxy.py b/homeassistant/components/esphome/ffmpeg_proxy.py index cefe87f49ba55..2dacae52f75a0 100644 --- a/homeassistant/components/esphome/ffmpeg_proxy.py +++ b/homeassistant/components/esphome/ffmpeg_proxy.py @@ -179,6 +179,9 @@ async def transcode( # Remove metadata and cover art command_args.extend(["-map_metadata", "-1", "-vn"]) + # disable progress stats on stderr + command_args.append("-nostats") + # Output to stdout command_args.append("pipe:") From b64f33e1d72ad48dcaa57367b0609a89251df924 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 16 Nov 2024 20:07:00 -0800 Subject: [PATCH 0524/1070] Remove Nest code related to Works with Nest API removal (#130785) --- homeassistant/components/nest/__init__.py | 15 ---- homeassistant/components/nest/strings.json | 6 -- tests/components/nest/common.py | 34 +-------- tests/components/nest/conftest.py | 22 +----- tests/components/nest/test_config_flow.py | 85 ++++++++-------------- tests/components/nest/test_init.py | 41 ----------- 6 files changed, 33 insertions(+), 170 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 6b094c68cb024..e89969cbe167a 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -49,7 +49,6 @@ config_validation as cv, device_registry as dr, entity_registry as er, - issue_registry as ir, ) from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.helpers.typing import ConfigType @@ -119,20 +118,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.http.register_view(NestEventMediaView(hass)) hass.http.register_view(NestEventMediaThumbnailView(hass)) - if DOMAIN in config and CONF_PROJECT_ID not in config[DOMAIN]: - ir.async_create_issue( - hass, - DOMAIN, - "legacy_nest_deprecated", - breaks_in_ha_version="2023.8.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="legacy_nest_removed", - translation_placeholders={ - "documentation_url": "https://www.home-assistant.io/integrations/nest/", - }, - ) - return False return True diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index f6a64dd66e64a..a31a285654417 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -84,12 +84,6 @@ "doorbell_chime": "Doorbell pressed" } }, - "issues": { - "legacy_nest_removed": { - "title": "Legacy Works With Nest has been removed", - "description": "Legacy Works With Nest has been removed from Home Assistant, and the API shuts down as of September 2023.\n\nYou must take action to use the SDM API. Remove all `nest` configuration from `configuration.yaml` and restart Home Assistant, then see the Nest [integration instructions]({documentation_url}) for set up instructions and supported devices." - } - }, "entity": { "event": { "chime": { diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index f34c40e09f9cb..8f1f0a2f0747e 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -4,8 +4,7 @@ from collections.abc import Awaitable, Callable, Generator import copy -from dataclasses import dataclass, field -import time +from dataclasses import dataclass from typing import Any from google_nest_sdm.auth import AbstractAuth @@ -37,7 +36,6 @@ class NestTestConfig: """Holder for integration configuration.""" - config: dict[str, Any] = field(default_factory=dict) config_entry_data: dict[str, Any] | None = None credential: ClientCredential | None = None @@ -54,39 +52,9 @@ class NestTestConfig: credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), ) TEST_CONFIGFLOW_APP_CREDS = NestTestConfig( - config=TEST_CONFIG_APP_CREDS.config, credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), ) -TEST_CONFIG_LEGACY = NestTestConfig( - config={ - "nest": { - "client_id": "some-client-id", - "client_secret": "some-client-secret", - }, - }, - config_entry_data={ - "auth_implementation": "local", - "tokens": { - "expires_at": time.time() + 86400, - "access_token": { - "token": "some-token", - }, - }, - }, -) -TEST_CONFIG_ENTRY_LEGACY = NestTestConfig( - config_entry_data={ - "auth_implementation": "local", - "tokens": { - "expires_at": time.time() + 86400, - "access_token": { - "token": "some-token", - }, - }, - }, -) - TEST_CONFIG_NEW_SUBSCRIPTION = NestTestConfig( config_entry_data={ "sdm": {}, diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index b070d0256124e..84f22e17e7850 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -202,20 +202,6 @@ def nest_test_config() -> NestTestConfig: return TEST_CONFIG_APP_CREDS -@pytest.fixture -def config( - subscriber_id: str | None, nest_test_config: NestTestConfig -) -> dict[str, Any]: - """Fixture that sets up the configuration.yaml for the test.""" - config = copy.deepcopy(nest_test_config.config) - if CONF_SUBSCRIBER_ID in config.get(DOMAIN, {}): - if subscriber_id: - config[DOMAIN][CONF_SUBSCRIBER_ID] = subscriber_id - else: - del config[DOMAIN][CONF_SUBSCRIBER_ID] - return config - - @pytest.fixture def config_entry_unique_id() -> str: """Fixture to set ConfigEntry unique id.""" @@ -275,20 +261,18 @@ async def credential(hass: HomeAssistant, nest_test_config: NestTestConfig) -> N async def setup_base_platform( hass: HomeAssistant, platforms: list[str], - config: dict[str, Any], config_entry: MockConfigEntry | None, ) -> YieldFixture[PlatformSetup]: """Fixture to setup the integration platform.""" - if config_entry: - config_entry.add_to_hass(hass) + config_entry.add_to_hass(hass) with patch("homeassistant.components.nest.PLATFORMS", platforms): async def _setup_func() -> bool: - assert await async_setup_component(hass, DOMAIN, config) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() yield _setup_func - if config_entry and config_entry.state == ConfigEntryState.LOADED: + if config_entry.state == ConfigEntryState.LOADED: await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index 8b05ace6d4de0..807e299b79c52 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -27,7 +27,6 @@ TEST_CONFIGFLOW_APP_CREDS, FakeSubscriber, NestTestConfig, - PlatformSetup, ) from tests.common import MockConfigEntry @@ -350,11 +349,11 @@ def mock_pubsub_api_responses( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_app_credentials( - hass: HomeAssistant, oauth, subscriber, setup_platform + hass: HomeAssistant, + oauth, + subscriber, ) -> None: """Check full flow.""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -389,12 +388,8 @@ async def test_app_credentials( ("sdm_managed_topic", "device_access_project_id", "cloud_project_id"), [(True, "new-project-id", "new-cloud-project-id")], ) -async def test_config_flow_restart( - hass: HomeAssistant, oauth, subscriber, setup_platform -) -> None: +async def test_config_flow_restart(hass: HomeAssistant, oauth, subscriber) -> None: """Check with auth implementation is re-initialized when aborting the flow.""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -447,11 +442,11 @@ async def test_config_flow_restart( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_config_flow_wrong_project_id( - hass: HomeAssistant, oauth, subscriber, setup_platform + hass: HomeAssistant, + oauth, + subscriber, ) -> None: """Check the case where the wrong project ids are entered.""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -506,12 +501,9 @@ async def test_config_flow_wrong_project_id( async def test_config_flow_pubsub_configuration_error( hass: HomeAssistant, oauth, - setup_platform, mock_subscriber, ) -> None: """Check full flow fails with configuration error.""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -554,11 +546,9 @@ async def test_config_flow_pubsub_configuration_error( [(True, HTTPStatus.INTERNAL_SERVER_ERROR)], ) async def test_config_flow_pubsub_subscriber_error( - hass: HomeAssistant, oauth, setup_platform, mock_subscriber + hass: HomeAssistant, oauth, mock_subscriber ) -> None: """Check full flow with a subscriber error.""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -707,11 +697,9 @@ async def test_reauth_multiple_config_entries( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_pubsub_subscription_strip_whitespace( - hass: HomeAssistant, oauth, subscriber, setup_platform + hass: HomeAssistant, oauth, subscriber ) -> None: """Check that project id has whitespace stripped on entry.""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -742,11 +730,9 @@ async def test_pubsub_subscription_strip_whitespace( [(True, HTTPStatus.UNAUTHORIZED)], ) async def test_pubsub_subscription_auth_failure( - hass: HomeAssistant, oauth, setup_platform, mock_subscriber + hass: HomeAssistant, oauth, mock_subscriber ) -> None: """Check flow that creates a pub/sub subscription.""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -819,7 +805,7 @@ async def test_pubsub_subscriber_config_entry_reauth( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_config_entry_title_from_home( - hass: HomeAssistant, oauth, setup_platform, subscriber + hass: HomeAssistant, oauth, subscriber ) -> None: """Test that the Google Home name is used for the config entry title.""" @@ -837,8 +823,6 @@ async def test_config_entry_title_from_home( ) ) - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -864,7 +848,7 @@ async def test_config_entry_title_from_home( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_config_entry_title_multiple_homes( - hass: HomeAssistant, oauth, setup_platform, subscriber + hass: HomeAssistant, oauth, subscriber ) -> None: """Test handling of multiple Google Homes authorized.""" @@ -894,8 +878,6 @@ async def test_config_entry_title_multiple_homes( ) ) - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -911,11 +893,9 @@ async def test_config_entry_title_multiple_homes( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_title_failure_fallback( - hass: HomeAssistant, oauth, setup_platform, mock_subscriber + hass: HomeAssistant, oauth, mock_subscriber ) -> None: """Test exception handling when determining the structure names.""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -943,9 +923,7 @@ async def test_title_failure_fallback( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) -async def test_structure_missing_trait( - hass: HomeAssistant, oauth, setup_platform, subscriber -) -> None: +async def test_structure_missing_trait(hass: HomeAssistant, oauth, subscriber) -> None: """Test handling the case where a structure has no name set.""" device_manager = await subscriber.async_get_device_manager() @@ -959,8 +937,6 @@ async def test_structure_missing_trait( ) ) - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -996,11 +972,11 @@ async def test_dhcp_discovery( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_dhcp_discovery_with_creds( - hass: HomeAssistant, oauth, subscriber, setup_platform + hass: HomeAssistant, + oauth, + subscriber, ) -> None: """Exercise discovery dhcp with no config present (can't run).""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -1054,13 +1030,10 @@ async def test_token_error( hass: HomeAssistant, oauth: OAuthFixture, subscriber: FakeSubscriber, - setup_platform: PlatformSetup, status_code: HTTPStatus, error_reason: str, ) -> None: """Check full flow.""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -1090,11 +1063,11 @@ async def test_token_error( ], ) async def test_existing_topic_and_subscription( - hass: HomeAssistant, oauth, subscriber, setup_platform + hass: HomeAssistant, + oauth, + subscriber, ) -> None: """Test selecting existing user managed topic and subscription.""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -1129,11 +1102,11 @@ async def test_existing_topic_and_subscription( async def test_no_eligible_topics( - hass: HomeAssistant, oauth, subscriber, setup_platform + hass: HomeAssistant, + oauth, + subscriber, ) -> None: """Test the case where there are no eligible pub/sub topics.""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -1153,11 +1126,11 @@ async def test_no_eligible_topics( ], ) async def test_list_topics_failure( - hass: HomeAssistant, oauth, subscriber, setup_platform + hass: HomeAssistant, + oauth, + subscriber, ) -> None: """Test selecting existing user managed topic and subscription.""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -1177,11 +1150,11 @@ async def test_list_topics_failure( ], ) async def test_list_subscriptions_failure( - hass: HomeAssistant, oauth, subscriber, setup_platform + hass: HomeAssistant, + oauth, + subscriber, ) -> None: """Test selecting existing user managed topic and subscription.""" - await setup_platform() - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index a17803a6cdedd..17ddc485e85c8 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -24,22 +24,16 @@ from homeassistant.components.nest import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .common import ( PROJECT_ID, SUBSCRIBER_ID, - TEST_CONFIG_ENTRY_LEGACY, - TEST_CONFIG_LEGACY, TEST_CONFIG_NEW_SUBSCRIPTION, - TEST_CONFIGFLOW_APP_CREDS, FakeSubscriber, PlatformSetup, YieldFixture, ) -from tests.common import MockConfigEntry - PLATFORM = "sensor" @@ -201,18 +195,6 @@ async def test_subscriber_configuration_failure( assert entries[0].state is ConfigEntryState.SETUP_ERROR -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) -async def test_empty_config( - hass: HomeAssistant, error_caplog: pytest.LogCaptureFixture, config, setup_platform -) -> None: - """Test setup is a no-op with not config.""" - await setup_platform() - assert not error_caplog.records - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 0 - - async def test_unload_entry(hass: HomeAssistant, setup_platform) -> None: """Test successful unload of a ConfigEntry.""" await setup_platform() @@ -318,26 +300,3 @@ async def test_migrate_unique_id( assert config_entry.state is ConfigEntryState.LOADED assert config_entry.unique_id == PROJECT_ID - - -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_LEGACY]) -async def test_legacy_works_with_nest_yaml( - hass: HomeAssistant, - config: dict[str, Any], - config_entry: MockConfigEntry, -) -> None: - """Test integration won't start with legacy works with nest yaml config.""" - config_entry.add_to_hass(hass) - assert not await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_ENTRY_LEGACY]) -async def test_legacy_works_with_nest_cleanup( - hass: HomeAssistant, setup_platform -) -> None: - """Test legacy works with nest config entries are silently removed once yaml is removed.""" - await setup_platform() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 0 From f58b5418ead043636951d2de281c3e0fbfa6e5f6 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sun, 17 Nov 2024 05:07:16 +0100 Subject: [PATCH 0525/1070] Update knx-frontend to 2024.11.16.205004 (#130786) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index df895282a2b29..39e3dced0d55d 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.3.0", "xknxproject==3.8.1", - "knx-frontend==2024.9.10.221729" + "knx-frontend==2024.11.16.205004" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index d86b8e82e72c5..23eb7fa79276b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1253,7 +1253,7 @@ kiwiki-client==0.1.1 knocki==0.3.5 # homeassistant.components.knx -knx-frontend==2024.9.10.221729 +knx-frontend==2024.11.16.205004 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03d60058e7439..09c7aa672c814 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1052,7 +1052,7 @@ kegtron-ble==0.4.0 knocki==0.3.5 # homeassistant.components.knx -knx-frontend==2024.9.10.221729 +knx-frontend==2024.11.16.205004 # homeassistant.components.konnected konnected==1.2.0 From 96299b16e2bb752c987687f14efde830a1914b7e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 16 Nov 2024 20:09:59 -0800 Subject: [PATCH 0526/1070] Remove code for old fitbit config import (#130783) * Remove code for old fitbit config import * Remove translations related to issues --- homeassistant/components/fitbit/sensor.py | 158 +------------- homeassistant/components/fitbit/strings.json | 14 -- tests/components/fitbit/conftest.py | 75 +------ tests/components/fitbit/test_config_flow.py | 204 +------------------ tests/components/fitbit/test_sensor.py | 32 +-- 5 files changed, 27 insertions(+), 456 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index ab9a593e19591..2218454bd6186 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -6,30 +6,16 @@ from dataclasses import dataclass import datetime import logging -import os from typing import Any, Final, cast -from fitbit import Fitbit -from oauthlib.oauth2.rfc6749.errors import OAuth2Error -import voluptuous as vol - -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_TOKEN, - CONF_UNIT_SYSTEM, PERCENTAGE, EntityCategory, UnitOfLength, @@ -38,33 +24,13 @@ UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResultType -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.json import load_json_object from .api import FitbitApi -from .const import ( - ATTR_ACCESS_TOKEN, - ATTR_LAST_SAVED_AT, - ATTR_REFRESH_TOKEN, - ATTRIBUTION, - BATTERY_LEVELS, - CONF_CLOCK_FORMAT, - CONF_MONITORED_RESOURCES, - DEFAULT_CLOCK_FORMAT, - DEFAULT_CONFIG, - DOMAIN, - FITBIT_CONFIG_FILE, - FITBIT_DEFAULT_RESOURCES, - FitbitScope, - FitbitUnitSystem, -) +from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem from .coordinator import FitbitData, FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException from .model import FitbitDevice, config_from_entry_data @@ -533,126 +499,6 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=PERCENTAGE, ) -FITBIT_RESOURCES_KEYS: Final[list[str]] = [ - desc.key - for desc in (*FITBIT_RESOURCES_LIST, FITBIT_RESOURCE_BATTERY, SLEEP_START_TIME) -] - -PLATFORM_SCHEMA: Final = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Optional( - CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES - ): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_KEYS)]), - vol.Optional(CONF_CLOCK_FORMAT, default=DEFAULT_CLOCK_FORMAT): vol.In( - ["12H", "24H"] - ), - vol.Optional(CONF_UNIT_SYSTEM, default=FitbitUnitSystem.LEGACY_DEFAULT): vol.In( - [ - FitbitUnitSystem.EN_GB, - FitbitUnitSystem.EN_US, - FitbitUnitSystem.METRIC, - FitbitUnitSystem.LEGACY_DEFAULT, - ] - ), - } -) - -# Only import configuration if it was previously created successfully with all -# of the following fields. -FITBIT_CONF_KEYS = [ - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - ATTR_ACCESS_TOKEN, - ATTR_REFRESH_TOKEN, - ATTR_LAST_SAVED_AT, -] - - -def load_config_file(config_path: str) -> dict[str, Any] | None: - """Load existing valid fitbit.conf from disk for import.""" - if os.path.isfile(config_path): - config_file = load_json_object(config_path) - if config_file != DEFAULT_CONFIG and all( - key in config_file for key in FITBIT_CONF_KEYS - ): - return config_file - return None - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Fitbit sensor.""" - config_path = hass.config.path(FITBIT_CONFIG_FILE) - config_file = await hass.async_add_executor_job(load_config_file, config_path) - _LOGGER.debug("loaded config file: %s", config_file) - - if config_file is not None: - _LOGGER.debug("Importing existing fitbit.conf application credentials") - - # Refresh the token before importing to ensure it is working and not - # expired on first initialization. - authd_client = Fitbit( - config_file[CONF_CLIENT_ID], - config_file[CONF_CLIENT_SECRET], - access_token=config_file[ATTR_ACCESS_TOKEN], - refresh_token=config_file[ATTR_REFRESH_TOKEN], - expires_at=config_file[ATTR_LAST_SAVED_AT], - refresh_cb=lambda x: None, - ) - try: - updated_token = await hass.async_add_executor_job( - authd_client.client.refresh_token - ) - except OAuth2Error as err: - _LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err) - translation_key = "deprecated_yaml_import_issue_cannot_connect" - else: - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET] - ), - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - "auth_implementation": DOMAIN, - CONF_TOKEN: { - ATTR_ACCESS_TOKEN: updated_token[ATTR_ACCESS_TOKEN], - ATTR_REFRESH_TOKEN: updated_token[ATTR_REFRESH_TOKEN], - "expires_at": updated_token["expires_at"], - "scope": " ".join(updated_token.get("scope", [])), - }, - CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], - CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], - CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES], - }, - ) - translation_key = "deprecated_yaml_import" - if ( - result.get("type") == FlowResultType.ABORT - and result.get("reason") == "cannot_connect" - ): - translation_key = "deprecated_yaml_import_issue_cannot_connect" - else: - translation_key = "deprecated_yaml_no_import" - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2024.5.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=translation_key, - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index e1ca1b01f7a68..2df6fa14b0789 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -40,19 +40,5 @@ "name": "Battery level" } } - }, - "issues": { - "deprecated_yaml_no_import": { - "title": "Fitbit YAML configuration is being removed", - "description": "Configuring Fitbit using YAML is being removed.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf if it exists and restart Home Assistant and [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually." - }, - "deprecated_yaml_import": { - "title": "Fitbit YAML configuration is being removed", - "description": "Configuring Fitbit using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically, including OAuth Application Credentials.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf and restart Home Assistant to fix this issue." - }, - "deprecated_yaml_import_issue_cannot_connect": { - "title": "The Fitbit YAML configuration import failed", - "description": "Configuring Fitbit using YAML is being removed but there was a connection error importing your YAML configuration.\n\nRestart Home Assistant to try again or remove the Fitbit YAML configuration from your configuration.yaml file and remove the fitbit.conf and continue to [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually." - } } } diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 5751173999316..48ceca02d0e63 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -1,6 +1,6 @@ """Test fixtures for fitbit.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable import datetime from http import HTTPStatus import time @@ -14,12 +14,7 @@ ClientCredential, async_import_client_credential, ) -from homeassistant.components.fitbit.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - DOMAIN, - OAUTH_SCOPES, -) +from homeassistant.components.fitbit.const import DOMAIN, OAUTH_SCOPES from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -83,13 +78,16 @@ def mock_token_entry(token_expiration_time: float, scopes: list[str]) -> dict[st @pytest.fixture(name="config_entry") -def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: +def mock_config_entry( + token_entry: dict[str, Any], imported_config_data: dict[str, Any] +) -> MockConfigEntry: """Fixture for a config entry.""" return MockConfigEntry( domain=DOMAIN, data={ "auth_implementation": FAKE_AUTH_IMPL, "token": token_entry, + **imported_config_data, }, unique_id=PROFILE_USER_ID, ) @@ -107,37 +105,6 @@ async def setup_credentials(hass: HomeAssistant) -> None: ) -@pytest.fixture(name="fitbit_config_yaml") -def mock_fitbit_config_yaml(token_expiration_time: float) -> dict[str, Any] | None: - """Fixture for the yaml fitbit.conf file contents.""" - return { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - "access_token": FAKE_ACCESS_TOKEN, - "refresh_token": FAKE_REFRESH_TOKEN, - "last_saved_at": token_expiration_time, - } - - -@pytest.fixture(name="fitbit_config_setup") -def mock_fitbit_config_setup( - fitbit_config_yaml: dict[str, Any] | None, -) -> Generator[None]: - """Fixture to mock out fitbit.conf file data loading and persistence.""" - has_config = fitbit_config_yaml is not None - with ( - patch( - "homeassistant.components.fitbit.sensor.os.path.isfile", - return_value=has_config, - ), - patch( - "homeassistant.components.fitbit.sensor.load_json_object", - return_value=fitbit_config_yaml, - ), - ): - yield - - @pytest.fixture(name="monitored_resources") def mock_monitored_resources() -> list[str] | None: """Fixture for the fitbit yaml config monitored_resources field.""" @@ -150,8 +117,8 @@ def mock_configured_unit_syststem() -> str | None: return None -@pytest.fixture(name="sensor_platform_config") -def mock_sensor_platform_config( +@pytest.fixture(name="imported_config_data") +def mock_imported_config_data( monitored_resources: list[str] | None, configured_unit_system: str | None, ) -> dict[str, Any]: @@ -164,32 +131,6 @@ def mock_sensor_platform_config( return config -@pytest.fixture(name="sensor_platform_setup") -async def mock_sensor_platform_setup( - hass: HomeAssistant, - sensor_platform_config: dict[str, Any], -) -> Callable[[], Awaitable[bool]]: - """Fixture to set up the integration.""" - - async def run() -> bool: - result = await async_setup_component( - hass, - "sensor", - { - "sensor": [ - { - "platform": DOMAIN, - **sensor_platform_config, - } - ] - }, - ) - await hass.async_block_till_done() - return result - - return run - - @pytest.fixture def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index 6f7174594865b..70c54cd2657b5 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -2,7 +2,6 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus -import time from typing import Any from unittest.mock import patch @@ -13,7 +12,7 @@ from homeassistant.components.fitbit.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir +from homeassistant.helpers import config_entry_oauth2_flow from .conftest import ( CLIENT_ID, @@ -255,207 +254,6 @@ async def test_config_entry_already_exists( assert result.get("reason") == "already_configured" -@pytest.mark.parametrize( - "token_expiration_time", - [time.time() + 86400, time.time() - 86400], - ids=("token_active", "token_expired"), -) -async def test_import_fitbit_config( - hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], - issue_registry: ir.IssueRegistry, - requests_mock: Mocker, -) -> None: - """Test that platform configuration is imported successfully.""" - - requests_mock.register_uri( - "POST", - OAUTH2_TOKEN, - status_code=HTTPStatus.OK, - json=SERVER_ACCESS_TOKEN, - ) - - with patch( - "homeassistant.components.fitbit.async_setup_entry", return_value=True - ) as mock_setup: - await sensor_platform_setup() - - assert len(mock_setup.mock_calls) == 1 - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - # Verify valid profile can be fetched from the API - config_entry = entries[0] - assert config_entry.title == DISPLAY_NAME - assert config_entry.unique_id == PROFILE_USER_ID - - data = dict(config_entry.data) - # Verify imported values from fitbit.conf and configuration.yaml and - # that the token is updated. - assert "token" in data - expires_at = data["token"]["expires_at"] - assert expires_at > time.time() - del data["token"]["expires_at"] - assert dict(config_entry.data) == { - "auth_implementation": DOMAIN, - "clock_format": "24H", - "monitored_resources": ["activities/steps"], - "token": { - "access_token": "server-access-token", - "refresh_token": "server-refresh-token", - "scope": "activity heartrate nutrition profile settings sleep weight", - }, - "unit_system": "default", - } - - # Verify an issue is raised for deprecated configuration.yaml - issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) - assert issue - assert issue.translation_key == "deprecated_yaml_import" - - -async def test_import_fitbit_config_failure_cannot_connect( - hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], - issue_registry: ir.IssueRegistry, - requests_mock: Mocker, -) -> None: - """Test platform configuration fails to import successfully.""" - - requests_mock.register_uri( - "POST", - OAUTH2_TOKEN, - status_code=HTTPStatus.OK, - json=SERVER_ACCESS_TOKEN, - ) - requests_mock.register_uri( - "GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR - ) - - with patch( - "homeassistant.components.fitbit.async_setup_entry", return_value=True - ) as mock_setup: - await sensor_platform_setup() - - assert len(mock_setup.mock_calls) == 0 - - # Verify an issue is raised that we were unable to import configuration - issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) - assert issue - assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" - - -@pytest.mark.parametrize( - "status_code", - [ - (HTTPStatus.UNAUTHORIZED), - (HTTPStatus.INTERNAL_SERVER_ERROR), - ], -) -async def test_import_fitbit_config_cannot_refresh( - hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], - issue_registry: ir.IssueRegistry, - requests_mock: Mocker, - status_code: HTTPStatus, -) -> None: - """Test platform configuration import fails when refreshing the token.""" - - requests_mock.register_uri( - "POST", - OAUTH2_TOKEN, - status_code=status_code, - json="", - ) - - with patch( - "homeassistant.components.fitbit.async_setup_entry", return_value=True - ) as mock_setup: - await sensor_platform_setup() - - assert len(mock_setup.mock_calls) == 0 - - # Verify an issue is raised that we were unable to import configuration - issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) - assert issue - assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" - - -async def test_import_fitbit_config_already_exists( - hass: HomeAssistant, - config_entry: MockConfigEntry, - setup_credentials: None, - integration_setup: Callable[[], Awaitable[bool]], - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], - issue_registry: ir.IssueRegistry, - requests_mock: Mocker, -) -> None: - """Test that platform configuration is not imported if it already exists.""" - - requests_mock.register_uri( - "POST", - OAUTH2_TOKEN, - status_code=HTTPStatus.OK, - json=SERVER_ACCESS_TOKEN, - ) - - # Verify existing config entry - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - with patch( - "homeassistant.components.fitbit.async_setup_entry", return_value=True - ) as mock_config_entry_setup: - await integration_setup() - - assert len(mock_config_entry_setup.mock_calls) == 1 - - with patch( - "homeassistant.components.fitbit.async_setup_entry", return_value=True - ) as mock_import_setup: - await sensor_platform_setup() - - assert len(mock_import_setup.mock_calls) == 0 - - # Still one config entry - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - # Verify an issue is raised for deprecated configuration.yaml - issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) - assert issue - assert issue.translation_key == "deprecated_yaml_import" - - -async def test_platform_setup_without_import( - hass: HomeAssistant, - sensor_platform_setup: Callable[[], Awaitable[bool]], - issue_registry: ir.IssueRegistry, -) -> None: - """Test platform configuration.yaml but no existing fitbit.conf credentials.""" - - with patch( - "homeassistant.components.fitbit.async_setup_entry", return_value=True - ) as mock_setup: - await sensor_platform_setup() - - # Verify no configuration entry is imported since the integration is not - # fully setup properly - assert len(mock_setup.mock_calls) == 0 - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 0 - - # Verify an issue is raised for deprecated configuration.yaml - assert len(issue_registry.issues) == 1 - issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) - assert issue - assert issue.translation_key == "deprecated_yaml_no_import" - - @pytest.mark.usefixtures("current_request_with_host") async def test_reauth_flow( hass: HomeAssistant, diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 9443d0500ebe5..d67bd75396fc7 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -212,8 +212,8 @@ def mock_token_refresh(requests_mock: Mocker) -> None: ) async def test_sensors( hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], entity_registry: er.EntityRegistry, entity_id: str, @@ -226,7 +226,7 @@ async def test_sensors( register_timeseries( api_resource, timeseries_response(api_resource.replace("/", "-"), api_value) ) - await sensor_platform_setup() + await integration_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -243,13 +243,13 @@ async def test_sensors( ) async def test_device_battery( hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], entity_registry: er.EntityRegistry, ) -> None: """Test battery level sensor for devices.""" - assert await sensor_platform_setup() + assert await integration_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -290,13 +290,13 @@ async def test_device_battery( ) async def test_device_battery_level( hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], entity_registry: er.EntityRegistry, ) -> None: """Test battery level sensor for devices.""" - assert await sensor_platform_setup() + assert await integration_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -347,15 +347,15 @@ async def test_device_battery_level( ) async def test_profile_local( hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], expected_unit: str, ) -> None: """Test the fitbit profile locale impact on unit of measure.""" register_timeseries("body/weight", timeseries_response("body-weight", "175")) - await sensor_platform_setup() + await integration_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -365,7 +365,7 @@ async def test_profile_local( @pytest.mark.parametrize( - ("sensor_platform_config", "api_response", "expected_state"), + ("imported_config_data", "api_response", "expected_state"), [ ( {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, @@ -396,8 +396,8 @@ async def test_profile_local( ) async def test_sleep_time_clock_format( hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], api_response: str, expected_state: str, @@ -407,7 +407,7 @@ async def test_sleep_time_clock_format( register_timeseries( "sleep/startTime", timeseries_response("sleep-startTime", api_response) ) - await sensor_platform_setup() + assert await integration_setup() state = hass.states.get("sensor.sleep_start_time") assert state From 445690588c855498b56561805ed652a12f36a21d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 16 Nov 2024 20:10:22 -0800 Subject: [PATCH 0527/1070] Update Google calendar OAuth instructions (#130775) * Update google calendar oauth instructions * Replace photos with calendar --- homeassistant/components/google/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 2ea45239a5307..acc69c3799ae8 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -45,7 +45,7 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." }, "services": { "add_event": { From db3d3854478086292e98a57af9522f79dc75eede Mon Sep 17 00:00:00 2001 From: dotvav Date: Sun, 17 Nov 2024 12:13:24 +0100 Subject: [PATCH 0528/1070] Bump pypalazzetti to 0.1.12 (#130800) --- homeassistant/components/palazzetti/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index aff82275e2efc..9bf7287fe05c6 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pypalazzetti==0.1.11"] + "requirements": ["pypalazzetti==0.1.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 23eb7fa79276b..c7a97f4e8d449 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2155,7 +2155,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.11 +pypalazzetti==0.1.12 # homeassistant.components.elv pypca==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09c7aa672c814..5390fc4a87215 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1742,7 +1742,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.11 +pypalazzetti==0.1.12 # homeassistant.components.lcn pypck==0.7.24 From 23bf4154f58899f0d97da9deb7d02cb467cc9845 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Nov 2024 13:41:48 -0500 Subject: [PATCH 0529/1070] Bump yarl to 1.17.2 (#130830) changelog: https://github.com/aio-libs/yarl/compare/v1.17.1...v1.17.2 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1e407cca106bc..63425c967e0b5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,7 +70,7 @@ voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.2.0 -yarl==1.17.1 +yarl==1.17.2 zeroconf==0.136.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index c2ce019b95cc6..29c04dd106205 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.17.1", + "yarl==1.17.2", "webrtc-models==0.2.0", ] diff --git a/requirements.txt b/requirements.txt index a4f1c86cc2107..b9acbd896fe51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,5 +47,5 @@ uv==0.5.0 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.17.1 +yarl==1.17.2 webrtc-models==0.2.0 From dcadd2d37c4c72de0448893d8f614ce140bf6961 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 17 Nov 2024 21:27:29 +0100 Subject: [PATCH 0530/1070] Bump uiprotect to 6.5.0 (#130834) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 85867b5c87cf0..7345dde36dffb 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==6.4.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==6.5.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index c7a97f4e8d449..47f998c10b134 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2897,7 +2897,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.4.0 +uiprotect==6.5.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5390fc4a87215..724c364309bd5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2310,7 +2310,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.4.0 +uiprotect==6.5.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 43235713c7010c8f3676e53a780b792ee4fbad36 Mon Sep 17 00:00:00 2001 From: Santobert Date: Sun, 17 Nov 2024 21:29:27 +0100 Subject: [PATCH 0531/1070] Remove myself from codeowners (#130805) --- CODEOWNERS | 2 -- homeassistant/components/neato/manifest.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index e204463695ea9..5bea90913b00a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -974,8 +974,6 @@ build.json @home-assistant/supervisor /tests/components/nanoleaf/ @milanmeu @joostlek /homeassistant/components/nasweb/ @nasWebio /tests/components/nasweb/ @nasWebio -/homeassistant/components/neato/ @Santobert -/tests/components/neato/ @Santobert /homeassistant/components/nederlandse_spoorwegen/ @YarmoM /homeassistant/components/ness_alarm/ @nickw444 /tests/components/ness_alarm/ @nickw444 diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index d6eff486b05f8..e4b471cb5ac01 100644 --- a/homeassistant/components/neato/manifest.json +++ b/homeassistant/components/neato/manifest.json @@ -1,7 +1,7 @@ { "domain": "neato", "name": "Neato Botvac", - "codeowners": ["@Santobert"], + "codeowners": [], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/neato", From 6f947d2612374cb5870729ddec864e2fd59200ef Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 17 Nov 2024 23:28:54 +0100 Subject: [PATCH 0532/1070] Use default device sensors also for AirQ devices in Sensibo (#130841) --- homeassistant/components/sensibo/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index a6a70ea6c4936..b395f8eb1eeb5 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -178,6 +178,7 @@ class SensiboDeviceSensorEntityDescription(SensorEntityDescription): value_fn=lambda data: data.co2, extra_fn=None, ), + *DEVICE_SENSOR_TYPES, ) ELEMENT_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( From f94e80d4c115e962818ba8235d1f0774c4462fd6 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 18 Nov 2024 01:34:33 +0200 Subject: [PATCH 0533/1070] Fix missing Shelly MAC address checks (#130833) * Fix missing Shelly MAC address checks * Make new error for mac_address_mismatch * Use reference in translation --- .../components/shelly/config_flow.py | 7 +++++ .../components/shelly/coordinator.py | 11 ++++++-- homeassistant/components/shelly/strings.json | 6 ++-- tests/components/shelly/test_config_flow.py | 28 +++++++++++++------ tests/components/shelly/test_coordinator.py | 19 +++++-------- 5 files changed, 45 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 1daa4710f3015..55686464637da 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -12,6 +12,7 @@ CustomPortNotSupported, DeviceConnectionError, InvalidAuthError, + MacAddressMismatchError, ) from aioshelly.rpc_device import RpcDevice import voluptuous as vol @@ -176,6 +177,8 @@ async def async_step_user( ) except DeviceConnectionError: errors["base"] = "cannot_connect" + except MacAddressMismatchError: + errors["base"] = "mac_address_mismatch" except CustomPortNotSupported: errors["base"] = "custom_port_not_supported" except Exception: # noqa: BLE001 @@ -215,6 +218,8 @@ async def async_step_credentials( errors["base"] = "invalid_auth" except DeviceConnectionError: errors["base"] = "cannot_connect" + except MacAddressMismatchError: + errors["base"] = "mac_address_mismatch" except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -378,6 +383,8 @@ async def async_step_reauth_confirm( await validate_input(self.hass, host, port, info, user_input) except (DeviceConnectionError, InvalidAuthError): return self.async_abort(reason="reauth_unsuccessful") + except MacAddressMismatchError: + return self.async_abort(reason="mac_address_mismatch") return self.async_update_reload_and_abort( reauth_entry, data_updates=user_input diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index a66fbb20f481e..f20b283cacf9b 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -11,7 +11,12 @@ from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.const import MODEL_NAMES, MODEL_VALVE -from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError +from aioshelly.exceptions import ( + DeviceConnectionError, + InvalidAuthError, + MacAddressMismatchError, + RpcCallError, +) from aioshelly.rpc_device import RpcDevice, RpcUpdateType from propcache import cached_property @@ -173,7 +178,7 @@ async def _async_device_connect_task(self) -> bool: try: await self.device.initialize() update_device_fw_info(self.hass, self.device, self.entry) - except DeviceConnectionError as err: + except (DeviceConnectionError, MacAddressMismatchError) as err: LOGGER.debug( "Error connecting to Shelly device %s, error: %r", self.name, err ) @@ -450,7 +455,7 @@ async def _async_update_data(self) -> None: if self.device.status["uptime"] > 2 * REST_SENSORS_UPDATE_INTERVAL: return await self.device.update_shelly() - except DeviceConnectionError as err: + except (DeviceConnectionError, MacAddressMismatchError) as err: raise UpdateFailed(f"Error fetching data: {err!r}") from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 342a7418b2a26..eb869b54e4c91 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -45,7 +45,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", "firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support", - "custom_port_not_supported": "Gen1 device does not support custom port." + "custom_port_not_supported": "Gen1 device does not support custom port.", + "mac_address_mismatch": "The MAC address of the device does not match the one in the configuration, please reboot the device and try again." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -53,7 +54,8 @@ "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used.", - "ipv6_not_supported": "IPv6 is not supported." + "ipv6_not_supported": "IPv6 is not supported.", + "mac_address_mismatch": "[%key:component::shelly::config::error::mac_address_mismatch%]" } }, "device_automation": { diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 93b3a46910c3b..d99457061826d 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -16,7 +16,7 @@ from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.components.shelly import config_flow +from homeassistant.components.shelly import MacAddressMismatchError, config_flow from homeassistant.components.shelly.const import ( CONF_BLE_SCANNER_MODE, DOMAIN, @@ -331,6 +331,7 @@ async def test_form_missing_model_key_zeroconf( ("exc", "base_error"), [ (DeviceConnectionError, "cannot_connect"), + (MacAddressMismatchError, "mac_address_mismatch"), (ValueError, "unknown"), ], ) @@ -436,6 +437,7 @@ async def test_user_setup_ignored_device( [ (InvalidAuthError, "invalid_auth"), (DeviceConnectionError, "cannot_connect"), + (MacAddressMismatchError, "mac_address_mismatch"), (ValueError, "unknown"), ], ) @@ -473,6 +475,7 @@ async def test_form_auth_errors_test_connection_gen1( [ (DeviceConnectionError, "cannot_connect"), (InvalidAuthError, "invalid_auth"), + (MacAddressMismatchError, "mac_address_mismatch"), (ValueError, "unknown"), ], ) @@ -844,8 +847,19 @@ async def test_reauth_successful( (3, {"password": "test2 password"}), ], ) +@pytest.mark.parametrize( + ("exc", "abort_reason"), + [ + (DeviceConnectionError, "reauth_unsuccessful"), + (MacAddressMismatchError, "mac_address_mismatch"), + ], +) async def test_reauth_unsuccessful( - hass: HomeAssistant, gen: int, user_input: dict[str, str] + hass: HomeAssistant, + gen: int, + user_input: dict[str, str], + exc: Exception, + abort_reason: str, ) -> None: """Test reauthentication flow failed.""" entry = MockConfigEntry( @@ -862,13 +876,9 @@ async def test_reauth_unsuccessful( return_value={"mac": "test-mac", "type": MODEL_1, "auth": True, "gen": gen}, ), patch( - "aioshelly.block_device.BlockDevice.create", - new=AsyncMock(side_effect=InvalidAuthError), - ), - patch( - "aioshelly.rpc_device.RpcDevice.create", - new=AsyncMock(side_effect=InvalidAuthError), + "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=exc) ), + patch("aioshelly.rpc_device.RpcDevice.create", new=AsyncMock(side_effect=exc)), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -876,7 +886,7 @@ async def test_reauth_unsuccessful( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_unsuccessful" + assert result["reason"] == abort_reason async def test_reauth_get_info_error(hass: HomeAssistant) -> None: diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 47c338e3fadb6..090c5e7207fb5 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -10,6 +10,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.shelly import MacAddressMismatchError from homeassistant.components.shelly.const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, @@ -254,11 +255,13 @@ async def test_block_polling_connection_error( assert get_entity_state(hass, "switch.test_name_channel_1") == STATE_UNAVAILABLE +@pytest.mark.parametrize("exc", [DeviceConnectionError, MacAddressMismatchError]) async def test_block_rest_update_connection_error( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, + exc: Exception, ) -> None: """Test block REST update connection error.""" entity_id = register_entity(hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud") @@ -269,11 +272,7 @@ async def test_block_rest_update_connection_error( await mock_rest_update(hass, freezer) assert get_entity_state(hass, entity_id) == STATE_ON - monkeypatch.setattr( - mock_block_device, - "update_shelly", - AsyncMock(side_effect=DeviceConnectionError), - ) + monkeypatch.setattr(mock_block_device, "update_shelly", AsyncMock(side_effect=exc)) await mock_rest_update(hass, freezer) assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE @@ -702,11 +701,13 @@ async def test_rpc_polling_auth_error( assert flow["context"].get("entry_id") == entry.entry_id +@pytest.mark.parametrize("exc", [DeviceConnectionError, MacAddressMismatchError]) async def test_rpc_reconnect_error( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, + exc: Exception, ) -> None: """Test RPC reconnect error.""" await init_integration(hass, 2) @@ -714,13 +715,7 @@ async def test_rpc_reconnect_error( assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON monkeypatch.setattr(mock_rpc_device, "connected", False) - monkeypatch.setattr( - mock_rpc_device, - "initialize", - AsyncMock( - side_effect=DeviceConnectionError, - ), - ) + monkeypatch.setattr(mock_rpc_device, "initialize", AsyncMock(side_effect=exc)) # Move time to generate reconnect freezer.tick(timedelta(seconds=RPC_RECONNECT_INTERVAL)) From 9c3ec3319bdb65148d5b008280d44390b1836880 Mon Sep 17 00:00:00 2001 From: Jack Boswell Date: Mon, 18 Nov 2024 19:02:11 +1100 Subject: [PATCH 0534/1070] Bump starlink-grpc-core to 1.2.0 (#130488) --- homeassistant/components/starlink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index b8733dd243567..ab5e2345795c3 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/starlink", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["starlink-grpc-core==1.1.3"] + "requirements": ["starlink-grpc-core==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 47f998c10b134..8d4c136451f3c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2734,7 +2734,7 @@ starline==0.1.5 starlingbank==3.2 # homeassistant.components.starlink -starlink-grpc-core==1.1.3 +starlink-grpc-core==1.2.0 # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 724c364309bd5..d0534f018f67d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2183,7 +2183,7 @@ srpenergy==1.3.6 starline==0.1.5 # homeassistant.components.starlink -starlink-grpc-core==1.1.3 +starlink-grpc-core==1.2.0 # homeassistant.components.statsd statsd==3.2.1 From caaea1d45b554ca80fe9dead876ccf22a443329e Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Mon, 18 Nov 2024 09:11:51 +0100 Subject: [PATCH 0535/1070] Bump homematicip to 1.1.3 (#130824) --- homeassistant/components/homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index b3e7eb9a72adb..97af964ffc782 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["homematicip"], "quality_scale": "silver", - "requirements": ["homematicip==1.1.2"] + "requirements": ["homematicip==1.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8d4c136451f3c..ce57a2d38c71b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1139,7 +1139,7 @@ home-assistant-intents==2024.11.13 homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.2 +homematicip==1.1.3 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0534f018f67d..766d986b992ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -965,7 +965,7 @@ home-assistant-intents==2024.11.13 homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.2 +homematicip==1.1.3 # homeassistant.components.remember_the_milk httplib2==0.20.4 From c154ac26eb6287fa144cd48cbe8fd80bbe4fb09d Mon Sep 17 00:00:00 2001 From: Brunno Vanelli Date: Mon, 18 Nov 2024 09:48:22 +0100 Subject: [PATCH 0536/1070] Bump pykoplenti to 1.3.0 (#130719) --- homeassistant/components/kostal_plenticore/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kostal_plenticore/manifest.json b/homeassistant/components/kostal_plenticore/manifest.json index d65368e7ee44d..09352fa7a808b 100644 --- a/homeassistant/components/kostal_plenticore/manifest.json +++ b/homeassistant/components/kostal_plenticore/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/kostal_plenticore", "iot_class": "local_polling", "loggers": ["kostal"], - "requirements": ["pykoplenti==1.2.2"] + "requirements": ["pykoplenti==1.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ce57a2d38c71b..328b5c6303ffc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2012,7 +2012,7 @@ pykmtronic==0.3.0 pykodi==0.2.7 # homeassistant.components.kostal_plenticore -pykoplenti==1.2.2 +pykoplenti==1.3.0 # homeassistant.components.kraken pykrakenapi==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 766d986b992ec..43f1f793f5672 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1623,7 +1623,7 @@ pykmtronic==0.3.0 pykodi==0.2.7 # homeassistant.components.kostal_plenticore -pykoplenti==1.2.2 +pykoplenti==1.3.0 # homeassistant.components.kraken pykrakenapi==0.1.8 From 2f1c1d66cb1aa2fdb46eb4788739e7c7ba2e4cbb Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 18 Nov 2024 10:22:10 +0100 Subject: [PATCH 0537/1070] Support KNX lights with multiple color modes (#130842) --- homeassistant/components/knx/light.py | 67 +++++++------ tests/components/knx/test_light.py | 133 ++++++++++++++++++++++++-- 2 files changed, 164 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index ba1194220c2d6..8e64b46c890e2 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -4,6 +4,7 @@ from typing import Any, cast +from propcache import cached_property from xknx import XKNX from xknx.devices.light import ColorTemperatureType, Light as XknxLight, XYYColor @@ -389,39 +390,47 @@ def color_temp_kelvin(self) -> int | None: ) return None - @property - def color_mode(self) -> ColorMode: - """Return the color mode of the light.""" - if self._device.supports_xyy_color: - return ColorMode.XY - if self._device.supports_hs_color: - return ColorMode.HS - if self._device.supports_rgbw: - return ColorMode.RGBW - if self._device.supports_color: - return ColorMode.RGB + @cached_property + def supported_color_modes(self) -> set[ColorMode]: + """Get supported color modes.""" + color_mode = set() if ( self._device.supports_color_temperature or self._device.supports_tunable_white ): - return ColorMode.COLOR_TEMP - if self._device.supports_brightness: - return ColorMode.BRIGHTNESS - return ColorMode.ONOFF - - @property - def supported_color_modes(self) -> set[ColorMode]: - """Flag supported color modes.""" - return {self.color_mode} + color_mode.add(ColorMode.COLOR_TEMP) + if self._device.supports_xyy_color: + color_mode.add(ColorMode.XY) + if self._device.supports_rgbw: + color_mode.add(ColorMode.RGBW) + elif self._device.supports_color: + # one of RGB or RGBW so individual color configurations work properly + color_mode.add(ColorMode.RGB) + if self._device.supports_hs_color: + color_mode.add(ColorMode.HS) + if not color_mode: + # brightness or on/off must be the only supported mode + if self._device.supports_brightness: + color_mode.add(ColorMode.BRIGHTNESS) + else: + color_mode.add(ColorMode.ONOFF) + return color_mode async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) - color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN) - rgb = kwargs.get(ATTR_RGB_COLOR) - rgbw = kwargs.get(ATTR_RGBW_COLOR) - hs_color = kwargs.get(ATTR_HS_COLOR) - xy_color = kwargs.get(ATTR_XY_COLOR) + # LightEntity color translation will ensure that only attributes of supported + # color modes are passed to this method - so we can't set unsupported mode here + if color_temp := kwargs.get(ATTR_COLOR_TEMP_KELVIN): + self._attr_color_mode = ColorMode.COLOR_TEMP + if rgb := kwargs.get(ATTR_RGB_COLOR): + self._attr_color_mode = ColorMode.RGB + if rgbw := kwargs.get(ATTR_RGBW_COLOR): + self._attr_color_mode = ColorMode.RGBW + if hs_color := kwargs.get(ATTR_HS_COLOR): + self._attr_color_mode = ColorMode.HS + if xy_color := kwargs.get(ATTR_XY_COLOR): + self._attr_color_mode = ColorMode.XY if ( not self.is_on @@ -500,17 +509,17 @@ async def set_color( await self._device.set_brightness(brightness) return # brightness without color in kwargs; set via color - if self.color_mode == ColorMode.XY: + if self._attr_color_mode == ColorMode.XY: await self._device.set_xyy_color(XYYColor(brightness=brightness)) return # default to white if color not known for RGB(W) - if self.color_mode == ColorMode.RGBW: + if self._attr_color_mode == ColorMode.RGBW: _rgbw = self.rgbw_color if not _rgbw or not any(_rgbw): _rgbw = (0, 0, 0, 255) await set_color(_rgbw[:3], _rgbw[3], brightness) return - if self.color_mode == ColorMode.RGB: + if self._attr_color_mode == ColorMode.RGB: _rgb = self.rgb_color if not _rgb or not any(_rgb): _rgb = (255, 255, 255) @@ -533,6 +542,7 @@ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: knx_module=knx_module, device=_create_yaml_light(knx_module.xknx, config), ) + self._attr_color_mode = next(iter(self.supported_color_modes)) self._attr_max_color_temp_kelvin: int = config[LightSchema.CONF_MAX_KELVIN] self._attr_min_color_temp_kelvin: int = config[LightSchema.CONF_MIN_KELVIN] self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) @@ -566,5 +576,6 @@ def __init__( self._device = _create_ui_light( knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME] ) + self._attr_color_mode = next(iter(self.supported_color_modes)) self._attr_max_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MAX] self._attr_min_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MIN] diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index 88f76a163d585..6ba6090d60dda 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -41,7 +41,11 @@ async def test_light_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: } ) - knx.assert_state("light.test", STATE_OFF) + knx.assert_state( + "light.test", + STATE_OFF, + supported_color_modes=[ColorMode.ONOFF], + ) # turn on light await hass.services.async_call( "light", @@ -110,6 +114,7 @@ async def test_light_brightness(hass: HomeAssistant, knx: KNXTestKit) -> None: "light.test", STATE_ON, brightness=80, + supported_color_modes=[ColorMode.BRIGHTNESS], color_mode=ColorMode.BRIGHTNESS, ) # receive brightness changes from KNX @@ -165,6 +170,7 @@ async def test_light_color_temp_absolute(hass: HomeAssistant, knx: KNXTestKit) - "light.test", STATE_ON, brightness=255, + supported_color_modes=[ColorMode.COLOR_TEMP], color_mode=ColorMode.COLOR_TEMP, color_temp=370, color_temp_kelvin=2700, @@ -227,6 +233,7 @@ async def test_light_color_temp_relative(hass: HomeAssistant, knx: KNXTestKit) - "light.test", STATE_ON, brightness=255, + supported_color_modes=[ColorMode.COLOR_TEMP], color_mode=ColorMode.COLOR_TEMP, color_temp=250, color_temp_kelvin=4000, @@ -300,6 +307,7 @@ async def test_light_hs_color(hass: HomeAssistant, knx: KNXTestKit) -> None: "light.test", STATE_ON, brightness=255, + supported_color_modes=[ColorMode.HS], color_mode=ColorMode.HS, hs_color=(360, 100), ) @@ -375,6 +383,7 @@ async def test_light_xyy_color(hass: HomeAssistant, knx: KNXTestKit) -> None: "light.test", STATE_ON, brightness=204, + supported_color_modes=[ColorMode.XY], color_mode=ColorMode.XY, xy_color=(0.8, 0.8), ) @@ -457,6 +466,7 @@ async def test_light_xyy_color_with_brightness( "light.test", STATE_ON, brightness=255, # brightness form xyy_color ignored when extra brightness GA is used + supported_color_modes=[ColorMode.XY], color_mode=ColorMode.XY, xy_color=(0.8, 0.8), ) @@ -543,6 +553,7 @@ async def test_light_rgb_individual(hass: HomeAssistant, knx: KNXTestKit) -> Non "light.test", STATE_ON, brightness=255, + supported_color_modes=[ColorMode.RGB], color_mode=ColorMode.RGB, rgb_color=(255, 255, 255), ) @@ -699,6 +710,7 @@ async def test_light_rgbw_individual( "light.test", STATE_ON, brightness=255, + supported_color_modes=[ColorMode.RGBW], color_mode=ColorMode.RGBW, rgbw_color=(0, 0, 0, 255), ) @@ -853,6 +865,7 @@ async def test_light_rgb(hass: HomeAssistant, knx: KNXTestKit) -> None: "light.test", STATE_ON, brightness=255, + supported_color_modes=[ColorMode.RGB], color_mode=ColorMode.RGB, rgb_color=(255, 255, 255), ) @@ -961,6 +974,7 @@ async def test_light_rgbw(hass: HomeAssistant, knx: KNXTestKit) -> None: "light.test", STATE_ON, brightness=255, + supported_color_modes=[ColorMode.RGBW], color_mode=ColorMode.RGBW, rgbw_color=(255, 101, 102, 103), ) @@ -1078,6 +1092,7 @@ async def test_light_rgbw_brightness(hass: HomeAssistant, knx: KNXTestKit) -> No "light.test", STATE_ON, brightness=255, + supported_color_modes=[ColorMode.RGBW], color_mode=ColorMode.RGBW, rgbw_color=(255, 101, 102, 103), ) @@ -1174,8 +1189,12 @@ async def test_light_ui_create( # created entity sends read-request to KNX bus await knx.assert_read("2/2/2") await knx.receive_response("2/2/2", True) - state = hass.states.get("light.test") - assert state.state is STATE_ON + knx.assert_state( + "light.test", + STATE_ON, + supported_color_modes=[ColorMode.ONOFF], + color_mode=ColorMode.ONOFF, + ) @pytest.mark.parametrize( @@ -1216,9 +1235,103 @@ async def test_light_ui_color_temp( blocking=True, ) await knx.assert_write("3/3/3", raw_ct) - state = hass.states.get("light.test") - assert state.state is STATE_ON - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == pytest.approx(4200, abs=1) + knx.assert_state( + "light.test", + STATE_ON, + supported_color_modes=[ColorMode.COLOR_TEMP], + color_mode=ColorMode.COLOR_TEMP, + color_temp_kelvin=pytest.approx(4200, abs=1), + ) + + +async def test_light_ui_multi_mode( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, +) -> None: + """Test creating a light with multiple color modes.""" + await knx.setup_integration({}) + await create_ui_entity( + platform=Platform.LIGHT, + entity_data={"name": "test"}, + knx_data={ + "color_temp_min": 2700, + "color_temp_max": 6000, + "_light_color_mode_schema": "default", + "ga_switch": { + "write": "1/1/1", + "passive": [], + "state": "2/2/2", + }, + "sync_state": True, + "ga_brightness": { + "write": "0/6/0", + "state": "0/6/1", + "passive": [], + }, + "ga_color_temp": { + "write": "0/6/2", + "dpt": "7.600", + "state": "0/6/3", + "passive": [], + }, + "ga_color": { + "write": "0/6/4", + "dpt": "251.600", + "state": "0/6/5", + "passive": [], + }, + }, + ) + await knx.assert_read("2/2/2", True) + await knx.assert_read("0/6/1", (0xFF,)) + await knx.assert_read("0/6/5", (0xFF, 0x65, 0x66, 0x67, 0x00, 0x0F)) + await knx.assert_read("0/6/3", (0x12, 0x34)) + + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.test", + ATTR_COLOR_NAME: "hotpink", + }, + blocking=True, + ) + await knx.assert_write("0/6/4", (255, 0, 128, 178, 0, 15)) + knx.assert_state( + "light.test", + STATE_ON, + brightness=255, + color_temp_kelvin=None, + rgbw_color=(255, 0, 128, 178), + supported_color_modes=[ + ColorMode.COLOR_TEMP, + ColorMode.RGBW, + ], + color_mode=ColorMode.RGBW, + ) + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.test", + ATTR_COLOR_TEMP_KELVIN: 4200, + }, + blocking=True, + ) + await knx.assert_write("0/6/2", (0x10, 0x68)) + knx.assert_state( + "light.test", + STATE_ON, + brightness=255, + color_temp_kelvin=4200, + rgbw_color=None, + supported_color_modes=[ + ColorMode.COLOR_TEMP, + ColorMode.RGBW, + ], + color_mode=ColorMode.COLOR_TEMP, + ) async def test_light_ui_load( @@ -1234,8 +1347,12 @@ async def test_light_ui_load( # unrelated switch in config store await knx.assert_read("1/0/45", response=True, ignore_order=True) - state = hass.states.get("light.test") - assert state.state is STATE_ON + knx.assert_state( + "light.test", + STATE_ON, + supported_color_modes=[ColorMode.ONOFF], + color_mode=ColorMode.ONOFF, + ) entity = entity_registry.async_get("light.test") assert entity.entity_category is EntityCategory.CONFIG From e9eaeedf2b6db4d2e52b58021e0d0b19e7b553b1 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:52:23 +0100 Subject: [PATCH 0538/1070] Add entity picture for gems to Habitica integration (#130827) --- homeassistant/components/habitica/sensor.py | 11 ++++++++++- tests/components/habitica/snapshots/test_sensor.ambr | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 3b2395ecc5250..d6943fcae56ce 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -24,7 +24,7 @@ ) from homeassistant.helpers.typing import StateType -from .const import DOMAIN, UNIT_TASKS +from .const import ASSETS_URL, DOMAIN, UNIT_TASKS from .entity import HabiticaBase from .types import HabiticaConfigEntry from .util import entity_used_in, get_attribute_points, get_attributes_total @@ -40,6 +40,7 @@ class HabitipySensorEntityDescription(SensorEntityDescription): attributes_fn: ( Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None] | None ) = None + entity_picture: str | None = None @dataclass(kw_only=True, frozen=True) @@ -144,6 +145,7 @@ class HabitipySensorEntity(StrEnum): value_fn=lambda user, _: user.get("balance", 0) * 4, suggested_display_precision=0, native_unit_of_measurement="gems", + entity_picture="shop_gem.png", ), HabitipySensorEntityDescription( key=HabitipySensorEntity.TRINKETS, @@ -293,6 +295,13 @@ def extra_state_attributes(self) -> dict[str, float | None] | None: return func(self.coordinator.data.user, self.coordinator.content) return None + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + if entity_picture := self.entity_description.entity_picture: + return f"{ASSETS_URL}{entity_picture}" + return None + class HabitipyTaskSensor(HabiticaBase, SensorEntity): """A Habitica task sensor.""" diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 3a43069bfc461..07eddf496b270 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -405,6 +405,7 @@ # name: test_sensors[sensor.test_user_gems-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/shop_gem.png', 'friendly_name': 'test-user Gems', 'unit_of_measurement': 'gems', }), From 75199a901f76b7b8958bd3ef7179a606f0757000 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Mon, 18 Nov 2024 04:56:47 -0500 Subject: [PATCH 0539/1070] UPB integration: Change unique ID from int to string. (#130832) --- homeassistant/components/upb/__init__.py | 21 +++++++++++++++++ homeassistant/components/upb/config_flow.py | 3 ++- tests/components/upb/test_init.py | 25 +++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/components/upb/test_init.py diff --git a/homeassistant/components/upb/__init__.py b/homeassistant/components/upb/__init__.py index ca4375d123288..c9f3a2df10501 100644 --- a/homeassistant/components/upb/__init__.py +++ b/homeassistant/components/upb/__init__.py @@ -1,5 +1,7 @@ """Support the UPB PIM.""" +import logging + import upb_lib from homeassistant.config_entries import ConfigEntry @@ -14,6 +16,7 @@ EVENT_UPB_SCENE_CHANGED, ) +_LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.LIGHT, Platform.SCENE] @@ -63,3 +66,21 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> upb.disconnect() hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate entry.""" + + _LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + # 1 -> 2: Unique ID from integer to string + if entry.minor_version == 1: + minor_version = 2 + hass.config_entries.async_update_entry( + entry, unique_id=str(entry.unique_id), minor_version=minor_version + ) + + _LOGGER.debug("Migration successful") + + return True diff --git a/homeassistant/components/upb/config_flow.py b/homeassistant/components/upb/config_flow.py index d9f111049fd90..788a0336d7300 100644 --- a/homeassistant/components/upb/config_flow.py +++ b/homeassistant/components/upb/config_flow.py @@ -78,6 +78,7 @@ class UPBConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for UPB PIM.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -98,7 +99,7 @@ async def async_step_user( errors["base"] = "unknown" if "base" not in errors: - await self.async_set_unique_id(network_id) + await self.async_set_unique_id(str(network_id)) self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/tests/components/upb/test_init.py b/tests/components/upb/test_init.py new file mode 100644 index 0000000000000..a7621ce65fe52 --- /dev/null +++ b/tests/components/upb/test_init.py @@ -0,0 +1,25 @@ +"""The init tests for the UPB platform.""" + +from unittest.mock import patch + +from homeassistant.components.upb.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None: + """Test migrating a 1.1 config entry to 1.2.""" + with patch("homeassistant.components.upb.async_setup_entry", return_value=True): + entry = MockConfigEntry( + domain=DOMAIN, + data={"protocol": "TCP", "address": "1.2.3.4", "file_path": "upb.upe"}, + version=1, + minor_version=1, + unique_id=123456, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id == "123456" From a6094c4ccecb10c9c1b8ae73272db5ffae3ee229 Mon Sep 17 00:00:00 2001 From: Johnny Willemsen Date: Mon, 18 Nov 2024 13:01:07 +0100 Subject: [PATCH 0540/1070] Add diagnostics to HomeConnect (#130500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: J. Diego Rodríguez Royo Co-authored-by: Joostlek --- .../components/home_connect/diagnostics.py | 20 ++ .../snapshots/test_diagnostics.ambr | 339 ++++++++++++++++++ .../home_connect/test_diagnostics.py | 35 ++ 3 files changed, 394 insertions(+) create mode 100644 homeassistant/components/home_connect/diagnostics.py create mode 100644 tests/components/home_connect/snapshots/test_diagnostics.ambr create mode 100644 tests/components/home_connect/test_diagnostics.py diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py new file mode 100644 index 0000000000000..ae484ae1d725f --- /dev/null +++ b/homeassistant/components/home_connect/diagnostics.py @@ -0,0 +1,20 @@ +"""Diagnostics support for Home Connect Diagnostics.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + device.appliance.haId: device.appliance.status + for device in hass.data[DOMAIN][config_entry.entry_id].devices + } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..29591c8d9ead3 --- /dev/null +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -0,0 +1,339 @@ +# serializer version: 1 +# name: test_async_get_config_entry_diagnostics + dict({ + 'BOSCH-000000000-000000000000': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + }), + 'BOSCH-HCS000000-D00000000001': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + }), + 'BOSCH-HCS000000-D00000000002': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + }), + 'BOSCH-HCS000000-D00000000003': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + }), + 'BOSCH-HCS000000-D00000000004': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': dict({ + 'type': 'Double', + 'unit': '%', + 'value': 70, + }), + 'BSH.Common.Setting.AmbientLightColor': dict({ + 'type': 'BSH.Common.EnumType.AmbientLightColor', + 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', + }), + 'BSH.Common.Setting.AmbientLightCustomColor': dict({ + 'type': 'String', + 'value': '#4a88f8', + }), + 'BSH.Common.Setting.AmbientLightEnabled': dict({ + 'type': 'Boolean', + 'value': True, + }), + 'BSH.Common.Setting.ColorTemperature': dict({ + 'type': 'BSH.Common.EnumType.ColorTemperature', + 'value': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', + }), + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Cooking.Common.Setting.Lighting': dict({ + 'type': 'Boolean', + 'value': True, + }), + 'Cooking.Common.Setting.LightingBrightness': dict({ + 'type': 'Double', + 'unit': '%', + 'value': 70, + }), + 'Cooking.Hood.Setting.ColorTemperaturePercent': dict({ + 'type': 'Double', + 'unit': '%', + 'value': 70, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + }), + 'BOSCH-HCS000000-D00000000005': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + }), + 'BOSCH-HCS000000-D00000000006': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + }), + 'BOSCH-HCS01OVN1-43E0065FE245': dict({ + 'BSH.Common.Root.ActiveProgram': dict({ + 'value': 'Cooking.Oven.Program.HeatingMode.HotAir', + }), + 'BSH.Common.Setting.PowerState': dict({ + 'type': 'BSH.Common.EnumType.PowerState', + 'value': 'BSH.Common.EnumType.PowerState.On', + }), + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + }), + 'BOSCH-HCS04DYR1-831694AE3C5A': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + }), + 'BOSCH-HCS06COM1-D70390681C2C': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + }), + 'SIEMENS-HCS02DWH1-6BE58C26DCC1': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': dict({ + 'type': 'Double', + 'unit': '%', + 'value': 70, + }), + 'BSH.Common.Setting.AmbientLightColor': dict({ + 'type': 'BSH.Common.EnumType.AmbientLightColor', + 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', + }), + 'BSH.Common.Setting.AmbientLightCustomColor': dict({ + 'type': 'String', + 'value': '#4a88f8', + }), + 'BSH.Common.Setting.AmbientLightEnabled': dict({ + 'type': 'Boolean', + 'value': True, + }), + 'BSH.Common.Setting.ChildLock': dict({ + 'type': 'Boolean', + 'value': False, + }), + 'BSH.Common.Setting.PowerState': dict({ + 'type': 'BSH.Common.EnumType.PowerState', + 'value': 'BSH.Common.EnumType.PowerState.On', + }), + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + }), + 'SIEMENS-HCS03WCH1-7BC6383CF794': dict({ + 'BSH.Common.Root.ActiveProgram': dict({ + 'value': 'BSH.Common.Root.ActiveProgram', + }), + 'BSH.Common.Setting.ChildLock': dict({ + 'type': 'Boolean', + 'value': False, + }), + 'BSH.Common.Setting.PowerState': dict({ + 'type': 'BSH.Common.EnumType.PowerState', + 'value': 'BSH.Common.EnumType.PowerState.On', + }), + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + }), + 'SIEMENS-HCS05FRF1-304F4F9E541D': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Setting.Dispenser.Enabled': dict({ + 'constraints': dict({ + 'access': 'readWrite', + }), + 'type': 'Boolean', + 'value': False, + }), + 'Refrigeration.Common.Setting.Light.External.Brightness': dict({ + 'constraints': dict({ + 'access': 'readWrite', + 'max': 100, + 'min': 0, + }), + 'type': 'Double', + 'unit': '%', + 'value': 70, + }), + 'Refrigeration.Common.Setting.Light.External.Power': dict({ + 'type': 'Boolean', + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': dict({ + 'constraints': dict({ + 'access': 'readWrite', + }), + 'type': 'Boolean', + 'value': False, + }), + 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': dict({ + 'constraints': dict({ + 'access': 'readWrite', + }), + 'type': 'Boolean', + 'value': False, + }), + }), + }) +# --- diff --git a/tests/components/home_connect/test_diagnostics.py b/tests/components/home_connect/test_diagnostics.py new file mode 100644 index 0000000000000..a8c8223ae506d --- /dev/null +++ b/tests/components/home_connect/test_diagnostics.py @@ -0,0 +1,35 @@ +"""Test diagnostics for Home Connect.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.home_connect.diagnostics import ( + async_get_config_entry_diagnostics, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import get_all_appliances + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("bypass_throttle") +async def test_async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test setup and unload.""" + get_appliances.side_effect = get_all_appliances + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot From 370ea367976118e4c6d8ddfb3d7a7ee4ff6ebdae Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Mon, 18 Nov 2024 12:30:08 +0000 Subject: [PATCH 0541/1070] Bump pytouchlinesl to 0.1.9 (#130867) --- homeassistant/components/touchline_sl/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json index dd591cbf03883..063f772658715 100644 --- a/homeassistant/components/touchline_sl/manifest.json +++ b/homeassistant/components/touchline_sl/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/touchline_sl", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["pytouchlinesl==0.1.8"] + "requirements": ["pytouchlinesl==0.1.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 328b5c6303ffc..d8897f12b3c80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2435,7 +2435,7 @@ pytomorrowio==0.3.6 pytouchline==0.7 # homeassistant.components.touchline_sl -pytouchlinesl==0.1.8 +pytouchlinesl==0.1.9 # homeassistant.components.traccar # homeassistant.components.traccar_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43f1f793f5672..8b841f6f699eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1947,7 +1947,7 @@ pytile==2023.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline_sl -pytouchlinesl==0.1.8 +pytouchlinesl==0.1.9 # homeassistant.components.traccar # homeassistant.components.traccar_server From 44a93a007627dbf7333c1104da53e429abb1965f Mon Sep 17 00:00:00 2001 From: MahrWe <28512631+MahrWe@users.noreply.github.com> Date: Mon, 18 Nov 2024 13:52:06 +0100 Subject: [PATCH 0542/1070] Linkplay additional models (#130262) Co-authored-by: Joostlek --- homeassistant/components/linkplay/utils.py | 70 +++++++++++++--------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py index b8dc185ded202..00bb691362b23 100644 --- a/homeassistant/components/linkplay/utils.py +++ b/homeassistant/components/linkplay/utils.py @@ -14,51 +14,67 @@ MANUFACTURER_ARYLIC: Final[str] = "Arylic" MANUFACTURER_IEAST: Final[str] = "iEAST" MANUFACTURER_WIIM: Final[str] = "WiiM" +MANUFACTURER_GGMM: Final[str] = "GGMM" +MANUFACTURER_MEDION: Final[str] = "Medion" MANUFACTURER_GENERIC: Final[str] = "Generic" MODELS_ARTSOUND_SMART_ZONE4: Final[str] = "Smart Zone 4 AMP" MODELS_ARTSOUND_SMART_HYDE: Final[str] = "Smart Hyde" MODELS_ARYLIC_S50: Final[str] = "S50+" MODELS_ARYLIC_S50_PRO: Final[str] = "S50 Pro" MODELS_ARYLIC_A30: Final[str] = "A30" +MODELS_ARYLIC_A50: Final[str] = "A50" MODELS_ARYLIC_A50S: Final[str] = "A50+" +MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0" MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3" MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4" +MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1" MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3" +MODELS_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp" MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5" MODELS_WIIM_AMP: Final[str] = "WiiM Amp" MODELS_WIIM_MINI: Final[str] = "WiiM Mini" +MODELS_GGMM_GGMM_E2: Final[str] = "GGMM E2" +MODELS_MEDION_MD_43970: Final[str] = "Life P66970 (MD 43970)" MODELS_GENERIC: Final[str] = "Generic" +PROJECTID_LOOKUP: Final[dict[str, tuple[str, str]]] = { + "SMART_ZONE4_AMP": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_ZONE4), + "SMART_HYDE": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_HYDE), + "ARYLIC_S50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50), + "RP0016_S50PRO_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50_PRO), + "RP0011_WB60_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A30), + "X-50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50), + "ARYLIC_A50S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50S), + "RP0011_WB60": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP), + "UP2STREAM_AMP_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3), + "UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4), + "UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3), + "ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP), + "UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "ARYLIC_S50A": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "RP0010_D5_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "RP0001": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "RP0013_WA31S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "RP0010_D5": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "RP0013_WA31S_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "RP0014_A50D_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "ARYLIC_A50TE": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "ARYLIC_A50N": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "iEAST-02": (MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5), + "WiiM_Amp_4layer": (MANUFACTURER_WIIM, MODELS_WIIM_AMP), + "Muzo_Mini": (MANUFACTURER_WIIM, MODELS_WIIM_MINI), + "GGMM_E2A": (MANUFACTURER_GGMM, MODELS_GGMM_GGMM_E2), + "A16": (MANUFACTURER_MEDION, MODELS_MEDION_MD_43970), +} + def get_info_from_project(project: str) -> tuple[str, str]: """Get manufacturer and model info based on given project.""" - match project: - case "SMART_ZONE4_AMP": - return MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_ZONE4 - case "SMART_HYDE": - return MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_HYDE - case "ARYLIC_S50": - return MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50 - case "RP0016_S50PRO_S": - return MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50_PRO - case "RP0011_WB60_S": - return MANUFACTURER_ARYLIC, MODELS_ARYLIC_A30 - case "ARYLIC_A50S": - return MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50S - case "UP2STREAM_AMP_V3": - return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3 - case "UP2STREAM_AMP_V4": - return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4 - case "UP2STREAM_PRO_V3": - return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3 - case "iEAST-02": - return MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5 - case "WiiM_Amp_4layer": - return MANUFACTURER_WIIM, MODELS_WIIM_AMP - case "Muzo_Mini": - return MANUFACTURER_WIIM, MODELS_WIIM_MINI - case _: - return MANUFACTURER_GENERIC, MODELS_GENERIC + return PROJECTID_LOOKUP.get(project, (MANUFACTURER_GENERIC, MODELS_GENERIC)) async def async_get_client_session(hass: HomeAssistant) -> ClientSession: From 40fb28a94d955eb29638f807ce806795b85a988b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 18 Nov 2024 14:12:11 +0100 Subject: [PATCH 0543/1070] Bump accuweather to 4.0.0 (#130868) --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 24a8180eef8a8..1c21a72ee1aa2 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -8,6 +8,6 @@ "iot_class": "cloud_polling", "loggers": ["accuweather"], "quality_scale": "platinum", - "requirements": ["accuweather==3.0.0"], + "requirements": ["accuweather==4.0.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index d8897f12b3c80..dc230cdbbfa0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -131,7 +131,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==3.0.0 +accuweather==4.0.0 # homeassistant.components.adax adax==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b841f6f699eb..79e82a6cc6713 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -119,7 +119,7 @@ Tami4EdgeAPI==3.0 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==3.0.0 +accuweather==4.0.0 # homeassistant.components.adax adax==0.4.0 From db5cc4fcd4dca217fb0ab68e063572e5d7f4778a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 18 Nov 2024 14:19:11 +0100 Subject: [PATCH 0544/1070] Fix mqtt subscription signature (#130866) --- homeassistant/components/mqtt/subscription.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 3f3f67970f3e0..08d501ede127b 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -86,7 +86,7 @@ def _should_resubscribe(self, other: EntitySubscription | None) -> bool: @callback def async_prepare_subscribe_topics( hass: HomeAssistant, - new_state: dict[str, EntitySubscription] | None, + sub_state: dict[str, EntitySubscription] | None, topics: dict[str, dict[str, Any]], ) -> dict[str, EntitySubscription]: """Prepare (re)subscribe to a set of MQTT topics. @@ -101,8 +101,9 @@ def async_prepare_subscribe_topics( sets of topics. Every call to async_subscribe_topics must always contain _all_ the topics the subscription state should manage. """ - current_subscriptions = new_state if new_state is not None else {} - new_state = {} + current_subscriptions: dict[str, EntitySubscription] + current_subscriptions = sub_state if sub_state is not None else {} + sub_state = {} for key, value in topics.items(): # Extract the new requested subscription requested = EntitySubscription( @@ -119,7 +120,7 @@ def async_prepare_subscribe_topics( # Get the current subscription state current = current_subscriptions.pop(key, None) requested.resubscribe_if_necessary(hass, current) - new_state[key] = requested + sub_state[key] = requested # Go through all remaining subscriptions and unsubscribe them for remaining in current_subscriptions.values(): @@ -132,7 +133,7 @@ def async_prepare_subscribe_topics( remaining.entity_id, ) - return new_state + return sub_state async def async_subscribe_topics( From 1ac0b006b2113256a720c9756071677e356de1c8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:19:40 +0100 Subject: [PATCH 0545/1070] Pass config_entry explicitly in rachio (#130865) --- homeassistant/components/rachio/coordinator.py | 5 +++++ homeassistant/components/rachio/device.py | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rachio/coordinator.py b/homeassistant/components/rachio/coordinator.py index 25c40bd6656de..62d42f2afdae1 100644 --- a/homeassistant/components/rachio/coordinator.py +++ b/homeassistant/components/rachio/coordinator.py @@ -8,6 +8,7 @@ from rachiopy import Rachio from requests.exceptions import Timeout +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -38,6 +39,7 @@ def __init__( self, hass: HomeAssistant, rachio: Rachio, + config_entry: ConfigEntry, base_station, base_count: int, ) -> None: @@ -48,6 +50,7 @@ def __init__( super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN} update coordinator", # To avoid exceeding the rate limit, increase polling interval for # each additional base station on the account @@ -76,6 +79,7 @@ def __init__( self, hass: HomeAssistant, rachio: Rachio, + config_entry: ConfigEntry, base_station, ) -> None: """Initialize a Rachio schedule coordinator.""" @@ -85,6 +89,7 @@ def __init__( super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN} schedule update coordinator", update_interval=timedelta(minutes=30), ) diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index f06910cd50510..179e5f5ec0d81 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -189,8 +189,10 @@ def _setup(self, hass: HomeAssistant) -> None: RachioBaseStation( rachio, base, - RachioUpdateCoordinator(hass, rachio, base, base_count), - RachioScheduleUpdateCoordinator(hass, rachio, base), + RachioUpdateCoordinator( + hass, rachio, self.config_entry, base, base_count + ), + RachioScheduleUpdateCoordinator(hass, rachio, self.config_entry, base), ) for base in base_stations ) From 4c816f54bfe15518321f73ed647b4ae4826fe3e8 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:52:49 +0100 Subject: [PATCH 0546/1070] Add sensor platform to acaia (#130614) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/acaia/__init__.py | 1 + homeassistant/components/acaia/sensor.py | 135 ++++++++++++++++++ tests/components/acaia/conftest.py | 2 +- .../acaia/snapshots/test_sensor.ambr | 103 +++++++++++++ tests/components/acaia/test_sensor.py | 63 ++++++++ 5 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/acaia/sensor.py create mode 100644 tests/components/acaia/snapshots/test_sensor.ambr create mode 100644 tests/components/acaia/test_sensor.py diff --git a/homeassistant/components/acaia/__init__.py b/homeassistant/components/acaia/__init__.py index dfdb4cb935d8b..f6eccc63b0800 100644 --- a/homeassistant/components/acaia/__init__.py +++ b/homeassistant/components/acaia/__init__.py @@ -7,6 +7,7 @@ PLATFORMS = [ Platform.BUTTON, + Platform.SENSOR, ] diff --git a/homeassistant/components/acaia/sensor.py b/homeassistant/components/acaia/sensor.py new file mode 100644 index 0000000000000..49ee101b4a2b2 --- /dev/null +++ b/homeassistant/components/acaia/sensor.py @@ -0,0 +1,135 @@ +"""Sensor platform for Acaia.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from aioacaia.acaiascale import AcaiaDeviceState, AcaiaScale +from aioacaia.const import UnitMass as AcaiaUnitOfMass + +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorExtraStoredData, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfMass +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import AcaiaConfigEntry +from .entity import AcaiaEntity + + +@dataclass(kw_only=True, frozen=True) +class AcaiaSensorEntityDescription(SensorEntityDescription): + """Description for Acaia sensor entities.""" + + value_fn: Callable[[AcaiaScale], int | float | None] + + +@dataclass(kw_only=True, frozen=True) +class AcaiaDynamicUnitSensorEntityDescription(AcaiaSensorEntityDescription): + """Description for Acaia sensor entities with dynamic units.""" + + unit_fn: Callable[[AcaiaDeviceState], str] | None = None + + +SENSORS: tuple[AcaiaSensorEntityDescription, ...] = ( + AcaiaDynamicUnitSensorEntityDescription( + key="weight", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.GRAMS, + state_class=SensorStateClass.MEASUREMENT, + unit_fn=lambda data: ( + UnitOfMass.OUNCES + if data.units == AcaiaUnitOfMass.OUNCES + else UnitOfMass.GRAMS + ), + value_fn=lambda scale: scale.weight, + ), +) +RESTORE_SENSORS: tuple[AcaiaSensorEntityDescription, ...] = ( + AcaiaSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda scale: ( + scale.device_state.battery_level if scale.device_state else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AcaiaConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors.""" + + coordinator = entry.runtime_data + entities: list[SensorEntity] = [ + AcaiaSensor(coordinator, entity_description) for entity_description in SENSORS + ] + entities.extend( + AcaiaRestoreSensor(coordinator, entity_description) + for entity_description in RESTORE_SENSORS + ) + async_add_entities(entities) + + +class AcaiaSensor(AcaiaEntity, SensorEntity): + """Representation of an Acaia sensor.""" + + entity_description: AcaiaDynamicUnitSensorEntityDescription + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of this entity.""" + if ( + self._scale.device_state is not None + and self.entity_description.unit_fn is not None + ): + return self.entity_description.unit_fn(self._scale.device_state) + return self.entity_description.native_unit_of_measurement + + @property + def native_value(self) -> int | float | None: + """Return the state of the entity.""" + return self.entity_description.value_fn(self._scale) + + +class AcaiaRestoreSensor(AcaiaEntity, RestoreSensor): + """Representation of an Acaia sensor with restore capabilities.""" + + entity_description: AcaiaSensorEntityDescription + _restored_data: SensorExtraStoredData | None = None + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + self._restored_data = await self.async_get_last_sensor_data() + if self._restored_data is not None: + self._attr_native_value = self._restored_data.native_value + self._attr_native_unit_of_measurement = ( + self._restored_data.native_unit_of_measurement + ) + + if self._scale.device_state is not None: + self._attr_native_value = self.entity_description.value_fn(self._scale) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._scale.device_state is not None: + self._attr_native_value = self.entity_description.value_fn(self._scale) + self._async_write_ha_state() + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available or self._restored_data is not None diff --git a/tests/components/acaia/conftest.py b/tests/components/acaia/conftest.py index 1dc6ff310512b..7e3c19c6c5ae4 100644 --- a/tests/components/acaia/conftest.py +++ b/tests/components/acaia/conftest.py @@ -74,7 +74,7 @@ def mock_scale() -> Generator[MagicMock]: scale.heartbeat_task = None scale.process_queue_task = None scale.device_state = AcaiaDeviceState( - battery_level=42, units=AcaiaUnitOfMass.GRAMS + battery_level=42, units=AcaiaUnitOfMass.OUNCES ) scale.weight = 123.45 yield scale diff --git a/tests/components/acaia/snapshots/test_sensor.ambr b/tests/components/acaia/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..46995877b4f1f --- /dev/null +++ b/tests/components/acaia/snapshots/test_sensor.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_sensors[sensor.lunar_ddeeff_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lunar_ddeeff_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'acaia', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.lunar_ddeeff_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'LUNAR-DDEEFF Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.lunar_ddeeff_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_sensors[sensor.lunar_ddeeff_weight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lunar_ddeeff_weight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weight', + 'platform': 'acaia', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_weight', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.lunar_ddeeff_weight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'LUNAR-DDEEFF Weight', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lunar_ddeeff_weight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.45', + }) +# --- diff --git a/tests/components/acaia/test_sensor.py b/tests/components/acaia/test_sensor.py new file mode 100644 index 0000000000000..2f5a851121c6e --- /dev/null +++ b/tests/components/acaia/test_sensor.py @@ -0,0 +1,63 @@ +"""Test sensors for acaia integration.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import PERCENTAGE, Platform +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import ( + MockConfigEntry, + mock_restore_cache_with_extra_data, + snapshot_platform, +) + + +async def test_sensors( + hass: HomeAssistant, + mock_scale: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Acaia sensors.""" + with patch("homeassistant.components.acaia.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_restore_state( + hass: HomeAssistant, + mock_scale: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test battery sensor restore state.""" + mock_scale.device_state = None + entity_id = "sensor.lunar_ddeeff_battery" + + mock_restore_cache_with_extra_data( + hass, + ( + ( + State( + entity_id, + "1", + ), + { + "native_value": 65, + "native_unit_of_measurement": PERCENTAGE, + }, + ), + ), + ) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(entity_id) + assert state + assert state.state == "65" From eb06dc214f42c1bb87704fcd0dbd4f177fd51b90 Mon Sep 17 00:00:00 2001 From: greyeee <62752780+greyeee@users.noreply.github.com> Date: Mon, 18 Nov 2024 22:02:23 +0800 Subject: [PATCH 0547/1070] Bump PySwitchbot to 0.53.0 (#130869) Upgrade PySwitchbot to 0.53.0 --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 0e369f8ad2d07..a0a11a9c1a4c5 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.51.0"] + "requirements": ["PySwitchbot==0.53.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dc230cdbbfa0b..8fef8c67bf304 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.51.0 +PySwitchbot==0.53.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 79e82a6cc6713..db67207ef4e20 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.51.0 +PySwitchbot==0.53.0 # homeassistant.components.syncthru PySyncThru==0.7.10 From 003ae881bf2cac3a145ae04296a8bde3db8a719a Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Mon, 18 Nov 2024 15:26:53 +0100 Subject: [PATCH 0548/1070] Add binary_sensor platform to acaia (#130676) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/acaia/__init__.py | 1 + .../components/acaia/binary_sensor.py | 58 +++++++++++++++++++ homeassistant/components/acaia/icons.json | 9 +++ homeassistant/components/acaia/strings.json | 5 ++ .../acaia/snapshots/test_binary_sensor.ambr | 48 +++++++++++++++ tests/components/acaia/test_binary_sensor.py | 28 +++++++++ 6 files changed, 149 insertions(+) create mode 100644 homeassistant/components/acaia/binary_sensor.py create mode 100644 tests/components/acaia/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/acaia/test_binary_sensor.py diff --git a/homeassistant/components/acaia/__init__.py b/homeassistant/components/acaia/__init__.py index f6eccc63b0800..44f21533e98bd 100644 --- a/homeassistant/components/acaia/__init__.py +++ b/homeassistant/components/acaia/__init__.py @@ -6,6 +6,7 @@ from .coordinator import AcaiaConfigEntry, AcaiaCoordinator PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, ] diff --git a/homeassistant/components/acaia/binary_sensor.py b/homeassistant/components/acaia/binary_sensor.py new file mode 100644 index 0000000000000..9aa4b92e93289 --- /dev/null +++ b/homeassistant/components/acaia/binary_sensor.py @@ -0,0 +1,58 @@ +"""Binary sensor platform for Acaia scales.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from aioacaia.acaiascale import AcaiaScale + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import AcaiaConfigEntry +from .entity import AcaiaEntity + + +@dataclass(kw_only=True, frozen=True) +class AcaiaBinarySensorEntityDescription(BinarySensorEntityDescription): + """Description for Acaia binary sensor entities.""" + + is_on_fn: Callable[[AcaiaScale], bool] + + +BINARY_SENSORS: tuple[AcaiaBinarySensorEntityDescription, ...] = ( + AcaiaBinarySensorEntityDescription( + key="timer_running", + translation_key="timer_running", + device_class=BinarySensorDeviceClass.RUNNING, + is_on_fn=lambda scale: scale.timer_running, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AcaiaConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensors.""" + + coordinator = entry.runtime_data + async_add_entities( + AcaiaBinarySensor(coordinator, description) for description in BINARY_SENSORS + ) + + +class AcaiaBinarySensor(AcaiaEntity, BinarySensorEntity): + """Representation of an Acaia binary sensor.""" + + entity_description: AcaiaBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.is_on_fn(self._scale) diff --git a/homeassistant/components/acaia/icons.json b/homeassistant/components/acaia/icons.json index aeab07ee91287..59b316a36cefd 100644 --- a/homeassistant/components/acaia/icons.json +++ b/homeassistant/components/acaia/icons.json @@ -1,5 +1,14 @@ { "entity": { + "binary_sensor": { + "timer_running": { + "default": "mdi:timer", + "state": { + "on": "mdi:timer-play", + "off": "mdi:timer-off" + } + } + }, "button": { "tare": { "default": "mdi:scale-balance" diff --git a/homeassistant/components/acaia/strings.json b/homeassistant/components/acaia/strings.json index f6a1aeb66fdb6..0e52e2c0b2f86 100644 --- a/homeassistant/components/acaia/strings.json +++ b/homeassistant/components/acaia/strings.json @@ -23,6 +23,11 @@ } }, "entity": { + "binary_sensor": { + "timer_running": { + "name": "Timer running" + } + }, "button": { "tare": { "name": "Tare" diff --git a/tests/components/acaia/snapshots/test_binary_sensor.ambr b/tests/components/acaia/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..113b5f1501e6a --- /dev/null +++ b/tests/components/acaia/snapshots/test_binary_sensor.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.lunar_ddeeff_timer_running-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.lunar_ddeeff_timer_running', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Timer running', + 'platform': 'acaia', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'timer_running', + 'unique_id': 'aa:bb:cc:dd:ee:ff_timer_running', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.lunar_ddeeff_timer_running-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'LUNAR-DDEEFF Timer running', + }), + 'context': , + 'entity_id': 'binary_sensor.lunar_ddeeff_timer_running', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/acaia/test_binary_sensor.py b/tests/components/acaia/test_binary_sensor.py new file mode 100644 index 0000000000000..a7aa7034d8d12 --- /dev/null +++ b/tests/components/acaia/test_binary_sensor.py @@ -0,0 +1,28 @@ +"""Test binary sensors for acaia integration.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import 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_binary_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_scale: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the acaia binary sensors.""" + + with patch("homeassistant.components.acaia.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From efa86293aa47f8f7b5395cd0b48f72ea8002b674 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:09:30 +0100 Subject: [PATCH 0549/1070] Bump uiprotect to 6.6.0 (#130872) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 7345dde36dffb..8ba35aad93b20 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==6.5.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==6.6.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 8fef8c67bf304..f2646cfd44661 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2897,7 +2897,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.5.0 +uiprotect==6.6.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db67207ef4e20..eb232521dd3c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2310,7 +2310,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.5.0 +uiprotect==6.6.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 039df1070ea2556e3b335183730f870fa0946fbe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Nov 2024 12:56:33 -0500 Subject: [PATCH 0550/1070] Bump bluetooth-adapters to 0.20.2 (#130877) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index fe16bd73a9e7c..e25c077b57fa4 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,7 +16,7 @@ "requirements": [ "bleak==0.22.3", "bleak-retry-connector==3.6.0", - "bluetooth-adapters==0.20.0", + "bluetooth-adapters==0.20.2", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.20.0", "dbus-fast==2.24.3", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 63425c967e0b5..0ccf1395a8801 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ awesomeversion==24.6.0 bcrypt==4.2.0 bleak-retry-connector==3.6.0 bleak==0.22.3 -bluetooth-adapters==0.20.0 +bluetooth-adapters==0.20.2 bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.20.0 cached-ipaddress==0.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index f2646cfd44661..2a3a68ea9b2e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -617,7 +617,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.20.0 +bluetooth-adapters==0.20.2 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb232521dd3c9..55c51828afe27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -541,7 +541,7 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.20.0 +bluetooth-adapters==0.20.2 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 From a37953512708a9a569fdd119ec478132ca060e54 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 18 Nov 2024 18:59:17 +0100 Subject: [PATCH 0551/1070] Reolink fix dev/entity id migration (#130836) --- homeassistant/components/reolink/__init__.py | 30 ++++- tests/components/reolink/test_init.py | 110 +++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 7a36991201a87..ae0badb3d84fd 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -326,7 +326,19 @@ def migrate_entity_ids( else: new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}" new_identifiers = {(DOMAIN, new_device_id)} - device_reg.async_update_device(device.id, new_identifiers=new_identifiers) + existing_device = device_reg.async_get_device(identifiers=new_identifiers) + if existing_device is None: + device_reg.async_update_device( + device.id, new_identifiers=new_identifiers + ) + else: + _LOGGER.warning( + "Reolink device with uid %s already exists, " + "removing device with uid %s", + new_device_id, + device_uid, + ) + device_reg.async_remove_device(device.id) entity_reg = er.async_get(hass) entities = er.async_entries_for_config_entry(entity_reg, config_entry_id) @@ -352,4 +364,18 @@ def migrate_entity_ids( id_parts = entity.unique_id.split("_", 2) if host.api.supported(ch, "UID") and id_parts[1] != host.api.camera_uid(ch): new_id = f"{host.unique_id}_{host.api.camera_uid(ch)}_{id_parts[2]}" - entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) + existing_entity = entity_reg.async_get_entity_id( + entity.domain, entity.platform, new_id + ) + if existing_entity is None: + entity_reg.async_update_entity( + entity.entity_id, new_unique_id=new_id + ) + else: + _LOGGER.warning( + "Reolink entity with unique_id %s already exists, " + "removing device with unique_id %s", + new_id, + entity.unique_id, + ) + entity_reg.async_remove(entity.entity_id) diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 67ac2db826270..f851e13c91dd7 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -469,6 +469,116 @@ def mock_supported(ch, capability): assert device_registry.async_get_device(identifiers={(DOMAIN, new_dev_id)}) +async def test_migrate_with_already_existing_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device ids that need to be migrated while the new ids already exist.""" + original_dev_id = f"{TEST_MAC}_ch0" + new_dev_id = f"{TEST_UID}_{TEST_UID_CAM}" + domain = Platform.SWITCH + + def mock_supported(ch, capability): + if capability == "UID" and ch is None: + return True + if capability == "UID": + return True + return True + + reolink_connect.channels = [0] + reolink_connect.supported = mock_supported + + device_registry.async_get_or_create( + identifiers={(DOMAIN, new_dev_id)}, + config_entry_id=config_entry.entry_id, + disabled_by=None, + ) + + device_registry.async_get_or_create( + identifiers={(DOMAIN, original_dev_id)}, + config_entry_id=config_entry.entry_id, + disabled_by=None, + ) + + assert device_registry.async_get_device(identifiers={(DOMAIN, original_dev_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, new_dev_id)}) + + # setup CH 0 and host entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [domain]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, original_dev_id)}) + is None + ) + assert device_registry.async_get_device(identifiers={(DOMAIN, new_dev_id)}) + + +async def test_migrate_with_already_existing_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test entity ids that need to be migrated while the new ids already exist.""" + original_id = f"{TEST_UID}_0_record_audio" + new_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio" + dev_id = f"{TEST_UID}_{TEST_UID_CAM}" + domain = Platform.SWITCH + + def mock_supported(ch, capability): + if capability == "UID" and ch is None: + return True + if capability == "UID": + return True + return True + + reolink_connect.channels = [0] + reolink_connect.supported = mock_supported + + dev_entry = device_registry.async_get_or_create( + identifiers={(DOMAIN, dev_id)}, + config_entry_id=config_entry.entry_id, + disabled_by=None, + ) + + entity_registry.async_get_or_create( + domain=domain, + platform=DOMAIN, + unique_id=new_id, + config_entry=config_entry, + suggested_object_id=new_id, + disabled_by=None, + device_id=dev_entry.id, + ) + + entity_registry.async_get_or_create( + domain=domain, + platform=DOMAIN, + unique_id=original_id, + config_entry=config_entry, + suggested_object_id=original_id, + disabled_by=None, + device_id=dev_entry.id, + ) + + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) + assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) + + # setup CH 0 and host entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [domain]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None + assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) + + async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry ) -> None: From fb83d30d9d31186921a710fd5dcaaffb7fb71edb Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 18 Nov 2024 12:48:46 -0600 Subject: [PATCH 0552/1070] Bump hassil to 2.0.2 (#130891) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 1676cdf825485..6c2d70b6a1148 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.0.1", "home-assistant-intents==2024.11.13"] + "requirements": ["hassil==2.0.2", "home-assistant-intents==2024.11.13"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0ccf1395a8801..2ce666f4de5a2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 habluetooth==3.6.0 hass-nabucasa==0.84.0 -hassil==2.0.1 +hassil==2.0.2 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241106.2 home-assistant-intents==2024.11.13 diff --git a/requirements_all.txt b/requirements_all.txt index 2a3a68ea9b2e5..654114fc7bd3c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1096,7 +1096,7 @@ hass-nabucasa==0.84.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.0.1 +hassil==2.0.2 # homeassistant.components.jewish_calendar hdate==0.10.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 55c51828afe27..1a8e44197cb27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -931,7 +931,7 @@ habluetooth==3.6.0 hass-nabucasa==0.84.0 # homeassistant.components.conversation -hassil==2.0.1 +hassil==2.0.2 # homeassistant.components.jewish_calendar hdate==0.10.9 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 0b10b72cfdd37..73edada8992b9 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.4 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.1 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.2 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 52887759410e72b27c1ad755555c27b9c9091826 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:50:31 +0000 Subject: [PATCH 0553/1070] Bump webrtc-models to 0.3.0 (#130889) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2ce666f4de5a2..3fb5ca4e48388 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -69,7 +69,7 @@ uv==0.5.0 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -webrtc-models==0.2.0 +webrtc-models==0.3.0 yarl==1.17.2 zeroconf==0.136.0 diff --git a/pyproject.toml b/pyproject.toml index 29c04dd106205..01dc8d03f13e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ dependencies = [ "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", "yarl==1.17.2", - "webrtc-models==0.2.0", + "webrtc-models==0.3.0", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index b9acbd896fe51..fb8152ec24618 100644 --- a/requirements.txt +++ b/requirements.txt @@ -48,4 +48,4 @@ voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 yarl==1.17.2 -webrtc-models==0.2.0 +webrtc-models==0.3.0 From 069e6c45547e5b7f29a9490e923eaf06cd3c6477 Mon Sep 17 00:00:00 2001 From: Charles Yuan <70110720+CharlesYuan02@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:23:32 -0500 Subject: [PATCH 0554/1070] Fixed Small Inaccuracy in Description String for myUplink (#130900) --- homeassistant/components/myuplink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json index 9ec5c355d7820..997c6fe54b6a3 100644 --- a/homeassistant/components/myuplink/strings.json +++ b/homeassistant/components/myuplink/strings.json @@ -1,6 +1,6 @@ { "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) to give Home Assistant access to your myUplink account. You also need to create application credentials linked to your account:\n1. Go to [Applications at myUplink developer site]({create_creds_url}) and get credentials from an existing application or select **Create New Application**.\n1. Set appropriate Application name and Description\n2. Enter `{callback_url}` as Callback Url" + "description": "Follow the [instructions]({more_info_url}) to give Home Assistant access to your myUplink account. You also need to create application credentials linked to your account:\n1. Go to [Applications at myUplink developer site]({create_creds_url}) and get credentials from an existing application or select **Create New Application**.\n1. Set appropriate Application name and Description\n1. Enter `{callback_url}` as Callback URL" }, "config": { "step": { From e48857987bbd96c21b3fc13f6abeae6895173ae7 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 18 Nov 2024 20:26:45 +0100 Subject: [PATCH 0555/1070] Use camera_capabilities instead frontend_stream_type (#130604) --- homeassistant/components/camera/__init__.py | 2 +- .../components/camera/media_source.py | 4 +- homeassistant/components/camera/webrtc.py | 6 ++- tests/components/camera/test_media_source.py | 7 ++- tests/components/camera/test_webrtc.py | 47 ++----------------- tests/components/go2rtc/test_init.py | 2 +- tests/components/nest/test_camera.py | 2 +- 7 files changed, 18 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index d31d21d424c84..b6bf794a05f90 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -896,7 +896,7 @@ def camera_capabilities(self) -> CameraCapabilities: else: frontend_stream_types.add(StreamType.HLS) - if self._webrtc_provider: + if self._webrtc_provider or self._legacy_webrtc_provider: frontend_stream_types.add(StreamType.WEB_RTC) return CameraCapabilities(frontend_stream_types) diff --git a/homeassistant/components/camera/media_source.py b/homeassistant/components/camera/media_source.py index ea30dafb09e19..222c95ff998f9 100644 --- a/homeassistant/components/camera/media_source.py +++ b/homeassistant/components/camera/media_source.py @@ -64,7 +64,7 @@ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: if not camera: raise Unresolvable(f"Could not resolve media item: {item.identifier}") - if (stream_type := camera.frontend_stream_type) is None: + if not (stream_types := camera.camera_capabilities.frontend_stream_types): return PlayMedia( f"/api/camera_proxy_stream/{camera.entity_id}", camera.content_type ) @@ -76,7 +76,7 @@ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: url = await _async_stream_endpoint_url(self.hass, camera, HLS_PROVIDER) except HomeAssistantError as err: # Handle known error - if stream_type != StreamType.HLS: + if StreamType.HLS not in stream_types: raise Unresolvable( "Camera does not support MJPEG or HLS streaming." ) from err diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index d627a88816948..f43e86fad3883 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -230,13 +230,15 @@ async def validate( """Validate that the camera supports WebRTC.""" entity_id = msg["entity_id"] camera = get_camera_from_entity_id(hass, entity_id) - if camera.frontend_stream_type != StreamType.WEB_RTC: + if StreamType.WEB_RTC not in ( + stream_types := camera.camera_capabilities.frontend_stream_types + ): connection.send_error( msg["id"], error_code, ( "Camera does not support WebRTC," - f" frontend_stream_type={camera.frontend_stream_type}" + f" frontend_stream_types={stream_types}" ), ) return diff --git a/tests/components/camera/test_media_source.py b/tests/components/camera/test_media_source.py index 85f876d4e818d..3b75b58c53f9b 100644 --- a/tests/components/camera/test_media_source.py +++ b/tests/components/camera/test_media_source.py @@ -5,6 +5,7 @@ import pytest from homeassistant.components import media_source +from homeassistant.components.camera import CameraCapabilities from homeassistant.components.camera.const import StreamType from homeassistant.components.stream import FORMAT_CONTENT_TYPE from homeassistant.core import HomeAssistant @@ -130,8 +131,10 @@ async def test_resolving_errors(hass: HomeAssistant) -> None: with ( pytest.raises(media_source.Unresolvable) as exc_info, patch( - "homeassistant.components.camera.Camera.frontend_stream_type", - new_callable=PropertyMock(return_value=StreamType.WEB_RTC), + "homeassistant.components.camera.Camera.camera_capabilities", + new_callable=PropertyMock( + return_value=CameraCapabilities({StreamType.WEB_RTC}) + ), ), ): await media_source.async_resolve_media( diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 29fb9d61c4ed7..be9f3aae6d71b 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -417,7 +417,7 @@ async def test_ws_get_client_config_no_rtc_camera( assert not msg["success"] assert msg["error"] == { "code": "webrtc_get_client_config_failed", - "message": "Camera does not support WebRTC, frontend_stream_type=hls", + "message": "Camera does not support WebRTC, frontend_stream_types={}", } @@ -747,7 +747,7 @@ async def test_websocket_webrtc_offer_invalid_stream_type( assert not response["success"] assert response["error"] == { "code": "webrtc_offer_failed", - "message": "Camera does not support WebRTC, frontend_stream_type=hls", + "message": "Camera does not support WebRTC, frontend_stream_types={}", } @@ -800,45 +800,6 @@ async def mock_hls_stream_source_fixture() -> AsyncGenerator[AsyncMock]: yield mock_hls_stream_source -@pytest.mark.usefixtures( - "mock_camera", - "mock_hls_stream_source", # Not an RTSP stream source - "mock_camera_webrtc_frontendtype_only", -) -async def test_unsupported_rtsp_to_webrtc_stream_type( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: - """Test rtsp-to-webrtc is not registered for non-RTSP streams.""" - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "error", - "code": "webrtc_offer_failed", - "message": "Camera does not support WebRTC", - } - - @pytest.mark.usefixtures("mock_camera", "mock_stream_source") async def test_rtsp_to_webrtc_provider_unregistered( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -894,7 +855,7 @@ async def test_rtsp_to_webrtc_provider_unregistered( assert not response["success"] assert response["error"] == { "code": "webrtc_offer_failed", - "message": "Camera does not support WebRTC, frontend_stream_type=hls", + "message": "Camera does not support WebRTC, frontend_stream_types={}", } assert not mock_provider.called @@ -1093,7 +1054,7 @@ async def test_ws_webrtc_candidate_invalid_stream_type( assert not response["success"] assert response["error"] == { "code": "webrtc_candidate_failed", - "message": "Camera does not support WebRTC, frontend_stream_type=hls", + "message": "Camera does not support WebRTC, frontend_stream_types={}", } diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 0f1cac6942da1..dba3b4d3a5448 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -211,7 +211,7 @@ async def _test_setup_and_signaling( ) -> None: """Test the go2rtc config entry.""" entity_id = camera.entity_id - assert camera.frontend_stream_type == StreamType.HLS + assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS} assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 029879f1413c2..eb15b998507b2 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -745,7 +745,7 @@ async def test_camera_web_rtc_unsupported( assert not msg["success"] assert msg["error"] == { "code": "webrtc_offer_failed", - "message": "Camera does not support WebRTC, frontend_stream_type=hls", + "message": "Camera does not support WebRTC, frontend_stream_types={}", } From 2cf3f2b243f48f88896736115dc6539d3a2efc2b Mon Sep 17 00:00:00 2001 From: YogevBokobza Date: Mon, 18 Nov 2024 21:42:58 +0200 Subject: [PATCH 0556/1070] Bump aioswitcher to 5.0.0 (#130874) * Bump aioswitcher to 5.0.0 * fix linting --- .../components/switcher_kis/button.py | 3 +-- .../components/switcher_kis/climate.py | 4 ++-- homeassistant/components/switcher_kis/cover.py | 4 ++-- homeassistant/components/switcher_kis/light.py | 4 ++-- .../components/switcher_kis/manifest.json | 2 +- .../components/switcher_kis/switch.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/switcher_kis/conftest.py | 8 ++++---- tests/components/switcher_kis/test_button.py | 8 ++++---- tests/components/switcher_kis/test_climate.py | 18 +++++++++--------- tests/components/switcher_kis/test_cover.py | 12 ++++++------ tests/components/switcher_kis/test_light.py | 8 ++++---- tests/components/switcher_kis/test_services.py | 6 +++--- tests/components/switcher_kis/test_switch.py | 8 ++++---- 15 files changed, 46 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index 5564fac830d62..d2686e2e5502a 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -10,7 +10,6 @@ DeviceState, SwitcherApi, SwitcherBaseResponse, - SwitcherType2Api, ThermostatSwing, ) from aioswitcher.api.remotes import SwitcherBreezeRemote @@ -128,7 +127,7 @@ async def async_press(self) -> None: error = None try: - async with SwitcherType2Api( + async with SwitcherApi( self.coordinator.data.device_type, self.coordinator.data.ip_address, self.coordinator.data.device_id, diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index eeff603bc8a6a..f2d4fb60252e7 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -4,7 +4,7 @@ from typing import Any, cast -from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api +from aioswitcher.api import SwitcherApi, SwitcherBaseResponse from aioswitcher.api.remotes import SwitcherBreezeRemote from aioswitcher.device import ( DeviceCategory, @@ -160,7 +160,7 @@ async def _async_control_breeze_device(self, **kwargs: Any) -> None: error = None try: - async with SwitcherType2Api( + async with SwitcherApi( self.coordinator.data.device_type, self.coordinator.data.ip_address, self.coordinator.data.device_id, diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index dc3b6d96aed19..7d3ec0e4af05f 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -5,7 +5,7 @@ import logging from typing import Any, cast -from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api +from aioswitcher.api import SwitcherApi, SwitcherBaseResponse from aioswitcher.device import DeviceCategory, ShutterDirection, SwitcherShutter from homeassistant.components.cover import ( @@ -99,7 +99,7 @@ async def _async_call_api(self, api: str, *args: Any) -> None: error = None try: - async with SwitcherType2Api( + async with SwitcherApi( self.coordinator.data.device_type, self.coordinator.data.ip_address, self.coordinator.data.device_id, diff --git a/homeassistant/components/switcher_kis/light.py b/homeassistant/components/switcher_kis/light.py index bd87176bcf002..b2ee624dbc593 100644 --- a/homeassistant/components/switcher_kis/light.py +++ b/homeassistant/components/switcher_kis/light.py @@ -5,7 +5,7 @@ import logging from typing import Any, cast -from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api +from aioswitcher.api import SwitcherApi, SwitcherBaseResponse from aioswitcher.device import DeviceCategory, DeviceState, SwitcherLight from homeassistant.components.light import ColorMode, LightEntity @@ -86,7 +86,7 @@ async def _async_call_api(self, api: str, *args: Any) -> None: error = None try: - async with SwitcherType2Api( + async with SwitcherApi( self.coordinator.data.device_type, self.coordinator.data.ip_address, self.coordinator.data.device_id, diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 4a50d992d6da2..bdedab03f1628 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aioswitcher"], "quality_scale": "platinum", - "requirements": ["aioswitcher==4.4.0"], + "requirements": ["aioswitcher==5.0.0"], "single_config_entry": true } diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 6a679680263c7..7d14620c1aafa 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -6,7 +6,7 @@ import logging from typing import Any -from aioswitcher.api import Command, SwitcherBaseResponse, SwitcherType1Api +from aioswitcher.api import Command, SwitcherApi, SwitcherBaseResponse from aioswitcher.device import DeviceCategory, DeviceState import voluptuous as vol @@ -105,7 +105,7 @@ async def _async_call_api(self, api: str, *args: Any) -> None: error = None try: - async with SwitcherType1Api( + async with SwitcherApi( self.coordinator.data.device_type, self.coordinator.data.ip_address, self.coordinator.data.device_id, diff --git a/requirements_all.txt b/requirements_all.txt index 654114fc7bd3c..732bca73f0ed1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -387,7 +387,7 @@ aiosteamist==1.0.0 aiostreammagic==2.8.5 # homeassistant.components.switcher_kis -aioswitcher==4.4.0 +aioswitcher==5.0.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a8e44197cb27..e2f58a8776799 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -369,7 +369,7 @@ aiosteamist==1.0.0 aiostreammagic==2.8.5 # homeassistant.components.switcher_kis -aioswitcher==4.4.0 +aioswitcher==5.0.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index 2cf123af2b0a1..518c36616ee31 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -60,19 +60,19 @@ def mock_api(): patchers = [ patch( - "homeassistant.components.switcher_kis.switch.SwitcherType1Api.connect", + "homeassistant.components.switcher_kis.switch.SwitcherApi.connect", new=api_mock, ), patch( - "homeassistant.components.switcher_kis.switch.SwitcherType1Api.disconnect", + "homeassistant.components.switcher_kis.switch.SwitcherApi.disconnect", new=api_mock, ), patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.connect", + "homeassistant.components.switcher_kis.climate.SwitcherApi.connect", new=api_mock, ), patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.disconnect", + "homeassistant.components.switcher_kis.climate.SwitcherApi.disconnect", new=api_mock, ), ] diff --git a/tests/components/switcher_kis/test_button.py b/tests/components/switcher_kis/test_button.py index d0604487370b9..50c015b4024f6 100644 --- a/tests/components/switcher_kis/test_button.py +++ b/tests/components/switcher_kis/test_button.py @@ -42,7 +42,7 @@ async def test_assume_button( assert hass.states.get(SWING_OFF_EID) is None with patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( BUTTON_DOMAIN, @@ -79,7 +79,7 @@ async def test_swing_button( assert hass.states.get(SWING_OFF_EID) is not None with patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( BUTTON_DOMAIN, @@ -103,7 +103,7 @@ async def test_control_device_fail( # Test exception during set hvac mode with patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", side_effect=RuntimeError("fake error"), ) as mock_control_device: with pytest.raises(HomeAssistantError): @@ -130,7 +130,7 @@ async def test_control_device_fail( # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", return_value=SwitcherBaseResponse(None), ) as mock_control_device: with pytest.raises(HomeAssistantError): diff --git a/tests/components/switcher_kis/test_climate.py b/tests/components/switcher_kis/test_climate.py index c9f7abf34dca8..72e1a93d1c312 100644 --- a/tests/components/switcher_kis/test_climate.py +++ b/tests/components/switcher_kis/test_climate.py @@ -49,7 +49,7 @@ async def test_climate_hvac_mode( # Test set hvac mode heat with patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -71,7 +71,7 @@ async def test_climate_hvac_mode( # Test set hvac mode off with patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -108,7 +108,7 @@ async def test_climate_temperature( # Test set target temperature with patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -128,7 +128,7 @@ async def test_climate_temperature( # Test set target temperature - incorrect params with patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", ) as mock_control_device: with pytest.raises(ServiceValidationError): await hass.services.async_call( @@ -160,7 +160,7 @@ async def test_climate_fan_level( # Test set fan level to high with patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -195,7 +195,7 @@ async def test_climate_swing( # Test set swing mode on with patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -218,7 +218,7 @@ async def test_climate_swing( # Test set swing mode off with patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -249,7 +249,7 @@ async def test_control_device_fail(hass: HomeAssistant, mock_bridge, mock_api) - # Test exception during set hvac mode with patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", side_effect=RuntimeError("fake error"), ) as mock_control_device: with pytest.raises(HomeAssistantError): @@ -276,7 +276,7 @@ async def test_control_device_fail(hass: HomeAssistant, mock_bridge, mock_api) - # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", return_value=SwitcherBaseResponse(None), ) as mock_control_device: with pytest.raises(HomeAssistantError): diff --git a/tests/components/switcher_kis/test_cover.py b/tests/components/switcher_kis/test_cover.py index d26fff8754c03..2936cafdd533e 100644 --- a/tests/components/switcher_kis/test_cover.py +++ b/tests/components/switcher_kis/test_cover.py @@ -115,7 +115,7 @@ async def test_cover( # Test set position with patch( - "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position" + "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position" ) as mock_control_device: await hass.services.async_call( COVER_DOMAIN, @@ -136,7 +136,7 @@ async def test_cover( # Test open with patch( - "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position" + "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position" ) as mock_control_device: await hass.services.async_call( COVER_DOMAIN, @@ -156,7 +156,7 @@ async def test_cover( # Test close with patch( - "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position" + "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position" ) as mock_control_device: await hass.services.async_call( COVER_DOMAIN, @@ -176,7 +176,7 @@ async def test_cover( # Test stop with patch( - "homeassistant.components.switcher_kis.cover.SwitcherType2Api.stop_shutter" + "homeassistant.components.switcher_kis.cover.SwitcherApi.stop_shutter" ) as mock_control_device: await hass.services.async_call( COVER_DOMAIN, @@ -232,7 +232,7 @@ async def test_cover_control_fail( # Test exception during set position with patch( - "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position", + "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position", side_effect=RuntimeError("fake error"), ) as mock_control_device: with pytest.raises(HomeAssistantError): @@ -257,7 +257,7 @@ async def test_cover_control_fail( # Test error response during set position with patch( - "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position", + "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position", return_value=SwitcherBaseResponse(None), ) as mock_control_device: with pytest.raises(HomeAssistantError): diff --git a/tests/components/switcher_kis/test_light.py b/tests/components/switcher_kis/test_light.py index 60c851bf6a946..aa7d6551d7502 100644 --- a/tests/components/switcher_kis/test_light.py +++ b/tests/components/switcher_kis/test_light.py @@ -86,7 +86,7 @@ async def test_light( # Test turning on light with patch( - "homeassistant.components.switcher_kis.light.SwitcherType2Api.set_light", + "homeassistant.components.switcher_kis.light.SwitcherApi.set_light", ) as mock_set_light: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -99,7 +99,7 @@ async def test_light( # Test turning off light with patch( - "homeassistant.components.switcher_kis.light.SwitcherType2Api.set_light" + "homeassistant.components.switcher_kis.light.SwitcherApi.set_light" ) as mock_set_light: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -153,7 +153,7 @@ async def test_light_control_fail( # Test exception during turn on with patch( - "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_light", + "homeassistant.components.switcher_kis.cover.SwitcherApi.set_light", side_effect=RuntimeError("fake error"), ) as mock_control_device: with pytest.raises(HomeAssistantError): @@ -178,7 +178,7 @@ async def test_light_control_fail( # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_light", + "homeassistant.components.switcher_kis.cover.SwitcherApi.set_light", return_value=SwitcherBaseResponse(None), ) as mock_control_device: with pytest.raises(HomeAssistantError): diff --git a/tests/components/switcher_kis/test_services.py b/tests/components/switcher_kis/test_services.py index 26c54ee53ed89..65e1967cbac71 100644 --- a/tests/components/switcher_kis/test_services.py +++ b/tests/components/switcher_kis/test_services.py @@ -48,7 +48,7 @@ async def test_turn_on_with_timer_service( assert state.state == STATE_OFF with patch( - "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device" + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" ) as mock_control_device: await hass.services.async_call( DOMAIN, @@ -78,7 +78,7 @@ async def test_set_auto_off_service(hass: HomeAssistant, mock_bridge, mock_api) entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" with patch( - "homeassistant.components.switcher_kis.switch.SwitcherType1Api.set_auto_shutdown" + "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown" ) as mock_set_auto_shutdown: await hass.services.async_call( DOMAIN, @@ -105,7 +105,7 @@ async def test_set_auto_off_service_fail( entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" with patch( - "homeassistant.components.switcher_kis.switch.SwitcherType1Api.set_auto_shutdown", + "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown", return_value=None, ) as mock_set_auto_shutdown: await hass.services.async_call( diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py index f14a8f5b1ca86..443c7bc930d1a 100644 --- a/tests/components/switcher_kis/test_switch.py +++ b/tests/components/switcher_kis/test_switch.py @@ -47,7 +47,7 @@ async def test_switch( # Test turning on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device", + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", ) as mock_control_device: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -60,7 +60,7 @@ async def test_switch( # Test turning off with patch( - "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device" + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" ) as mock_control_device: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -97,7 +97,7 @@ async def test_switch_control_fail( # Test exception during turn on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device", + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", side_effect=RuntimeError("fake error"), ) as mock_control_device: await hass.services.async_call( @@ -121,7 +121,7 @@ async def test_switch_control_fail( # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device", + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", return_value=SwitcherBaseResponse(None), ) as mock_control_device: await hass.services.async_call( From 00250843c6adeb2422b4cae338239c5913dcef89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Nov 2024 15:49:19 -0600 Subject: [PATCH 0557/1070] Bump PySwitchbot to 0.53.2 (#130906) changelog: https://github.com/sblibs/pySwitchbot/compare/0.53.0...0.53.2 --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index a0a11a9c1a4c5..64a2ec7563322 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.53.0"] + "requirements": ["PySwitchbot==0.53.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 732bca73f0ed1..5d9e8b9b4f133 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.53.0 +PySwitchbot==0.53.2 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2f58a8776799..dd6ecb1c30e0a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.53.0 +PySwitchbot==0.53.2 # homeassistant.components.syncthru PySyncThru==0.7.10 From 999f3e0d7753a0ba29d29652cc3ad8798a7b8533 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 19 Nov 2024 03:41:50 +0100 Subject: [PATCH 0558/1070] Use RTCIceCandidateInit instead of RTCIceCandidate (#130901) --- homeassistant/components/camera/__init__.py | 4 +- homeassistant/components/camera/webrtc.py | 13 +++-- homeassistant/components/go2rtc/__init__.py | 6 +-- homeassistant/components/nest/camera.py | 4 +- tests/components/camera/common.py | 4 +- tests/components/camera/conftest.py | 4 +- tests/components/camera/test_init.py | 4 +- tests/components/camera/test_webrtc.py | 50 +++++++++++++++++--- tests/components/go2rtc/test_init.py | 8 ++-- tests/components/unifiprotect/test_camera.py | 4 +- 10 files changed, 72 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index b6bf794a05f90..64a4480d9d38c 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -20,7 +20,7 @@ import attr from propcache import cached_property, under_cached_property import voluptuous as vol -from webrtc_models import RTCIceCandidate, RTCIceServer +from webrtc_models import RTCIceCandidateInit, RTCIceServer from homeassistant.components import websocket_api from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView @@ -865,7 +865,7 @@ def async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration: return config async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidate + self, session_id: str, candidate: RTCIceCandidateInit ) -> None: """Handle a WebRTC candidate.""" if self._webrtc_provider: diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index f43e86fad3883..f020df6109293 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -11,7 +11,12 @@ from typing import TYPE_CHECKING, Any, Protocol import voluptuous as vol -from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceServer +from webrtc_models import ( + RTCConfiguration, + RTCIceCandidate, + RTCIceCandidateInit, + RTCIceServer, +) from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback @@ -78,7 +83,7 @@ class WebRTCAnswer(WebRTCMessage): class WebRTCCandidate(WebRTCMessage): """WebRTC candidate.""" - candidate: RTCIceCandidate + candidate: RTCIceCandidate | RTCIceCandidateInit def as_dict(self) -> dict[str, Any]: """Return a dict representation of the message.""" @@ -146,7 +151,7 @@ async def async_handle_async_webrtc_offer( @abstractmethod async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidate + self, session_id: str, candidate: RTCIceCandidateInit ) -> None: """Handle the WebRTC candidate.""" @@ -338,7 +343,7 @@ async def ws_candidate( ) -> None: """Handle WebRTC candidate websocket command.""" await camera.async_on_webrtc_candidate( - msg["session_id"], RTCIceCandidate(msg["candidate"]) + msg["session_id"], RTCIceCandidateInit(msg["candidate"]) ) connection.send_message(websocket_api.result_message(msg["id"])) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index f1f6e44abc198..31acdd2de5077 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -16,7 +16,7 @@ WsError, ) import voluptuous as vol -from webrtc_models import RTCIceCandidate +from webrtc_models import RTCIceCandidateInit from homeassistant.components.camera import ( Camera, @@ -264,7 +264,7 @@ def on_messages(message: ReceiveMessages) -> None: value: WebRTCMessage match message: case WebRTCCandidate(): - value = HAWebRTCCandidate(RTCIceCandidate(message.candidate)) + value = HAWebRTCCandidate(RTCIceCandidateInit(message.candidate)) case WebRTCAnswer(): value = HAWebRTCAnswer(message.sdp) case WsError(): @@ -277,7 +277,7 @@ def on_messages(message: ReceiveMessages) -> None: await ws_client.send(WebRTCOffer(offer_sdp, config.configuration.ice_servers)) async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidate + self, session_id: str, candidate: RTCIceCandidateInit ) -> None: """Handle the WebRTC candidate.""" diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 0a46d67a3ad52..281e6b0bb2820 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -19,7 +19,7 @@ from google_nest_sdm.device import Device from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.exceptions import ApiException -from webrtc_models import RTCIceCandidate +from webrtc_models import RTCIceCandidateInit from homeassistant.components.camera import ( Camera, @@ -304,7 +304,7 @@ async def async_handle_async_webrtc_offer( self._refresh_unsub[session_id] = refresh.unsub async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidate + self, session_id: str, candidate: RTCIceCandidateInit ) -> None: """Ignore WebRTC candidates for Nest cloud based cameras.""" return diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index 569756c26401e..19ac2cc168b13 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -6,7 +6,7 @@ from unittest.mock import Mock -from webrtc_models import RTCIceCandidate +from webrtc_models import RTCIceCandidateInit from homeassistant.components.camera import ( Camera, @@ -66,7 +66,7 @@ async def async_handle_async_webrtc_offer( send_message(WebRTCAnswer(answer="answer")) async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidate + self, session_id: str, candidate: RTCIceCandidateInit ) -> None: """Handle the WebRTC candidate.""" diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index f0c418711c756..cb25b366029d0 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, Mock, PropertyMock, patch import pytest -from webrtc_models import RTCIceCandidate +from webrtc_models import RTCIceCandidateInit from homeassistant.components import camera from homeassistant.components.camera.const import StreamType @@ -192,7 +192,7 @@ async def async_handle_async_webrtc_offer( send_message(WebRTCAnswer(WEBRTC_ANSWER)) async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidate + self, session_id: str, candidate: RTCIceCandidateInit ) -> None: """Handle a WebRTC candidate.""" # Do nothing diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 32024694b7ea0..af8c220bbe4ca 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -7,7 +7,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from webrtc_models import RTCIceCandidate +from webrtc_models import RTCIceCandidateInit from homeassistant.components import camera from homeassistant.components.camera import ( @@ -954,7 +954,7 @@ async def async_handle_async_webrtc_offer( send_message(WebRTCAnswer("answer")) async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidate + self, session_id: str, candidate: RTCIceCandidateInit ) -> None: """Handle the WebRTC candidate.""" diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index be9f3aae6d71b..89bd74be30184 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from webrtc_models import RTCIceCandidate, RTCIceServer +from webrtc_models import RTCIceCandidate, RTCIceCandidateInit, RTCIceServer from homeassistant.components.camera import ( DATA_ICE_SERVERS, @@ -481,11 +481,30 @@ async def test_websocket_webrtc_offer( assert msg["success"] +@pytest.mark.filterwarnings( + "ignore:Using RTCIceCandidate is deprecated. Use RTCIceCandidateInit instead" +) +@pytest.mark.usefixtures("mock_stream_source", "mock_camera") +async def test_websocket_webrtc_offer_webrtc_provider_deprecated( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + register_test_provider: SomeTestProvider, +) -> None: + """Test initiating a WebRTC stream with a webrtc provider with the deprecated class.""" + await _test_websocket_webrtc_offer_webrtc_provider( + hass, + hass_ws_client, + register_test_provider, + WebRTCCandidate(RTCIceCandidate("candidate")), + {"type": "candidate", "candidate": "candidate"}, + ) + + @pytest.mark.parametrize( ("message", "expected_frontend_message"), [ ( - WebRTCCandidate(RTCIceCandidate("candidate")), + WebRTCCandidate(RTCIceCandidateInit("candidate")), {"type": "candidate", "candidate": "candidate"}, ), ( @@ -503,6 +522,23 @@ async def test_websocket_webrtc_offer_webrtc_provider( register_test_provider: SomeTestProvider, message: WebRTCMessage, expected_frontend_message: dict[str, Any], +) -> None: + """Test initiating a WebRTC stream with a webrtc provider.""" + await _test_websocket_webrtc_offer_webrtc_provider( + hass, + hass_ws_client, + register_test_provider, + message, + expected_frontend_message, + ) + + +async def _test_websocket_webrtc_offer_webrtc_provider( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + register_test_provider: SomeTestProvider, + message: WebRTCMessage, + expected_frontend_message: dict[str, Any], ) -> None: """Test initiating a WebRTC stream with a webrtc provider.""" client = await hass_ws_client(hass) @@ -934,7 +970,7 @@ async def test_ws_webrtc_candidate( assert response["type"] == TYPE_RESULT assert response["success"] mock_on_webrtc_candidate.assert_called_once_with( - session_id, RTCIceCandidate(candidate) + session_id, RTCIceCandidateInit(candidate) ) @@ -986,7 +1022,7 @@ async def test_ws_webrtc_candidate_webrtc_provider( assert response["type"] == TYPE_RESULT assert response["success"] mock_on_webrtc_candidate.assert_called_once_with( - session_id, RTCIceCandidate(candidate) + session_id, RTCIceCandidateInit(candidate) ) @@ -1088,7 +1124,7 @@ async def async_handle_async_webrtc_offer( send_message(WebRTCAnswer(answer="answer")) async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidate + self, session_id: str, candidate: RTCIceCandidateInit ) -> None: """Handle the WebRTC candidate.""" @@ -1098,7 +1134,9 @@ async def async_on_webrtc_candidate( await provider.async_handle_async_webrtc_offer( Mock(), "offer_sdp", "session_id", Mock() ) - await provider.async_on_webrtc_candidate("session_id", RTCIceCandidate("candidate")) + await provider.async_on_webrtc_candidate( + "session_id", RTCIceCandidateInit("candidate") + ) provider.async_close_session("session_id") diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index dba3b4d3a5448..38ff82fc9c895 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -18,7 +18,7 @@ WsError, ) import pytest -from webrtc_models import RTCIceCandidate +from webrtc_models import RTCIceCandidateInit from homeassistant.components.camera import ( DOMAIN as CAMERA_DOMAIN, @@ -423,7 +423,7 @@ async def message_callbacks( [ ( WebRTCCandidate("candidate"), - HAWebRTCCandidate(RTCIceCandidate("candidate")), + HAWebRTCCandidate(RTCIceCandidateInit("candidate")), ), ( WebRTCAnswer(ANSWER_SDP), @@ -459,7 +459,7 @@ async def test_on_candidate( session_id = "session_id" # Session doesn't exist - await camera.async_on_webrtc_candidate(session_id, RTCIceCandidate("candidate")) + await camera.async_on_webrtc_candidate(session_id, RTCIceCandidateInit("candidate")) assert ( "homeassistant.components.go2rtc", logging.DEBUG, @@ -479,7 +479,7 @@ async def test_on_candidate( ) ws_client.reset_mock() - await camera.async_on_webrtc_candidate(session_id, RTCIceCandidate("candidate")) + await camera.async_on_webrtc_candidate(session_id, RTCIceCandidateInit("candidate")) ws_client.send.assert_called_once_with(WebRTCCandidate("candidate")) assert caplog.record_tuples == [] diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 379f443923a27..689352d8aa3dd 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -9,12 +9,12 @@ from uiprotect.data import Camera as ProtectCamera, CameraChannel, StateType from uiprotect.exceptions import NvrError from uiprotect.websocket import WebsocketState +from webrtc_models import RTCIceCandidateInit from homeassistant.components.camera import ( CameraEntityFeature, CameraState, CameraWebRTCProvider, - RTCIceCandidate, StreamType, WebRTCSendMessage, async_get_image, @@ -77,7 +77,7 @@ async def async_handle_async_webrtc_offer( """Handle the WebRTC offer and return the answer via the provided callback.""" async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidate + self, session_id: str, candidate: RTCIceCandidateInit ) -> None: """Handle the WebRTC candidate.""" From c7f0745f48fecdda31c31c4e126dbce186e53f95 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Tue, 19 Nov 2024 03:54:09 +0100 Subject: [PATCH 0559/1070] Catch googlemaps exceptions in google_travel_time (#130903) Catch googlemaps exceptions --- .../components/google_travel_time/sensor.py | 17 +++++++----- .../google_travel_time/test_sensor.py | 27 ++++++++++++++++++- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 618dda50bd4aa..a764036321b5a 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -7,6 +7,7 @@ from googlemaps import Client from googlemaps.distance_matrix import distance_matrix +from googlemaps.exceptions import ApiError, Timeout, TransportError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -172,9 +173,13 @@ def update(self) -> None: self._resolved_destination, ) if self._resolved_destination is not None and self._resolved_origin is not None: - self._matrix = distance_matrix( - self._client, - self._resolved_origin, - self._resolved_destination, - **options_copy, - ) + try: + self._matrix = distance_matrix( + self._client, + self._resolved_origin, + self._resolved_destination, + **options_copy, + ) + except (ApiError, TransportError, Timeout) as ex: + _LOGGER.error("Error getting travel time: %s", ex) + self._matrix = None diff --git a/tests/components/google_travel_time/test_sensor.py b/tests/components/google_travel_time/test_sensor.py index 5ac9ecad4828b..9ee6ebbbc7b06 100644 --- a/tests/components/google_travel_time/test_sensor.py +++ b/tests/components/google_travel_time/test_sensor.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch +from googlemaps.exceptions import ApiError, Timeout, TransportError import pytest from homeassistant.components.google_travel_time.config_flow import default_options @@ -13,7 +14,9 @@ UNITS_IMPERIAL, UNITS_METRIC, ) +from homeassistant.components.google_travel_time.sensor import SCAN_INTERVAL from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import ( METRIC_SYSTEM, US_CUSTOMARY_SYSTEM, @@ -22,7 +25,7 @@ from .const import MOCK_CONFIG -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture(name="mock_update") @@ -240,3 +243,25 @@ async def test_sensor_unit_system( distance_matrix_mock.assert_called_once() assert distance_matrix_mock.call_args.kwargs["units"] == expected_unit_option + + +@pytest.mark.parametrize( + ("exception"), + [(ApiError), (TransportError), (Timeout)], +) +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, {})], +) +async def test_sensor_exception( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_update: MagicMock, + mock_config: MagicMock, + exception: Exception, +) -> None: + """Test that exception gets caught.""" + mock_update.side_effect = exception("Errormessage") + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + assert "Error getting travel time" in caplog.text From 1941850685ef5d09564911bdf7dbbce047ffb209 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 18 Nov 2024 22:47:15 -0800 Subject: [PATCH 0560/1070] Modernize Fitbit entity names (#130828) * Modernize fitbit entity names * Use placeholder with tracker device name * Make names sentence case * Apply name simplifications from PR feedback * Remove translations that are duplicate of device class * Update homeassistant/components/fitbit/sensor.py Co-authored-by: Joost Lekkerkerker * Add a test for tracker distance and update snapshots --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/fitbit/sensor.py | 100 +++++--- homeassistant/components/fitbit/strings.json | 75 ++++++ tests/components/fitbit/conftest.py | 1 + .../fitbit/snapshots/test_sensor.ambr | 228 ++++++++++-------- tests/components/fitbit/test_sensor.py | 122 ++++++---- 5 files changed, 343 insertions(+), 183 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 2218454bd6186..d58dad4ca6720 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -24,7 +24,7 @@ UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -41,6 +41,8 @@ SCAN_INTERVAL: Final = datetime.timedelta(minutes=30) +FITBIT_TRACKER_SUBSTRING = "/tracker/" + def _default_value_fn(result: dict[str, Any]) -> str: """Parse a Fitbit timeseries API responses.""" @@ -122,11 +124,34 @@ class FitbitSensorEntityDescription(SensorEntityDescription): unit_fn: Callable[[FitbitUnitSystem], str | None] = lambda x: None scope: FitbitScope | None = None + @property + def is_tracker(self) -> bool: + """Return if the entity is a tracker.""" + return FITBIT_TRACKER_SUBSTRING in self.key + + +def _build_device_info( + config_entry: ConfigEntry, entity_description: FitbitSensorEntityDescription +) -> DeviceInfo: + """Build device info for sensor entities info across devices.""" + unique_id = cast(str, config_entry.unique_id) + if entity_description.is_tracker: + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"{unique_id}_tracker")}, + translation_key="tracker", + translation_placeholders={"display_name": config_entry.title}, + ) + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, unique_id)}, + ) + FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( FitbitSensorEntityDescription( key="activities/activityCalories", - name="Activity Calories", + translation_key="activity_calories", native_unit_of_measurement="cal", icon="mdi:fire", scope=FitbitScope.ACTIVITY, @@ -135,7 +160,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/calories", - name="Calories", + translation_key="calories", native_unit_of_measurement="cal", icon="mdi:fire", scope=FitbitScope.ACTIVITY, @@ -143,7 +168,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/caloriesBMR", - name="Calories BMR", + translation_key="calories_bmr", native_unit_of_measurement="cal", icon="mdi:fire", scope=FitbitScope.ACTIVITY, @@ -153,7 +178,6 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/distance", - name="Distance", icon="mdi:map-marker", device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, @@ -163,7 +187,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/elevation", - name="Elevation", + translation_key="elevation", icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, unit_fn=_elevation_unit, @@ -173,7 +197,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/floors", - name="Floors", + translation_key="floors", native_unit_of_measurement="floors", icon="mdi:walk", scope=FitbitScope.ACTIVITY, @@ -182,7 +206,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/heart", - name="Resting Heart Rate", + translation_key="resting_heart_rate", native_unit_of_measurement="bpm", icon="mdi:heart-pulse", value_fn=_int_value_or_none("restingHeartRate"), @@ -191,7 +215,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/minutesFairlyActive", - name="Minutes Fairly Active", + translation_key="minutes_fairly_active", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, @@ -201,7 +225,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/minutesLightlyActive", - name="Minutes Lightly Active", + translation_key="minutes_lightly_active", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, @@ -211,7 +235,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/minutesSedentary", - name="Minutes Sedentary", + translation_key="minutes_sedentary", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:seat-recline-normal", device_class=SensorDeviceClass.DURATION, @@ -221,7 +245,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/minutesVeryActive", - name="Minutes Very Active", + translation_key="minutes_very_active", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:run", device_class=SensorDeviceClass.DURATION, @@ -231,7 +255,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/steps", - name="Steps", + translation_key="steps", native_unit_of_measurement="steps", icon="mdi:walk", scope=FitbitScope.ACTIVITY, @@ -239,7 +263,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/tracker/activityCalories", - name="Tracker Activity Calories", + translation_key="activity_calories", native_unit_of_measurement="cal", icon="mdi:fire", scope=FitbitScope.ACTIVITY, @@ -249,7 +273,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/tracker/calories", - name="Tracker Calories", + translation_key="calories", native_unit_of_measurement="cal", icon="mdi:fire", scope=FitbitScope.ACTIVITY, @@ -259,7 +283,6 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/tracker/distance", - name="Tracker Distance", icon="mdi:map-marker", device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, @@ -271,7 +294,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/tracker/elevation", - name="Tracker Elevation", + translation_key="elevation", icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, unit_fn=_elevation_unit, @@ -282,7 +305,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/tracker/floors", - name="Tracker Floors", + translation_key="floors", native_unit_of_measurement="floors", icon="mdi:walk", scope=FitbitScope.ACTIVITY, @@ -292,7 +315,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/tracker/minutesFairlyActive", - name="Tracker Minutes Fairly Active", + translation_key="minutes_fairly_active", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, @@ -303,7 +326,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/tracker/minutesLightlyActive", - name="Tracker Minutes Lightly Active", + translation_key="minutes_lightly_active", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, @@ -314,7 +337,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/tracker/minutesSedentary", - name="Tracker Minutes Sedentary", + translation_key="minutes_sedentary", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:seat-recline-normal", device_class=SensorDeviceClass.DURATION, @@ -325,7 +348,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/tracker/minutesVeryActive", - name="Tracker Minutes Very Active", + translation_key="minutes_very_active", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:run", device_class=SensorDeviceClass.DURATION, @@ -336,7 +359,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="activities/tracker/steps", - name="Tracker Steps", + translation_key="steps", native_unit_of_measurement="steps", icon="mdi:walk", scope=FitbitScope.ACTIVITY, @@ -346,7 +369,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="body/bmi", - name="BMI", + translation_key="bmi", native_unit_of_measurement="BMI", icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, @@ -357,7 +380,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="body/fat", - name="Body Fat", + translation_key="body_fat", native_unit_of_measurement=PERCENTAGE, icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, @@ -368,7 +391,6 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="body/weight", - name="Weight", icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.WEIGHT, @@ -378,7 +400,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="sleep/awakeningsCount", - name="Awakenings Count", + translation_key="awakenings_count", native_unit_of_measurement="times awaken", icon="mdi:sleep", scope=FitbitScope.SLEEP, @@ -387,7 +409,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="sleep/efficiency", - name="Sleep Efficiency", + translation_key="sleep_efficiency", native_unit_of_measurement=PERCENTAGE, icon="mdi:sleep", state_class=SensorStateClass.MEASUREMENT, @@ -396,7 +418,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="sleep/minutesAfterWakeup", - name="Minutes After Wakeup", + translation_key="minutes_after_wakeup", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -406,7 +428,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="sleep/minutesAsleep", - name="Sleep Minutes Asleep", + translation_key="sleep_minutes_asleep", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -416,7 +438,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="sleep/minutesAwake", - name="Sleep Minutes Awake", + translation_key="sleep_minutes_awake", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -426,7 +448,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="sleep/minutesToFallAsleep", - name="Sleep Minutes to Fall Asleep", + translation_key="sleep_minutes_to_fall_asleep", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -436,7 +458,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="sleep/timeInBed", - name="Sleep Time in Bed", + translation_key="sleep_time_in_bed", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:hotel", device_class=SensorDeviceClass.DURATION, @@ -446,7 +468,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="foods/log/caloriesIn", - name="Calories In", + translation_key="calories_in", native_unit_of_measurement="cal", icon="mdi:food-apple", state_class=SensorStateClass.TOTAL_INCREASING, @@ -455,7 +477,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): ), FitbitSensorEntityDescription( key="foods/log/water", - name="Water", + translation_key="water", icon="mdi:cup-water", unit_fn=_water_unit, state_class=SensorStateClass.TOTAL_INCREASING, @@ -467,14 +489,14 @@ class FitbitSensorEntityDescription(SensorEntityDescription): # Different description depending on clock format SLEEP_START_TIME = FitbitSensorEntityDescription( key="sleep/startTime", - name="Sleep Start Time", + translation_key="sleep_start_time", icon="mdi:clock", scope=FitbitScope.SLEEP, entity_category=EntityCategory.DIAGNOSTIC, ) SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( key="sleep/startTime", - name="Sleep Start Time", + translation_key="sleep_start_time", icon="mdi:clock", value_fn=_clock_format_12h, scope=FitbitScope.SLEEP, @@ -540,6 +562,7 @@ def is_allowed_resource(description: FitbitSensorEntityDescription) -> bool: description, units=description.unit_fn(unit_system), enable_default_override=is_explicit_enable(description), + device_info=_build_device_info(entry, description), ) for description in resource_list if is_allowed_resource(description) @@ -574,6 +597,7 @@ class FitbitSensor(SensorEntity): entity_description: FitbitSensorEntityDescription _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, @@ -583,6 +607,7 @@ def __init__( description: FitbitSensorEntityDescription, units: str | None, enable_default_override: bool, + device_info: DeviceInfo, ) -> None: """Initialize the Fitbit sensor.""" self.config_entry = config_entry @@ -590,6 +615,7 @@ def __init__( self.api = api self._attr_unique_id = f"{user_profile_id}_{description.key}" + self._attr_device_info = device_info if units is not None: self._attr_native_unit_of_measurement = units diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index 2df6fa14b0789..9029a8265bb6e 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -38,7 +38,82 @@ }, "battery_level": { "name": "Battery level" + }, + "activity_calories": { + "name": "Activity calories" + }, + "calories": { + "name": "Calories" + }, + "calories_bmr": { + "name": "Calories BMR" + }, + "elevation": { + "name": "Elevation" + }, + "floors": { + "name": "Floors" + }, + "resting_heart_rate": { + "name": "Resting heart rate" + }, + "minutes_fairly_active": { + "name": "Minutes fairly active" + }, + "minutes_lightly_active": { + "name": "Minutes lightly active" + }, + "minutes_sedentary": { + "name": "Minutes sedentary" + }, + "minutes_very_active": { + "name": "Minutes very active" + }, + "sleep_start_time": { + "name": "Sleep start time" + }, + "steps": { + "name": "Steps" + }, + "bmi": { + "name": "BMI" + }, + "body_fat": { + "name": "Body fat" + }, + "awakenings_count": { + "name": "Awakenings count" + }, + "sleep_efficiency": { + "name": "Sleep efficiency" + }, + "minutes_after_wakeup": { + "name": "Minutes after wakeup" + }, + "sleep_minutes_asleep": { + "name": "Sleep minutes asleep" + }, + "sleep_minutes_awake": { + "name": "Sleep minutes awake" + }, + "sleep_minutes_to_fall_asleep": { + "name": "Sleep minutes to fall asleep" + }, + "sleep_time_in_bed": { + "name": "Sleep time in bed" + }, + "calories_in": { + "name": "Calories in" + }, + "water": { + "name": "Water" } } + }, + + "device": { + "tracker": { + "name": "{display_name} tracker" + } } } diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 48ceca02d0e63..8a408748f16a3 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -90,6 +90,7 @@ def mock_config_entry( **imported_config_data, }, unique_id=PROFILE_USER_ID, + title=DISPLAY_NAME, ) diff --git a/tests/components/fitbit/snapshots/test_sensor.ambr b/tests/components/fitbit/snapshots/test_sensor.ambr index 55b2639a56da3..068df25454d25 100644 --- a/tests/components/fitbit/snapshots/test_sensor.ambr +++ b/tests/components/fitbit/snapshots/test_sensor.ambr @@ -4,7 +4,7 @@ '99', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'Water', + 'friendly_name': 'First L. Water', 'icon': 'mdi:cup-water', 'state_class': , 'unit_of_measurement': , @@ -16,7 +16,7 @@ '1600', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'Calories In', + 'friendly_name': 'First L. Calories in', 'icon': 'mdi:food-apple', 'state_class': , 'unit_of_measurement': 'cal', @@ -28,7 +28,7 @@ '99', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'Water', + 'friendly_name': 'First L. Water', 'icon': 'mdi:cup-water', 'state_class': , 'unit_of_measurement': , @@ -40,19 +40,19 @@ '1600', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'Calories In', + 'friendly_name': 'First L. Calories in', 'icon': 'mdi:food-apple', 'state_class': , 'unit_of_measurement': 'cal', }), ) # --- -# name: test_sensors[monitored_resources0-sensor.activity_calories-activities/activityCalories-135] +# name: test_sensors[monitored_resources0-sensor.first_l_activity_calories-activities/activityCalories-135] tuple( '135', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'Activity Calories', + 'friendly_name': 'First L. Activity calories', 'icon': 'mdi:fire', 'state_class': , 'unit_of_measurement': 'cal', @@ -60,25 +60,67 @@ 'fitbit-api-user-id-1_activities/activityCalories', ) # --- -# name: test_sensors[monitored_resources1-sensor.calories-activities/calories-139] +# name: test_sensors[monitored_resources1-sensor.first_l_tracker_activity_calories-activities/tracker/activityCalories-135] tuple( - '139', + '135', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'Calories', + 'friendly_name': 'First L. tracker Activity calories', 'icon': 'mdi:fire', 'state_class': , 'unit_of_measurement': 'cal', }), - 'fitbit-api-user-id-1_activities/calories', + 'fitbit-api-user-id-1_activities/tracker/activityCalories', + ) +# --- +# name: test_sensors[monitored_resources10-sensor.first_l_minutes_lightly_active-activities/minutesLightlyActive-95] + tuple( + '95', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'First L. Minutes lightly active', + 'icon': 'mdi:walk', + 'state_class': , + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesLightlyActive', ) # --- -# name: test_sensors[monitored_resources10-sensor.steps-activities/steps-5600] +# name: test_sensors[monitored_resources11-sensor.first_l_minutes_sedentary-activities/minutesSedentary-18] + tuple( + '18', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'First L. Minutes sedentary', + 'icon': 'mdi:seat-recline-normal', + 'state_class': , + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesSedentary', + ) +# --- +# name: test_sensors[monitored_resources12-sensor.first_l_minutes_very_active-activities/minutesVeryActive-20] + tuple( + '20', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'First L. Minutes very active', + 'icon': 'mdi:run', + 'state_class': , + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesVeryActive', + ) +# --- +# name: test_sensors[monitored_resources13-sensor.first_l_steps-activities/steps-5600] tuple( '5600', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'Steps', + 'friendly_name': 'First L. Steps', 'icon': 'mdi:walk', 'state_class': , 'unit_of_measurement': 'steps', @@ -86,13 +128,13 @@ 'fitbit-api-user-id-1_activities/steps', ) # --- -# name: test_sensors[monitored_resources11-sensor.weight-body/weight-175] +# name: test_sensors[monitored_resources14-sensor.first_l_weight-body/weight-175] tuple( '175.0', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', 'device_class': 'weight', - 'friendly_name': 'Weight', + 'friendly_name': 'First L. Weight', 'icon': 'mdi:human', 'state_class': , 'unit_of_measurement': , @@ -100,12 +142,12 @@ 'fitbit-api-user-id-1_body/weight', ) # --- -# name: test_sensors[monitored_resources12-sensor.body_fat-body/fat-18] +# name: test_sensors[monitored_resources15-sensor.first_l_body_fat-body/fat-18] tuple( '18.0', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'Body Fat', + 'friendly_name': 'First L. Body fat', 'icon': 'mdi:human', 'state_class': , 'unit_of_measurement': '%', @@ -113,12 +155,12 @@ 'fitbit-api-user-id-1_body/fat', ) # --- -# name: test_sensors[monitored_resources13-sensor.bmi-body/bmi-23.7] +# name: test_sensors[monitored_resources16-sensor.first_l_bmi-body/bmi-23.7] tuple( '23.7', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'BMI', + 'friendly_name': 'First L. BMI', 'icon': 'mdi:human', 'state_class': , 'unit_of_measurement': 'BMI', @@ -126,12 +168,12 @@ 'fitbit-api-user-id-1_body/bmi', ) # --- -# name: test_sensors[monitored_resources14-sensor.awakenings_count-sleep/awakeningsCount-7] +# name: test_sensors[monitored_resources17-sensor.first_l_awakenings_count-sleep/awakeningsCount-7] tuple( '7', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'Awakenings Count', + 'friendly_name': 'First L. Awakenings count', 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': 'times awaken', @@ -139,12 +181,12 @@ 'fitbit-api-user-id-1_sleep/awakeningsCount', ) # --- -# name: test_sensors[monitored_resources15-sensor.sleep_efficiency-sleep/efficiency-80] +# name: test_sensors[monitored_resources18-sensor.first_l_sleep_efficiency-sleep/efficiency-80] tuple( '80', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'Sleep Efficiency', + 'friendly_name': 'First L. Sleep efficiency', 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': '%', @@ -152,13 +194,13 @@ 'fitbit-api-user-id-1_sleep/efficiency', ) # --- -# name: test_sensors[monitored_resources16-sensor.minutes_after_wakeup-sleep/minutesAfterWakeup-17] +# name: test_sensors[monitored_resources19-sensor.first_l_minutes_after_wakeup-sleep/minutesAfterWakeup-17] tuple( '17', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', 'device_class': 'duration', - 'friendly_name': 'Minutes After Wakeup', + 'friendly_name': 'First L. Minutes after wakeup', 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': , @@ -166,13 +208,26 @@ 'fitbit-api-user-id-1_sleep/minutesAfterWakeup', ) # --- -# name: test_sensors[monitored_resources17-sensor.sleep_minutes_asleep-sleep/minutesAsleep-360] +# name: test_sensors[monitored_resources2-sensor.first_l_calories-activities/calories-139] + tuple( + '139', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'First L. Calories', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': 'cal', + }), + 'fitbit-api-user-id-1_activities/calories', + ) +# --- +# name: test_sensors[monitored_resources20-sensor.first_l_sleep_minutes_asleep-sleep/minutesAsleep-360] tuple( '360', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', 'device_class': 'duration', - 'friendly_name': 'Sleep Minutes Asleep', + 'friendly_name': 'First L. Sleep minutes asleep', 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': , @@ -180,13 +235,13 @@ 'fitbit-api-user-id-1_sleep/minutesAsleep', ) # --- -# name: test_sensors[monitored_resources18-sensor.sleep_minutes_awake-sleep/minutesAwake-35] +# name: test_sensors[monitored_resources21-sensor.first_l_sleep_minutes_awake-sleep/minutesAwake-35] tuple( '35', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', 'device_class': 'duration', - 'friendly_name': 'Sleep Minutes Awake', + 'friendly_name': 'First L. Sleep minutes awake', 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': , @@ -194,13 +249,13 @@ 'fitbit-api-user-id-1_sleep/minutesAwake', ) # --- -# name: test_sensors[monitored_resources19-sensor.sleep_minutes_to_fall_asleep-sleep/minutesToFallAsleep-35] +# name: test_sensors[monitored_resources22-sensor.first_l_sleep_minutes_to_fall_asleep-sleep/minutesToFallAsleep-35] tuple( '35', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', 'device_class': 'duration', - 'friendly_name': 'Sleep Minutes to Fall Asleep', + 'friendly_name': 'First L. Sleep minutes to fall asleep', 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': , @@ -208,38 +263,24 @@ 'fitbit-api-user-id-1_sleep/minutesToFallAsleep', ) # --- -# name: test_sensors[monitored_resources2-sensor.distance-activities/distance-12.7] - tuple( - '12.70', - ReadOnlyDict({ - 'attribution': 'Data provided by Fitbit.com', - 'device_class': 'distance', - 'friendly_name': 'Distance', - 'icon': 'mdi:map-marker', - 'state_class': , - 'unit_of_measurement': , - }), - 'fitbit-api-user-id-1_activities/distance', - ) -# --- -# name: test_sensors[monitored_resources20-sensor.sleep_start_time-sleep/startTime-2020-01-27T00:17:30.000] +# name: test_sensors[monitored_resources23-sensor.first_l_sleep_start_time-sleep/startTime-2020-01-27T00:17:30.000] tuple( '2020-01-27T00:17:30.000', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'Sleep Start Time', + 'friendly_name': 'First L. Sleep start time', 'icon': 'mdi:clock', }), 'fitbit-api-user-id-1_sleep/startTime', ) # --- -# name: test_sensors[monitored_resources21-sensor.sleep_time_in_bed-sleep/timeInBed-462] +# name: test_sensors[monitored_resources24-sensor.first_l_sleep_time_in_bed-sleep/timeInBed-462] tuple( '462', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', 'device_class': 'duration', - 'friendly_name': 'Sleep Time in Bed', + 'friendly_name': 'First L. Sleep time in bed', 'icon': 'mdi:hotel', 'state_class': , 'unit_of_measurement': , @@ -247,99 +288,98 @@ 'fitbit-api-user-id-1_sleep/timeInBed', ) # --- -# name: test_sensors[monitored_resources3-sensor.elevation-activities/elevation-7600.24] +# name: test_sensors[monitored_resources3-sensor.first_l_tracker_calories-activities/tracker/calories-139] tuple( - '7600.24', + '139', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'device_class': 'distance', - 'friendly_name': 'Elevation', - 'icon': 'mdi:walk', - 'state_class': , - 'unit_of_measurement': , + 'friendly_name': 'First L. tracker Calories', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': 'cal', }), - 'fitbit-api-user-id-1_activities/elevation', + 'fitbit-api-user-id-1_activities/tracker/calories', ) # --- -# name: test_sensors[monitored_resources4-sensor.floors-activities/floors-8] +# name: test_sensors[monitored_resources4-sensor.first_l_distance-activities/distance-12.7] tuple( - '8', + '12.70', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'Floors', - 'icon': 'mdi:walk', + 'device_class': 'distance', + 'friendly_name': 'First L. Distance', + 'icon': 'mdi:map-marker', 'state_class': , - 'unit_of_measurement': 'floors', + 'unit_of_measurement': , }), - 'fitbit-api-user-id-1_activities/floors', + 'fitbit-api-user-id-1_activities/distance', ) # --- -# name: test_sensors[monitored_resources5-sensor.resting_heart_rate-activities/heart-api_value5] +# name: test_sensors[monitored_resources5-sensor.first_l_tracker_distance-activities/distance-12.7] tuple( - '76', + 'unknown', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'friendly_name': 'Resting Heart Rate', - 'icon': 'mdi:heart-pulse', - 'state_class': , - 'unit_of_measurement': 'bpm', + 'device_class': 'distance', + 'friendly_name': 'First L. tracker Distance', + 'icon': 'mdi:map-marker', + 'state_class': , + 'unit_of_measurement': , }), - 'fitbit-api-user-id-1_activities/heart', + 'fitbit-api-user-id-1_activities/tracker/distance', ) # --- -# name: test_sensors[monitored_resources6-sensor.minutes_fairly_active-activities/minutesFairlyActive-35] +# name: test_sensors[monitored_resources6-sensor.first_l_elevation-activities/elevation-7600.24] tuple( - '35', + '7600.24', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'device_class': 'duration', - 'friendly_name': 'Minutes Fairly Active', + 'device_class': 'distance', + 'friendly_name': 'First L. Elevation', 'icon': 'mdi:walk', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), - 'fitbit-api-user-id-1_activities/minutesFairlyActive', + 'fitbit-api-user-id-1_activities/elevation', ) # --- -# name: test_sensors[monitored_resources7-sensor.minutes_lightly_active-activities/minutesLightlyActive-95] +# name: test_sensors[monitored_resources7-sensor.first_l_floors-activities/floors-8] tuple( - '95', + '8', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'device_class': 'duration', - 'friendly_name': 'Minutes Lightly Active', + 'friendly_name': 'First L. Floors', 'icon': 'mdi:walk', - 'state_class': , - 'unit_of_measurement': , + 'state_class': , + 'unit_of_measurement': 'floors', }), - 'fitbit-api-user-id-1_activities/minutesLightlyActive', + 'fitbit-api-user-id-1_activities/floors', ) # --- -# name: test_sensors[monitored_resources8-sensor.minutes_sedentary-activities/minutesSedentary-18] +# name: test_sensors[monitored_resources8-sensor.first_l_resting_heart_rate-activities/heart-api_value8] tuple( - '18', + '76', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', - 'device_class': 'duration', - 'friendly_name': 'Minutes Sedentary', - 'icon': 'mdi:seat-recline-normal', + 'friendly_name': 'First L. Resting heart rate', + 'icon': 'mdi:heart-pulse', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'bpm', }), - 'fitbit-api-user-id-1_activities/minutesSedentary', + 'fitbit-api-user-id-1_activities/heart', ) # --- -# name: test_sensors[monitored_resources9-sensor.minutes_very_active-activities/minutesVeryActive-20] +# name: test_sensors[monitored_resources9-sensor.first_l_minutes_fairly_active-activities/minutesFairlyActive-35] tuple( - '20', + '35', ReadOnlyDict({ 'attribution': 'Data provided by Fitbit.com', 'device_class': 'duration', - 'friendly_name': 'Minutes Very Active', - 'icon': 'mdi:run', + 'friendly_name': 'First L. Minutes fairly active', + 'icon': 'mdi:walk', 'state_class': , 'unit_of_measurement': , }), - 'fitbit-api-user-id-1_activities/minutesVeryActive', + 'fitbit-api-user-id-1_activities/minutesFairlyActive', ) # --- diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index d67bd75396fc7..cee9835f89fce 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -78,133 +78,151 @@ def mock_token_refresh(requests_mock: Mocker) -> None: [ ( ["activities/activityCalories"], - "sensor.activity_calories", + "sensor.first_l_activity_calories", "activities/activityCalories", "135", ), + ( + ["activities/tracker/activityCalories"], + "sensor.first_l_tracker_activity_calories", + "activities/tracker/activityCalories", + "135", + ), ( ["activities/calories"], - "sensor.calories", + "sensor.first_l_calories", "activities/calories", "139", ), + ( + ["activities/tracker/calories"], + "sensor.first_l_tracker_calories", + "activities/tracker/calories", + "139", + ), ( ["activities/distance"], - "sensor.distance", + "sensor.first_l_distance", + "activities/distance", + "12.7", + ), + ( + ["activities/tracker/distance"], + "sensor.first_l_tracker_distance", "activities/distance", "12.7", ), ( ["activities/elevation"], - "sensor.elevation", + "sensor.first_l_elevation", "activities/elevation", "7600.24", ), ( ["activities/floors"], - "sensor.floors", + "sensor.first_l_floors", "activities/floors", "8", ), ( ["activities/heart"], - "sensor.resting_heart_rate", + "sensor.first_l_resting_heart_rate", "activities/heart", {"restingHeartRate": 76}, ), ( ["activities/minutesFairlyActive"], - "sensor.minutes_fairly_active", + "sensor.first_l_minutes_fairly_active", "activities/minutesFairlyActive", 35, ), ( ["activities/minutesLightlyActive"], - "sensor.minutes_lightly_active", + "sensor.first_l_minutes_lightly_active", "activities/minutesLightlyActive", 95, ), ( ["activities/minutesSedentary"], - "sensor.minutes_sedentary", + "sensor.first_l_minutes_sedentary", "activities/minutesSedentary", 18, ), ( ["activities/minutesVeryActive"], - "sensor.minutes_very_active", + "sensor.first_l_minutes_very_active", "activities/minutesVeryActive", 20, ), ( ["activities/steps"], - "sensor.steps", + "sensor.first_l_steps", "activities/steps", "5600", ), ( ["body/weight"], - "sensor.weight", + "sensor.first_l_weight", "body/weight", "175", ), ( ["body/fat"], - "sensor.body_fat", + "sensor.first_l_body_fat", "body/fat", "18", ), ( ["body/bmi"], - "sensor.bmi", + "sensor.first_l_bmi", "body/bmi", "23.7", ), ( ["sleep/awakeningsCount"], - "sensor.awakenings_count", + "sensor.first_l_awakenings_count", "sleep/awakeningsCount", "7", ), ( ["sleep/efficiency"], - "sensor.sleep_efficiency", + "sensor.first_l_sleep_efficiency", "sleep/efficiency", "80", ), ( ["sleep/minutesAfterWakeup"], - "sensor.minutes_after_wakeup", + "sensor.first_l_minutes_after_wakeup", "sleep/minutesAfterWakeup", "17", ), ( ["sleep/minutesAsleep"], - "sensor.sleep_minutes_asleep", + "sensor.first_l_sleep_minutes_asleep", "sleep/minutesAsleep", "360", ), ( ["sleep/minutesAwake"], - "sensor.sleep_minutes_awake", + "sensor.first_l_sleep_minutes_awake", "sleep/minutesAwake", "35", ), ( ["sleep/minutesToFallAsleep"], - "sensor.sleep_minutes_to_fall_asleep", + "sensor.first_l_sleep_minutes_to_fall_asleep", "sleep/minutesToFallAsleep", "35", ), ( ["sleep/startTime"], - "sensor.sleep_start_time", + "sensor.first_l_sleep_start_time", "sleep/startTime", "2020-01-27T00:17:30.000", ), ( ["sleep/timeInBed"], - "sensor.sleep_time_in_bed", + "sensor.first_l_sleep_time_in_bed", "sleep/timeInBed", "462", ), @@ -359,7 +377,7 @@ async def test_profile_local( entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 - state = hass.states.get("sensor.weight") + state = hass.states.get("sensor.first_l_weight") assert state assert state.attributes.get("unit_of_measurement") == expected_unit @@ -409,7 +427,7 @@ async def test_sleep_time_clock_format( ) assert await integration_setup() - state = hass.states.get("sensor.sleep_start_time") + state = hass.states.get("sensor.first_l_sleep_start_time") assert state assert state.state == expected_state @@ -445,16 +463,16 @@ async def test_activity_scope_config_entry( states = hass.states.async_all() assert {s.entity_id for s in states} == { - "sensor.activity_calories", - "sensor.calories", - "sensor.distance", - "sensor.elevation", - "sensor.floors", - "sensor.minutes_fairly_active", - "sensor.minutes_lightly_active", - "sensor.minutes_sedentary", - "sensor.minutes_very_active", - "sensor.steps", + "sensor.first_l_activity_calories", + "sensor.first_l_calories", + "sensor.first_l_distance", + "sensor.first_l_elevation", + "sensor.first_l_floors", + "sensor.first_l_minutes_fairly_active", + "sensor.first_l_minutes_lightly_active", + "sensor.first_l_minutes_sedentary", + "sensor.first_l_minutes_very_active", + "sensor.first_l_steps", } @@ -478,7 +496,7 @@ async def test_heartrate_scope_config_entry( states = hass.states.async_all() assert {s.entity_id for s in states} == { - "sensor.resting_heart_rate", + "sensor.first_l_resting_heart_rate", } @@ -506,11 +524,11 @@ async def test_nutrition_scope_config_entry( ) assert await integration_setup() - state = hass.states.get("sensor.water") + state = hass.states.get("sensor.first_l_water") assert state assert (state.state, state.attributes) == snapshot - state = hass.states.get("sensor.calories_in") + state = hass.states.get("sensor.first_l_calories_in") assert state assert (state.state, state.attributes) == snapshot @@ -545,14 +563,14 @@ async def test_sleep_scope_config_entry( states = hass.states.async_all() assert {s.entity_id for s in states} == { - "sensor.awakenings_count", - "sensor.sleep_efficiency", - "sensor.minutes_after_wakeup", - "sensor.sleep_minutes_asleep", - "sensor.sleep_minutes_awake", - "sensor.sleep_minutes_to_fall_asleep", - "sensor.sleep_time_in_bed", - "sensor.sleep_start_time", + "sensor.first_l_awakenings_count", + "sensor.first_l_sleep_efficiency", + "sensor.first_l_minutes_after_wakeup", + "sensor.first_l_sleep_minutes_asleep", + "sensor.first_l_sleep_minutes_awake", + "sensor.first_l_sleep_minutes_to_fall_asleep", + "sensor.first_l_sleep_time_in_bed", + "sensor.first_l_sleep_start_time", } @@ -573,7 +591,7 @@ async def test_weight_scope_config_entry( states = hass.states.async_all() assert [s.entity_id for s in states] == [ - "sensor.weight", + "sensor.first_l_weight", ] @@ -623,7 +641,7 @@ async def test_sensor_update_failed( assert await integration_setup() - state = hass.states.get("sensor.resting_heart_rate") + state = hass.states.get("sensor.first_l_resting_heart_rate") assert state assert state.state == "unavailable" @@ -655,7 +673,7 @@ async def test_sensor_update_failed_requires_reauth( assert await integration_setup() - state = hass.states.get("sensor.resting_heart_rate") + state = hass.states.get("sensor.first_l_resting_heart_rate") assert state assert state.state == "unavailable" @@ -698,14 +716,14 @@ async def test_sensor_update_success( assert await integration_setup() - state = hass.states.get("sensor.resting_heart_rate") + state = hass.states.get("sensor.first_l_resting_heart_rate") assert state assert state.state == "60" - await async_update_entity(hass, "sensor.resting_heart_rate") + await async_update_entity(hass, "sensor.first_l_resting_heart_rate") await hass.async_block_till_done() - state = hass.states.get("sensor.resting_heart_rate") + state = hass.states.get("sensor.first_l_resting_heart_rate") assert state assert state.state == "70" @@ -867,6 +885,6 @@ async def test_resting_heart_rate_responses( ) assert await integration_setup() - state = hass.states.get("sensor.resting_heart_rate") + state = hass.states.get("sensor.first_l_resting_heart_rate") assert state assert state.state == expected_state From 4836f4af2b816672fdc5461535915450ef0b8d92 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 19 Nov 2024 07:47:38 +0100 Subject: [PATCH 0561/1070] Bump pre-commit-hooks to v5.0.0 (#130888) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5fcd7eb5f8007..f2b2a77ae17ba 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: exclude_types: [csv, json, html] exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v5.0.0 hooks: - id: check-executables-have-shebangs stages: [manual] From f9a0cc5c310c9cf2d5011240e7b48ab3af30a66e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 19 Nov 2024 08:30:41 +0100 Subject: [PATCH 0562/1070] Add new sensors to Mill (#130896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Mill sensors Signed-off-by: Daniel Hjelseth Høyer * Update homeassistant/components/mill/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mill/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update const.py * Update sensor.py --------- Signed-off-by: Daniel Hjelseth Høyer Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/mill/sensor.py | 27 ++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 64b9008a82b81..c4b975ab03973 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -57,6 +57,19 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), + SensorEntityDescription( + key="current_power", + translation_key="current_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="control_signal", + translation_key="control_signal", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), ) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -118,6 +131,16 @@ ), ) +SOCKET_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + *HEATER_SENSOR_TYPES, +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -145,7 +168,9 @@ async def async_setup_entry( ) for mill_device in mill_data_coordinator.data.values() for entity_description in ( - HEATER_SENSOR_TYPES + SOCKET_SENSOR_TYPES + if isinstance(mill_device, mill.Socket) + else HEATER_SENSOR_TYPES if isinstance(mill_device, mill.Heater) else SENSOR_TYPES ) From b6d79415fe05bcdf3a12a23a905f683dbf97e36c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Nov 2024 01:36:44 -0600 Subject: [PATCH 0563/1070] Bump aiohttp to 3.11.4 (#130924) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3fb5ca4e48388..f5d9492194159 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.2.0 -aiohttp==3.11.2 +aiohttp==3.11.4 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 01dc8d03f13e8..f2be95a697f59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.2", + "aiohttp==3.11.4", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index fb8152ec24618..ebb35d5fffc57 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.2 +aiohttp==3.11.4 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 aiozoneinfo==0.2.1 From 7dc2102545bd09299e90055a3d87b3fc1fa34a70 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 19 Nov 2024 09:11:47 +0100 Subject: [PATCH 0564/1070] Simplify FanEntity preset_mode shorthand attributes (#130930) --- homeassistant/components/fan/__init__.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index b1c2b748520c8..ed383202b2833 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -235,8 +235,8 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_current_direction: str | None = None _attr_oscillating: bool | None = None _attr_percentage: int | None - _attr_preset_mode: str | None - _attr_preset_modes: list[str] | None + _attr_preset_mode: str | None = None + _attr_preset_modes: list[str] | None = None _attr_speed_count: int _attr_supported_features: FanEntityFeature = FanEntityFeature(0) @@ -538,9 +538,7 @@ def preset_mode(self) -> str | None: Requires FanEntityFeature.SET_SPEED. """ - if hasattr(self, "_attr_preset_mode"): - return self._attr_preset_mode - return None + return self._attr_preset_mode @cached_property def preset_modes(self) -> list[str] | None: @@ -548,9 +546,7 @@ def preset_modes(self) -> list[str] | None: Requires FanEntityFeature.SET_SPEED. """ - if hasattr(self, "_attr_preset_modes"): - return self._attr_preset_modes - return None + return self._attr_preset_modes # These can be removed if no deprecated constant are in this module anymore From b1260dc4ece301fc4b753428a839c733c6b66d25 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 19 Nov 2024 16:01:22 +0100 Subject: [PATCH 0565/1070] Update strings.json to fix typo in "Husqavarna" (#130954) --- homeassistant/components/husqvarna_automower/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 0f06e9c521e6b..50048d19c457c 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -314,7 +314,7 @@ "issues": { "deprecated_entity": { "title": "The Husqvarna Automower {entity_name} sensor is deprecated", - "description": "The Husqavarna Automower entity `{entity}` is deprecated and will be removed in a future release.\nYou can use the new returning state of the lawn mower entity instead.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`." + "description": "The Husqvarna Automower entity `{entity}` is deprecated and will be removed in a future release.\nYou can use the new returning state of the lawn mower entity instead.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`." } }, "services": { From 48703db78a9458739819d9062f1b7ec4f4931352 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 19 Nov 2024 16:51:48 +0100 Subject: [PATCH 0566/1070] Update strings.json to replace wrong "todo" with "lawn mower" (#130962) --- homeassistant/components/husqvarna_automower/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 50048d19c457c..149d53f8783b3 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -314,7 +314,7 @@ "issues": { "deprecated_entity": { "title": "The Husqvarna Automower {entity_name} sensor is deprecated", - "description": "The Husqvarna Automower entity `{entity}` is deprecated and will be removed in a future release.\nYou can use the new returning state of the lawn mower entity instead.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`." + "description": "The Husqvarna Automower entity `{entity}` is deprecated and will be removed in a future release.\nYou can use the new returning state of the lawn mower entity instead.\nPlease update your automations and scripts to replace the sensor entity with the newly added lawn mower entity.\nWhen you are done migrating you can disable `{entity}`." } }, "services": { From 42bba24247dab3af26d858a84e09613e09ed201d Mon Sep 17 00:00:00 2001 From: Marco Glauser Date: Tue, 19 Nov 2024 17:30:56 +0100 Subject: [PATCH 0567/1070] Add Hejhome Fingerbot (Tuya whitelabel) configuration (#130732) --- homeassistant/components/tuya/switch.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 77432c5b9a5f7..2b5e6fec4a6d0 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -528,6 +528,13 @@ translation_key="switch", ), ), + # Hejhome whitelabel Fingerbot + "znjxs": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + ), + ), # IoT Switch? # Note: Undocumented "tdq": ( From 3a8a8861d2eaebe64b99976d8a3c66ab77317880 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Nov 2024 12:03:13 -0600 Subject: [PATCH 0568/1070] Bump aiohttp to 3.11.5 (#130964) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f5d9492194159..93cba1378d6f2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.2.0 -aiohttp==3.11.4 +aiohttp==3.11.5 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index f2be95a697f59..40a31e52aec9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.4", + "aiohttp==3.11.5", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index ebb35d5fffc57..2aac427eec187 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.4 +aiohttp==3.11.5 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 aiozoneinfo==0.2.1 From 8b4983087bac984041f223adc311751b2a53df08 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Nov 2024 12:55:55 -0600 Subject: [PATCH 0569/1070] Bump PyJWT to 2.10.0 (#130907) * Bump PyJWT to 2.10.0 changelog: https://github.com/jpadilla/pyjwt/compare/2.9.0...2.10.0 * handle new keys * add test to verify all default options are checked for merge --- homeassistant/auth/jwt_wrapper.py | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/auth/test_jwt_wrapper.py | 6 ++++++ 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/auth/jwt_wrapper.py b/homeassistant/auth/jwt_wrapper.py index 3aa3ac63764e2..464df006f5f17 100644 --- a/homeassistant/auth/jwt_wrapper.py +++ b/homeassistant/auth/jwt_wrapper.py @@ -18,7 +18,7 @@ JWT_TOKEN_CACHE_SIZE = 16 MAX_TOKEN_SIZE = 8192 -_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss") +_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss", "sub", "jti") _VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | { "require": [] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 93cba1378d6f2..d2101b3af9b32 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -47,7 +47,7 @@ paho-mqtt==1.6.1 Pillow==11.0.0 propcache==0.2.0 psutil-home-assistant==0.0.1 -PyJWT==2.9.0 +PyJWT==2.10.0 pymicro-vad==1.0.1 PyNaCl==1.5.0 pyOpenSSL==24.2.1 diff --git a/pyproject.toml b/pyproject.toml index 40a31e52aec9a..7eb0a815506a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "ifaddr==0.2.0", "Jinja2==3.1.4", "lru-dict==1.3.0", - "PyJWT==2.9.0", + "PyJWT==2.10.0", # PyJWT has loose dependency. We want the latest one. "cryptography==43.0.1", "Pillow==11.0.0", diff --git a/requirements.txt b/requirements.txt index 2aac427eec187..4a9246bf37239 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 Jinja2==3.1.4 lru-dict==1.3.0 -PyJWT==2.9.0 +PyJWT==2.10.0 cryptography==43.0.1 Pillow==11.0.0 propcache==0.2.0 diff --git a/tests/auth/test_jwt_wrapper.py b/tests/auth/test_jwt_wrapper.py index 297d4dd5d7fcb..f9295a7791ce6 100644 --- a/tests/auth/test_jwt_wrapper.py +++ b/tests/auth/test_jwt_wrapper.py @@ -6,6 +6,12 @@ from homeassistant.auth import jwt_wrapper +async def test_all_default_options_are_in_verify_options() -> None: + """Test that all default options in _VERIFY_OPTIONS.""" + for option in jwt_wrapper._PyJWTWithVerify._get_default_options(): + assert option in jwt_wrapper._VERIFY_OPTIONS + + async def test_reject_access_token_with_impossible_large_size() -> None: """Test rejecting access tokens with impossible sizes.""" with pytest.raises(jwt.DecodeError): From 4adf1992e154d916d3fe092de2090e2282e3b1d9 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Tue, 19 Nov 2024 20:13:33 +0100 Subject: [PATCH 0570/1070] Bump aioairq to 0.4.3 (#130963) --- homeassistant/components/airq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 2b23928aba87d..1ae7da1487532 100644 --- a/homeassistant/components/airq/manifest.json +++ b/homeassistant/components/airq/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairq"], - "requirements": ["aioairq==0.3.2"] + "requirements": ["aioairq==0.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5d9e8b9b4f133..59f3da1ee899a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -176,7 +176,7 @@ aio-georss-gdacs==0.10 aioacaia==0.1.6 # homeassistant.components.airq -aioairq==0.3.2 +aioairq==0.4.3 # homeassistant.components.airzone_cloud aioairzone-cloud==0.6.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd6ecb1c30e0a..089b53ebd60ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aio-georss-gdacs==0.10 aioacaia==0.1.6 # homeassistant.components.airq -aioairq==0.3.2 +aioairq==0.4.3 # homeassistant.components.airzone_cloud aioairzone-cloud==0.6.10 From 71e8c79cadfb770b3e87c8796dc3b70965662f5a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 19 Nov 2024 20:19:09 +0100 Subject: [PATCH 0571/1070] Remove deprecated not used constants in switchbot (#130980) --- homeassistant/components/switchbot/const.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 19b264bd46f2f..b8cf4e8e1abf2 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -74,8 +74,3 @@ class SupportedModels(StrEnum): CONF_KEY_ID = "key_id" CONF_ENCRYPTION_KEY = "encryption_key" CONF_LOCK_NIGHTLATCH = "lock_force_nightlatch" - -# Deprecated config Entry Options to be removed in 2023.4 -CONF_TIME_BETWEEN_UPDATE_COMMAND = "update_time" -CONF_RETRY_TIMEOUT = "retry_timeout" -CONF_SCAN_TIMEOUT = "scan_timeout" From c5622df386d5bff09ebdeae67c82f384b6d75013 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 19 Nov 2024 20:19:30 +0100 Subject: [PATCH 0572/1070] Remove deprecated yaml import from dynalite (#130982) --- homeassistant/components/dynalite/__init__.py | 35 +--- .../components/dynalite/config_flow.py | 34 ---- homeassistant/components/dynalite/const.py | 1 - tests/components/dynalite/common.py | 4 +- tests/components/dynalite/test_bridge.py | 9 +- tests/components/dynalite/test_config_flow.py | 61 ++----- tests/components/dynalite/test_init.py | 169 +++--------------- tests/components/dynalite/test_panel.py | 22 +-- 8 files changed, 60 insertions(+), 275 deletions(-) diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 59b8e464bb0d1..7388c43cb89b1 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -4,21 +4,17 @@ import voluptuous as vol -from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -# Loading the config flow file will register the flow from .bridge import DynaliteBridge from .const import ( ATTR_AREA, ATTR_CHANNEL, ATTR_HOST, - CONF_BRIDGES, DOMAIN, LOGGER, PLATFORMS, @@ -27,41 +23,14 @@ ) from .convert_config import convert_config from .panel import async_register_dynalite_frontend -from .schema import BRIDGE_SCHEMA - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])} - ), - }, - ), - extra=vol.ALLOW_EXTRA, -) + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" - conf = config.get(DOMAIN, {}) - LOGGER.debug("Setting up dynalite component config = %s", conf) hass.data[DOMAIN] = {} - bridges = conf.get(CONF_BRIDGES, []) - - for bridge_conf in bridges: - host = bridge_conf[CONF_HOST] - LOGGER.debug("Starting config entry flow host=%s conf=%s", host, bridge_conf) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=bridge_conf, - ) - ) - async def dynalite_service(service_call: ServiceCall) -> None: data = service_call.data host = data.get(ATTR_HOST, "") diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index 928f7043a4985..4b111c25cc96b 100644 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -8,9 +8,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .bridge import DynaliteBridge from .const import DEFAULT_PORT, DOMAIN, LOGGER @@ -26,38 +24,6 @@ def __init__(self) -> None: """Initialize the Dynalite flow.""" self.host = None - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import a new bridge as a config entry.""" - LOGGER.debug("Starting async_step_import (deprecated) - %s", import_data) - # Raise an issue that this is deprecated and has been imported - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.12.0", - is_fixable=False, - is_persistent=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Dynalite", - }, - ) - - host = import_data[CONF_HOST] - # Check if host already exists - for entry in self._async_current_entries(): - if entry.data[CONF_HOST] == host: - self.hass.config_entries.async_update_entry( - entry, data=dict(import_data) - ) - return self.async_abort(reason="already_configured") - - # New entry - return await self._try_create(import_data) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py index c1cb1a0fb1bc7..4712b14bea3ee 100644 --- a/homeassistant/components/dynalite/const.py +++ b/homeassistant/components/dynalite/const.py @@ -16,7 +16,6 @@ ACTIVE_ON = "on" CONF_AREA = "area" CONF_AUTO_DISCOVER = "autodiscover" -CONF_BRIDGES = "bridges" CONF_CHANNEL = "channel" CONF_CHANNEL_COVER = "channel_cover" CONF_CLOSE_PRESET = "close" diff --git a/tests/components/dynalite/common.py b/tests/components/dynalite/common.py index 640b6b3e24f1d..2d48d7e7b4f87 100644 --- a/tests/components/dynalite/common.py +++ b/tests/components/dynalite/common.py @@ -5,7 +5,7 @@ from dynalite_devices_lib.dynalitebase import DynaliteBaseDevice from homeassistant.components import dynalite -from homeassistant.const import ATTR_SERVICE +from homeassistant.const import ATTR_SERVICE, CONF_HOST from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -34,7 +34,7 @@ async def get_entry_id_from_hass(hass: HomeAssistant) -> str: async def create_entity_from_device(hass: HomeAssistant, device: DynaliteBaseDevice): """Set up the component and platform and create a light based on the device provided.""" host = "1.2.3.4" - entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host}) + entry = MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host}) entry.add_to_hass(hass) with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices" diff --git a/tests/components/dynalite/test_bridge.py b/tests/components/dynalite/test_bridge.py index b0517b8903162..ed9296ae68581 100644 --- a/tests/components/dynalite/test_bridge.py +++ b/tests/components/dynalite/test_bridge.py @@ -17,6 +17,7 @@ ATTR_PACKET, ATTR_PRESET, ) +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -26,7 +27,7 @@ async def test_update_device(hass: HomeAssistant) -> None: """Test that update works.""" host = "1.2.3.4" - entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host}) + entry = MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host}) entry.add_to_hass(hass) with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices" @@ -56,7 +57,7 @@ async def test_update_device(hass: HomeAssistant) -> None: async def test_add_devices_then_register(hass: HomeAssistant) -> None: """Test that add_devices work.""" host = "1.2.3.4" - entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host}) + entry = MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host}) entry.add_to_hass(hass) with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices" @@ -91,7 +92,7 @@ async def test_add_devices_then_register(hass: HomeAssistant) -> None: async def test_register_then_add_devices(hass: HomeAssistant) -> None: """Test that add_devices work after register_add_entities.""" host = "1.2.3.4" - entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host}) + entry = MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host}) entry.add_to_hass(hass) with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices" @@ -120,7 +121,7 @@ async def test_register_then_add_devices(hass: HomeAssistant) -> None: async def test_notifications(hass: HomeAssistant) -> None: """Test that update works.""" host = "1.2.3.4" - entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host}) + entry = MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host}) entry.add_to_hass(hass) with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices" diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py index 8bb47fd67e3d6..20ee42d33b527 100644 --- a/tests/components/dynalite/test_config_flow.py +++ b/tests/components/dynalite/test_config_flow.py @@ -7,11 +7,9 @@ from homeassistant import config_entries from homeassistant.components import dynalite from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PORT -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -31,11 +29,8 @@ async def test_flow( exp_type, exp_result, exp_reason, - issue_registry: ir.IssueRegistry, ) -> None: """Run a flow with or without errors and return result.""" - issue = issue_registry.async_get_issue(dynalite.DOMAIN, "deprecated_yaml") - assert issue is None host = "1.2.3.4" with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", @@ -43,8 +38,8 @@ async def test_flow( ): result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={dynalite.CONF_HOST: host}, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: host}, ) await hass.async_block_till_done() assert result["type"] == exp_type @@ -52,51 +47,33 @@ async def test_flow( assert result["result"].state == exp_result if exp_reason: assert result["reason"] == exp_reason - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{dynalite.DOMAIN}" - ) - assert issue is not None - assert issue.issue_domain == dynalite.DOMAIN - assert issue.severity == ir.IssueSeverity.WARNING - - -async def test_deprecated( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Check that deprecation warning appears in caplog.""" - await async_setup_component( - hass, dynalite.DOMAIN, {dynalite.DOMAIN: {dynalite.CONF_HOST: "aaa"}} - ) - assert "The 'dynalite' option is deprecated" in caplog.text async def test_existing(hass: HomeAssistant) -> None: """Test when the entry exists with the same config.""" host = "1.2.3.4" - MockConfigEntry( - domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host} - ).add_to_hass(hass) + MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host}).add_to_hass(hass) with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", return_value=True, ): result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={dynalite.CONF_HOST: host}, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: host}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_existing_update(hass: HomeAssistant) -> None: +async def test_existing_abort_update(hass: HomeAssistant) -> None: """Test when the entry exists with a different config.""" host = "1.2.3.4" port1 = 7777 port2 = 8888 entry = MockConfigEntry( domain=dynalite.DOMAIN, - data={dynalite.CONF_HOST: host, CONF_PORT: port1}, + data={CONF_HOST: host, CONF_PORT: port1}, ) entry.add_to_hass(hass) with patch( @@ -109,12 +86,12 @@ async def test_existing_update(hass: HomeAssistant) -> None: assert mock_dyn_dev().configure.mock_calls[0][1][0]["port"] == port1 result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={dynalite.CONF_HOST: host, CONF_PORT: port2}, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: host, CONF_PORT: port2}, ) await hass.async_block_till_done() - assert mock_dyn_dev().configure.call_count == 2 - assert mock_dyn_dev().configure.mock_calls[1][1][0]["port"] == port2 + assert mock_dyn_dev().configure.call_count == 1 + assert mock_dyn_dev().configure.mock_calls[0][1][0]["port"] == port1 assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -123,17 +100,15 @@ async def test_two_entries(hass: HomeAssistant) -> None: """Test when two different entries exist with different hosts.""" host1 = "1.2.3.4" host2 = "5.6.7.8" - MockConfigEntry( - domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host1} - ).add_to_hass(hass) + MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host1}).add_to_hass(hass) with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", return_value=True, ): result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={dynalite.CONF_HOST: host2}, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: host2}, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].state is ConfigEntryState.LOADED @@ -172,9 +147,7 @@ async def test_setup_user(hass: HomeAssistant) -> None: async def test_setup_user_existing_host(hass: HomeAssistant) -> None: """Test that when we setup a host that is defined, we get an error.""" host = "3.4.5.6" - MockConfigEntry( - domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host} - ).add_to_hass(hass) + MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host}).add_to_hass(hass) result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py index 2c15c41e40b47..4bf4eb53ad663 100644 --- a/tests/components/dynalite/test_init.py +++ b/tests/components/dynalite/test_init.py @@ -6,7 +6,7 @@ from voluptuous import MultipleInvalid import homeassistant.components.dynalite.const as dynalite -from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_ROOM +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -20,71 +20,18 @@ async def test_empty_config(hass: HomeAssistant) -> None: assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 0 -async def test_async_setup(hass: HomeAssistant) -> None: - """Test a successful setup with all of the different options.""" - with patch( - "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", - return_value=True, - ): - assert await async_setup_component( - hass, - dynalite.DOMAIN, - { - dynalite.DOMAIN: { - dynalite.CONF_BRIDGES: [ - { - CONF_HOST: "1.2.3.4", - CONF_PORT: 1234, - dynalite.CONF_AUTO_DISCOVER: True, - dynalite.CONF_POLL_TIMER: 5.5, - dynalite.CONF_AREA: { - "1": { - CONF_NAME: "Name1", - dynalite.CONF_CHANNEL: {"4": {}}, - dynalite.CONF_PRESET: {"7": {}}, - dynalite.CONF_NO_DEFAULT: True, - }, - "2": {CONF_NAME: "Name2"}, - "3": { - CONF_NAME: "Name3", - dynalite.CONF_TEMPLATE: CONF_ROOM, - }, - "4": { - CONF_NAME: "Name4", - dynalite.CONF_TEMPLATE: dynalite.CONF_TIME_COVER, - }, - }, - CONF_DEFAULT: {dynalite.CONF_FADE: 2.3}, - dynalite.CONF_ACTIVE: dynalite.ACTIVE_INIT, - dynalite.CONF_PRESET: { - "5": {CONF_NAME: "pres5", dynalite.CONF_FADE: 4.5} - }, - dynalite.CONF_TEMPLATE: { - CONF_ROOM: { - dynalite.CONF_ROOM_ON: 6, - dynalite.CONF_ROOM_OFF: 7, - }, - dynalite.CONF_TIME_COVER: { - dynalite.CONF_OPEN_PRESET: 8, - dynalite.CONF_CLOSE_PRESET: 9, - dynalite.CONF_STOP_PRESET: 10, - dynalite.CONF_CHANNEL_COVER: 3, - dynalite.CONF_DURATION: 2.2, - dynalite.CONF_TILT_TIME: 3.3, - dynalite.CONF_DEVICE_CLASS: "awning", - }, - }, - } - ] - } - }, - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 1 - - async def test_service_request_area_preset(hass: HomeAssistant) -> None: """Test requesting and area preset via service call.""" + entry = MockConfigEntry( + domain=dynalite.DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + ) + entry2 = MockConfigEntry( + domain=dynalite.DOMAIN, + data={CONF_HOST: "5.6.7.8"}, + ) + entry.add_to_hass(hass) + entry2.add_to_hass(hass) with ( patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", @@ -95,20 +42,8 @@ async def test_service_request_area_preset(hass: HomeAssistant) -> None: return_value=True, ) as mock_req_area_pres, ): - assert await async_setup_component( - hass, - dynalite.DOMAIN, - { - dynalite.DOMAIN: { - dynalite.CONF_BRIDGES: [ - {CONF_HOST: "1.2.3.4"}, - {CONF_HOST: "5.6.7.8"}, - ] - } - }, - ) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 2 await hass.services.async_call( dynalite.DOMAIN, "request_area_preset", @@ -160,6 +95,16 @@ async def test_service_request_area_preset(hass: HomeAssistant) -> None: async def test_service_request_channel_level(hass: HomeAssistant) -> None: """Test requesting the level of a channel via service call.""" + entry = MockConfigEntry( + domain=dynalite.DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + ) + entry2 = MockConfigEntry( + domain=dynalite.DOMAIN, + data={CONF_HOST: "5.6.7.8"}, + ) + entry.add_to_hass(hass) + entry2.add_to_hass(hass) with ( patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", @@ -170,21 +115,7 @@ async def test_service_request_channel_level(hass: HomeAssistant) -> None: return_value=True, ) as mock_req_chan_lvl, ): - assert await async_setup_component( - hass, - dynalite.DOMAIN, - { - dynalite.DOMAIN: { - dynalite.CONF_BRIDGES: [ - { - CONF_HOST: "1.2.3.4", - dynalite.CONF_AREA: {"7": {CONF_NAME: "test"}}, - }, - {CONF_HOST: "5.6.7.8"}, - ] - } - }, - ) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 2 await hass.services.async_call( @@ -212,60 +143,6 @@ async def test_service_request_channel_level(hass: HomeAssistant) -> None: assert mock_req_chan_lvl.mock_calls == [call(4, 5), call(4, 5)] -async def test_async_setup_bad_config1(hass: HomeAssistant) -> None: - """Test a successful with bad config on templates.""" - with patch( - "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", - return_value=True, - ): - assert not await async_setup_component( - hass, - dynalite.DOMAIN, - { - dynalite.DOMAIN: { - dynalite.CONF_BRIDGES: [ - { - CONF_HOST: "1.2.3.4", - dynalite.CONF_AREA: { - "1": { - dynalite.CONF_TEMPLATE: dynalite.CONF_TIME_COVER, - CONF_NAME: "Name", - dynalite.CONF_ROOM_ON: 7, - } - }, - } - ] - } - }, - ) - await hass.async_block_till_done() - - -async def test_async_setup_bad_config2(hass: HomeAssistant) -> None: - """Test a successful with bad config on numbers.""" - host = "1.2.3.4" - with patch( - "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", - return_value=True, - ): - assert not await async_setup_component( - hass, - dynalite.DOMAIN, - { - dynalite.DOMAIN: { - dynalite.CONF_BRIDGES: [ - { - CONF_HOST: host, - dynalite.CONF_AREA: {"WRONG": {CONF_NAME: "Name"}}, - } - ] - } - }, - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 0 - - async def test_unload_entry(hass: HomeAssistant) -> None: """Test being able to unload an entry.""" host = "1.2.3.4" diff --git a/tests/components/dynalite/test_panel.py b/tests/components/dynalite/test_panel.py index 97752142f0c60..a13b27e7567d1 100644 --- a/tests/components/dynalite/test_panel.py +++ b/tests/components/dynalite/test_panel.py @@ -4,7 +4,7 @@ from homeassistant.components import dynalite from homeassistant.components.cover import DEVICE_CLASSES -from homeassistant.const import CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -20,7 +20,7 @@ async def test_get_config( entry = MockConfigEntry( domain=dynalite.DOMAIN, - data={dynalite.CONF_HOST: host, CONF_PORT: port}, + data={CONF_HOST: host, CONF_PORT: port}, ) entry.add_to_hass(hass) with patch( @@ -44,7 +44,7 @@ async def test_get_config( result = msg["result"] entry_id = entry.entry_id assert result == { - "config": {entry_id: {dynalite.CONF_HOST: host, CONF_PORT: port}}, + "config": {entry_id: {CONF_HOST: host, CONF_PORT: port}}, "default": { "DEFAULT_NAME": dynalite.const.DEFAULT_NAME, "DEFAULT_PORT": dynalite.const.DEFAULT_PORT, @@ -66,7 +66,7 @@ async def test_save_config( entry1 = MockConfigEntry( domain=dynalite.DOMAIN, - data={dynalite.CONF_HOST: host1, CONF_PORT: port1}, + data={CONF_HOST: host1, CONF_PORT: port1}, ) entry1.add_to_hass(hass) with patch( @@ -77,7 +77,7 @@ async def test_save_config( await hass.async_block_till_done() entry2 = MockConfigEntry( domain=dynalite.DOMAIN, - data={dynalite.CONF_HOST: host2, CONF_PORT: port2}, + data={CONF_HOST: host2, CONF_PORT: port2}, ) entry2.add_to_hass(hass) with patch( @@ -94,7 +94,7 @@ async def test_save_config( "id": 24, "type": "dynalite/save-config", "entry_id": entry2.entry_id, - "config": {dynalite.CONF_HOST: host3, CONF_PORT: port3}, + "config": {CONF_HOST: host3, CONF_PORT: port3}, } ) @@ -103,9 +103,9 @@ async def test_save_config( assert msg["result"] == {} existing_entry = hass.config_entries.async_get_entry(entry1.entry_id) - assert existing_entry.data == {dynalite.CONF_HOST: host1, CONF_PORT: port1} + assert existing_entry.data == {CONF_HOST: host1, CONF_PORT: port1} modified_entry = hass.config_entries.async_get_entry(entry2.entry_id) - assert modified_entry.data[dynalite.CONF_HOST] == host3 + assert modified_entry.data[CONF_HOST] == host3 assert modified_entry.data[CONF_PORT] == port3 @@ -120,7 +120,7 @@ async def test_save_config_invalid_entry( entry = MockConfigEntry( domain=dynalite.DOMAIN, - data={dynalite.CONF_HOST: host1, CONF_PORT: port1}, + data={CONF_HOST: host1, CONF_PORT: port1}, ) entry.add_to_hass(hass) with patch( @@ -136,7 +136,7 @@ async def test_save_config_invalid_entry( "id": 24, "type": "dynalite/save-config", "entry_id": "junk", - "config": {dynalite.CONF_HOST: host2, CONF_PORT: port2}, + "config": {CONF_HOST: host2, CONF_PORT: port2}, } ) @@ -145,4 +145,4 @@ async def test_save_config_invalid_entry( assert msg["result"] == {"error": True} existing_entry = hass.config_entries.async_get_entry(entry.entry_id) - assert existing_entry.data == {dynalite.CONF_HOST: host1, CONF_PORT: port1} + assert existing_entry.data == {CONF_HOST: host1, CONF_PORT: port1} From 0199418ba9d2c86c69c4a9e025b6d45eafa6b00d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 19 Nov 2024 20:19:53 +0100 Subject: [PATCH 0573/1070] Add missing catholic category in workday (#130983) --- homeassistant/components/workday/strings.json | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index f3b966e28ea5e..e74dc0160d9c4 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -86,18 +86,19 @@ "options": { "armed_forces": "Armed forces", "bank": "Bank", + "catholic": "Catholic", + "chinese": "Chinese", + "christian": "Christian", "government": "Government", "half_day": "Half day", + "hebrew": "Hebrew", + "hindu": "Hindu", + "islamic": "Islamic", "optional": "Optional", "public": "Public", "school": "School", "unofficial": "Unofficial", - "workday": "Workday", - "chinese": "Chinese", - "christian": "Christian", - "hebrew": "Hebrew", - "hindu": "Hindu", - "islamic": "Islamic" + "workday": "Workday" } }, "days": { From c4568e6632c77990b24d1cea64300a97a6192359 Mon Sep 17 00:00:00 2001 From: Steve Venzerul Date: Tue, 19 Nov 2024 14:25:12 -0500 Subject: [PATCH 0574/1070] Add missing translations and icons for ZHA Sinope devices (#130826) Co-authored-by: TheJulianJES --- homeassistant/components/zha/icons.json | 9 +++++++++ homeassistant/components/zha/strings.json | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/homeassistant/components/zha/icons.json b/homeassistant/components/zha/icons.json index 5b3b85ced3995..6ba4aab18ab8e 100644 --- a/homeassistant/components/zha/icons.json +++ b/homeassistant/components/zha/icons.json @@ -118,6 +118,12 @@ }, "exercise_day_of_week": { "default": "mdi:wrench-clock" + }, + "off_led_color": { + "default": "mdi:palette-outline" + }, + "on_led_color": { + "default": "mdi:palette" } }, "sensor": { @@ -206,6 +212,9 @@ }, "use_load_balancing": { "default": "mdi:scale-balance" + }, + "double_up_full": { + "default": "mdi:gesture-double-tap" } } }, diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index d0505bf2460a9..b77eaa21faba3 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -791,6 +791,12 @@ }, "valve_countdown_2": { "name": "Irrigation time 2" + }, + "on_led_intensity": { + "name": "On LED intensity" + }, + "off_led_intensity": { + "name": "Off LED intensity" } }, "select": { @@ -886,6 +892,12 @@ }, "weather_delay": { "name": "Weather delay" + }, + "on_led_color": { + "name": "On LED color" + }, + "off_led_color": { + "name": "Off LED color" } }, "sensor": { @@ -1193,6 +1205,9 @@ }, "valve_on_off_2": { "name": "Valve 2" + }, + "double_up_full": { + "name": "Double tap on - full" } } } From 3cb8dedacce292cef8a0fc84cee24f5b745fdf07 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 19 Nov 2024 20:53:11 +0100 Subject: [PATCH 0575/1070] Bump holidays to 0.61 (#130984) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 8c64f492d42b2..a3c0a4514d3d7 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.60", "babel==2.15.0"] + "requirements": ["holidays==0.61", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index b02db73472967..ea08bfe17176e 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.60"] + "requirements": ["holidays==0.61"] } diff --git a/requirements_all.txt b/requirements_all.txt index 59f3da1ee899a..c8023c1fc442f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.60 +holidays==0.61 # homeassistant.components.frontend home-assistant-frontend==20241106.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 089b53ebd60ca..b69be71a1ce23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -953,7 +953,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.60 +holidays==0.61 # homeassistant.components.frontend home-assistant-frontend==20241106.2 From e53d97db6f49c4d06f6034da2700d98d67608af6 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Nov 2024 21:17:38 +0100 Subject: [PATCH 0576/1070] Add sensor test to sabnzbd (#130988) --- tests/components/sabnzbd/conftest.py | 45 ++ tests/components/sabnzbd/fixtures/queue.json | 39 ++ .../sabnzbd/snapshots/test_sensor.ambr | 576 ++++++++++++++++++ tests/components/sabnzbd/test_sensor.py | 20 + 4 files changed, 680 insertions(+) create mode 100644 tests/components/sabnzbd/fixtures/queue.json create mode 100644 tests/components/sabnzbd/snapshots/test_sensor.ambr create mode 100644 tests/components/sabnzbd/test_sensor.py diff --git a/tests/components/sabnzbd/conftest.py b/tests/components/sabnzbd/conftest.py index b5450e5134fa8..68b17bbfeacc6 100644 --- a/tests/components/sabnzbd/conftest.py +++ b/tests/components/sabnzbd/conftest.py @@ -5,6 +5,13 @@ import pytest +from homeassistant.components.sabnzbd import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_json_object_fixture + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +20,41 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.sabnzbd.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture(name="sabnzbd") +def mock_sabnzbd() -> Generator[AsyncMock]: + """Mock the Sabnzbd API.""" + with patch( + "homeassistant.components.sabnzbd.sab.SabnzbdApi", autospec=True + ) as mock_sabnzbd: + mock = mock_sabnzbd.return_value + mock.return_value.check_available = True + mock.queue = load_json_object_fixture("queue.json", DOMAIN) + yield mock + + +@pytest.fixture(name="config_entry") +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a MockConfigEntry for testing.""" + return MockConfigEntry( + domain=DOMAIN, + title="Sabnzbd", + entry_id="01JD2YVVPBC62D620DGYNG2R8H", + data={ + CONF_NAME: "Sabnzbd", + CONF_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0", + CONF_URL: "http://localhost:8080", + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, sabnzbd: AsyncMock +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() diff --git a/tests/components/sabnzbd/fixtures/queue.json b/tests/components/sabnzbd/fixtures/queue.json new file mode 100644 index 0000000000000..342500aea394f --- /dev/null +++ b/tests/components/sabnzbd/fixtures/queue.json @@ -0,0 +1,39 @@ +{ + "total_size": 1638.4, + "month_size": 38.8, + "week_size": 9.4, + "day_size": 9.4, + "version": "4.3.3", + "paused": true, + "pause_int": "0", + "paused_all": false, + "diskspace1": "444.95", + "diskspace2": "3127.88", + "diskspace1_norm": "445.0 G", + "diskspace2_norm": "3.1 T", + "diskspacetotal1": "465.76", + "diskspacetotal2": "7448.42", + "speedlimit": "85", + "speedlimit_abs": "22282240", + "have_warnings": "0", + "finishaction": null, + "quota": "0 ", + "have_quota": false, + "left_quota": "0 ", + "cache_art": "0", + "cache_size": "0 B", + "kbpersec": "0.00", + "speed": "0 ", + "mbleft": "0.00", + "mb": "0.00", + "sizeleft": "0 B", + "size": "0 B", + "noofslots_total": 0, + "noofslots": 0, + "start": 0, + "limit": 10, + "finish": 10, + "status": "Paused", + "timeleft": "0:00:00", + "slots": [] +} diff --git a/tests/components/sabnzbd/snapshots/test_sensor.ambr b/tests/components/sabnzbd/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..f339906d038d4 --- /dev/null +++ b/tests/components/sabnzbd/snapshots/test_sensor.ambr @@ -0,0 +1,576 @@ +# serializer version: 1 +# name: test_sensor[sensor.sabnzbd_daily_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sabnzbd_daily_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daily total', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_total', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_day_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sabnzbd_daily_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'SABnzbd Daily total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sabnzbd_daily_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.4', + }) +# --- +# name: test_sensor[sensor.sabnzbd_free_disk_space-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sabnzbd_free_disk_space', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Free disk space', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'free_disk_space', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_diskspace1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sabnzbd_free_disk_space-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'SABnzbd Free disk space', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sabnzbd_free_disk_space', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '444.95', + }) +# --- +# name: test_sensor[sensor.sabnzbd_left_to_download-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sabnzbd_left_to_download', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Left to download', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'left', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_mbleft', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sabnzbd_left_to_download-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'SABnzbd Left to download', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sabnzbd_left_to_download', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.00', + }) +# --- +# name: test_sensor[sensor.sabnzbd_monthly_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sabnzbd_monthly_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monthly total', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monthly_total', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_month_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sabnzbd_monthly_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'SABnzbd Monthly total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sabnzbd_monthly_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.8', + }) +# --- +# name: test_sensor[sensor.sabnzbd_overall_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sabnzbd_overall_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overall total', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overall_total', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_total_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sabnzbd_overall_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'SABnzbd Overall total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sabnzbd_overall_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1638.4', + }) +# --- +# name: test_sensor[sensor.sabnzbd_queue-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sabnzbd_queue', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Queue', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'queue', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_mb', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sabnzbd_queue-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'SABnzbd Queue', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sabnzbd_queue', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.00', + }) +# --- +# name: test_sensor[sensor.sabnzbd_queue_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sabnzbd_queue_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Queue count', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'queue_count', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_noofslots_total', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.sabnzbd_queue_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SABnzbd Queue count', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.sabnzbd_queue_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.sabnzbd_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sabnzbd_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Speed', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'speed', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_kbpersec', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sabnzbd_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'SABnzbd Speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sabnzbd_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[sensor.sabnzbd_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sabnzbd_status', + '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': 'Status', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.sabnzbd_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SABnzbd Status', + }), + 'context': , + 'entity_id': 'sensor.sabnzbd_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Paused', + }) +# --- +# name: test_sensor[sensor.sabnzbd_total_disk_space-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sabnzbd_total_disk_space', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total disk space', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_disk_space', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_diskspacetotal1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sabnzbd_total_disk_space-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'SABnzbd Total disk space', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sabnzbd_total_disk_space', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '465.76', + }) +# --- +# name: test_sensor[sensor.sabnzbd_weekly_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sabnzbd_weekly_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weekly total', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'weekly_total', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_week_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sabnzbd_weekly_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'SABnzbd Weekly total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sabnzbd_weekly_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.4', + }) +# --- diff --git a/tests/components/sabnzbd/test_sensor.py b/tests/components/sabnzbd/test_sensor.py new file mode 100644 index 0000000000000..aade644072c59 --- /dev/null +++ b/tests/components/sabnzbd/test_sensor.py @@ -0,0 +1,20 @@ +"""Sensor tests for the Sabnzbd component.""" + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_integration") +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor setup.""" + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) From b9ff8ebe3a950dc4e79dca041ec79b8a69cfc30c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Nov 2024 21:23:22 +0100 Subject: [PATCH 0577/1070] Pass sabnzdb config entry explicitly to coordinator (#130990) Pass config entry explicitly to coordinator --- homeassistant/components/sabnzbd/__init__.py | 2 +- homeassistant/components/sabnzbd/coordinator.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index a827e9a36a45c..3182e628a6bc3 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -174,7 +174,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await migrate_unique_id(hass, entry) update_device_identifiers(hass, entry) - coordinator = SabnzbdUpdateCoordinator(hass, sab_api) + coordinator = SabnzbdUpdateCoordinator(hass, entry, sab_api) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/sabnzbd/coordinator.py b/homeassistant/components/sabnzbd/coordinator.py index 5db59bb584bd9..0c69a81a7f592 100644 --- a/homeassistant/components/sabnzbd/coordinator.py +++ b/homeassistant/components/sabnzbd/coordinator.py @@ -6,6 +6,7 @@ from pysabnzbd import SabnzbdApi, SabnzbdApiException +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,6 +19,7 @@ class SabnzbdUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, sab_api: SabnzbdApi, ) -> None: """Initialize the SABnzbd update coordinator.""" @@ -26,6 +28,7 @@ def __init__( super().__init__( hass, _LOGGER, + config_entry=config_entry, name="SABnzbd", update_interval=timedelta(seconds=30), ) From 81fc83398b3c0567950e8fc3e3585b3ad8b32d61 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Nov 2024 22:02:36 +0100 Subject: [PATCH 0578/1070] Use HassKey in ping (#130973) --- homeassistant/components/ping/__init__.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index f4a04caae5b53..4b03e5e4407a7 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass import logging from icmplib import SocketPermissionError, async_ping @@ -12,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import CONF_PING_COUNT, DOMAIN from .coordinator import PingUpdateCoordinator @@ -21,13 +21,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR] - - -@dataclass(slots=True) -class PingDomainData: - """Dataclass to store privileged status.""" - - privileged: bool | None +DATA_PRIVILEGED_KEY: HassKey[bool | None] = HassKey(DOMAIN) type PingConfigEntry = ConfigEntry[PingUpdateCoordinator] @@ -35,29 +29,25 @@ class PingDomainData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ping integration.""" - - hass.data[DOMAIN] = PingDomainData( - privileged=await _can_use_icmp_lib_with_privilege(), - ) + hass.data[DATA_PRIVILEGED_KEY] = await _can_use_icmp_lib_with_privilege() return True async def async_setup_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool: """Set up Ping (ICMP) from a config entry.""" - - data: PingDomainData = hass.data[DOMAIN] + privileged = hass.data[DATA_PRIVILEGED_KEY] host: str = entry.options[CONF_HOST] count: int = int(entry.options[CONF_PING_COUNT]) ping_cls: type[PingDataICMPLib | PingDataSubProcess] - if data.privileged is None: + if privileged is None: ping_cls = PingDataSubProcess else: ping_cls = PingDataICMPLib coordinator = PingUpdateCoordinator( - hass=hass, ping=ping_cls(hass, host, count, data.privileged) + hass=hass, ping=ping_cls(hass, host, count, privileged) ) await coordinator.async_config_entry_first_refresh() From 6f0139389f12b1cc879e1c58592cdb28510b644e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Tue, 19 Nov 2024 22:04:00 +0100 Subject: [PATCH 0579/1070] Bump plugwise to v1.5.1 (#130966) --- homeassistant/components/plugwise/climate.py | 4 +++- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fixtures/anna_heatpump_heating/all_data.json | 2 +- .../plugwise/fixtures/legacy_anna/all_data.json | 2 +- .../plugwise/fixtures/m_adam_cooling/all_data.json | 4 ++-- .../plugwise/fixtures/m_adam_heating/all_data.json | 4 ++-- .../plugwise/fixtures/m_adam_jip/all_data.json | 8 ++++---- .../m_adam_multiple_devices_per_zone/all_data.json | 10 +++++----- .../fixtures/m_anna_heatpump_cooling/all_data.json | 2 +- .../fixtures/m_anna_heatpump_idle/all_data.json | 2 +- .../plugwise/snapshots/test_diagnostics.ambr | 10 +++++----- 13 files changed, 28 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 7b0fe35835dc0..46b4bff250aac 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -143,7 +143,9 @@ def target_temperature_low(self) -> float: @property def hvac_mode(self) -> HVACMode: """Return HVAC operation ie. auto, cool, heat, heat_cool, or off mode.""" - if (mode := self.device.get("mode")) is None or mode not in self.hvac_modes: + if ( + mode := self.device.get("climate_mode") + ) is None or mode not in self.hvac_modes: return HVACMode.HEAT return HVACMode(mode) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index dbbad15c0dca7..bf29bd53b41aa 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], - "requirements": ["plugwise==1.5.0"], + "requirements": ["plugwise==1.5.1"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index c8023c1fc442f..e729086078309 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1622,7 +1622,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.5.0 +plugwise==1.5.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b69be71a1ce23..fb4ba016224a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1329,7 +1329,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.5.0 +plugwise==1.5.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index b767f5531f245..5fc2a114b2f78 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -61,11 +61,11 @@ "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", "available_schedules": ["standaard", "off"], + "climate_mode": "auto", "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "mode": "auto", "model": "ThermoTouch", "name": "Anna", "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], diff --git a/tests/components/plugwise/fixtures/legacy_anna/all_data.json b/tests/components/plugwise/fixtures/legacy_anna/all_data.json index 1eca4e285ccbf..c5ee4b2b103e2 100644 --- a/tests/components/plugwise/fixtures/legacy_anna/all_data.json +++ b/tests/components/plugwise/fixtures/legacy_anna/all_data.json @@ -36,11 +36,11 @@ }, "0d266432d64443e283b5d708ae98b455": { "active_preset": "home", + "climate_mode": "heat", "dev_class": "thermostat", "firmware": "2017-03-13T11:54:58+01:00", "hardware": "6539-1301-500", "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "mode": "heat", "model": "ThermoTouch", "name": "Anna", "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 166b13b84ffc3..6edd4c5901d70 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -63,10 +63,10 @@ "Weekschema", "off" ], + "climate_mode": "cool", "control_state": "cooling", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", - "mode": "cool", "model": "ThermoTouch", "model_id": "143.1", "name": "Anna", @@ -125,12 +125,12 @@ "binary_sensors": { "low_battery": true }, + "climate_mode": "auto", "control_state": "preheating", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", "location": "f871b8c4d63549319221e294e4f88074", - "mode": "auto", "model": "Lisa", "model_id": "158-01", "name": "Lisa Badkamer", diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index 61935f1306adf..7a3fb6e3b5c58 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -68,10 +68,10 @@ "Weekschema", "off" ], + "climate_mode": "heat", "control_state": "preheating", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", - "mode": "heat", "model": "ThermoTouch", "model_id": "143.1", "name": "Anna", @@ -124,12 +124,12 @@ "binary_sensors": { "low_battery": true }, + "climate_mode": "auto", "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", "location": "f871b8c4d63549319221e294e4f88074", - "mode": "auto", "model": "Lisa", "model_id": "158-01", "name": "Lisa Badkamer", diff --git a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json index ec2095648b889..61d6baaf88fbf 100644 --- a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json @@ -6,12 +6,12 @@ "binary_sensors": { "low_battery": false }, + "climate_mode": "off", "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", "location": "06aecb3d00354375924f50c47af36bd2", - "mode": "off", "model": "Lisa", "model_id": "158-01", "name": "Slaapkamer", @@ -107,12 +107,12 @@ "binary_sensors": { "low_battery": false }, + "climate_mode": "heat", "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", "location": "d27aede973b54be484f6842d1b2802ad", - "mode": "heat", "model": "Lisa", "model_id": "158-01", "name": "Kinderkamer", @@ -167,12 +167,12 @@ "binary_sensors": { "low_battery": false }, + "climate_mode": "heat", "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", "location": "d58fec52899f4f1c92e4f8fad6d8c48c", - "mode": "heat", "model": "Lisa", "model_id": "158-01", "name": "Logeerkamer", @@ -285,12 +285,12 @@ "binary_sensors": { "low_battery": false }, + "climate_mode": "heat", "control_state": "off", "dev_class": "zone_thermometer", "firmware": "2020-09-01T02:00:00+02:00", "hardware": "1", "location": "13228dab8ce04617af318a2888b3c548", - "mode": "heat", "model": "Jip", "model_id": "168-01", "name": "Woonkamer", diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json index a182b1ac8dd04..7c3962b832fbc 100644 --- a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json @@ -126,11 +126,11 @@ "binary_sensors": { "low_battery": false }, + "climate_mode": "auto", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", "location": "82fa13f017d240daa0d0ea1775420f24", - "mode": "auto", "model": "Lisa", "model_id": "158-01", "name": "Zone Thermostat Jessie", @@ -277,11 +277,11 @@ "binary_sensors": { "low_battery": false }, + "climate_mode": "auto", "dev_class": "zone_thermostat", "firmware": "2016-08-02T02:00:00+02:00", "hardware": "255", "location": "c50f167537524366a5af7aa3942feb1e", - "mode": "auto", "model": "Lisa", "model_id": "158-01", "name": "Zone Lisa WK", @@ -370,11 +370,11 @@ "binary_sensors": { "low_battery": false }, + "climate_mode": "heat", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", "location": "12493538af164a409c6a1c79e38afe1c", - "mode": "heat", "model": "Lisa", "model_id": "158-01", "name": "Zone Lisa Bios", @@ -406,11 +406,11 @@ "binary_sensors": { "low_battery": false }, + "climate_mode": "heat", "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", "location": "446ac08dd04d4eff8ac57489757b7314", - "mode": "heat", "model": "Tom/Floor", "model_id": "106-03", "name": "CV Kraan Garage", @@ -451,11 +451,11 @@ "binary_sensors": { "low_battery": false }, + "climate_mode": "auto", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", "location": "08963fec7c53423ca5680aa4cb502c63", - "mode": "auto", "model": "Lisa", "model_id": "158-01", "name": "Zone Thermostat Badkamer", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index 05f5e0ffa466a..74f20379d68d5 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -61,11 +61,11 @@ "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", "available_schedules": ["standaard", "off"], + "climate_mode": "auto", "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "mode": "auto", "model": "ThermoTouch", "name": "Anna", "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index 327a87f940902..3b1e9bf8caca1 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -61,11 +61,11 @@ "3cb70739631c4d17a86b8b12e8a5161b": { "active_preset": "home", "available_schedules": ["standaard", "off"], + "climate_mode": "auto", "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "mode": "auto", "model": "ThermoTouch", "name": "Anna", "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index d187e0355bf7d..2a8223a256833 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -128,11 +128,11 @@ 'binary_sensors': dict({ 'low_battery': False, }), + 'climate_mode': 'auto', 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', 'location': '82fa13f017d240daa0d0ea1775420f24', - 'mode': 'auto', 'model': 'Lisa', 'model_id': '158-01', 'name': 'Zone Thermostat Jessie', @@ -285,11 +285,11 @@ 'binary_sensors': dict({ 'low_battery': False, }), + 'climate_mode': 'auto', 'dev_class': 'zone_thermostat', 'firmware': '2016-08-02T02:00:00+02:00', 'hardware': '255', 'location': 'c50f167537524366a5af7aa3942feb1e', - 'mode': 'auto', 'model': 'Lisa', 'model_id': '158-01', 'name': 'Zone Lisa WK', @@ -384,11 +384,11 @@ 'binary_sensors': dict({ 'low_battery': False, }), + 'climate_mode': 'heat', 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', 'location': '12493538af164a409c6a1c79e38afe1c', - 'mode': 'heat', 'model': 'Lisa', 'model_id': '158-01', 'name': 'Zone Lisa Bios', @@ -426,11 +426,11 @@ 'binary_sensors': dict({ 'low_battery': False, }), + 'climate_mode': 'heat', 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', 'location': '446ac08dd04d4eff8ac57489757b7314', - 'mode': 'heat', 'model': 'Tom/Floor', 'model_id': '106-03', 'name': 'CV Kraan Garage', @@ -477,11 +477,11 @@ 'binary_sensors': dict({ 'low_battery': False, }), + 'climate_mode': 'auto', 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', 'location': '08963fec7c53423ca5680aa4cb502c63', - 'mode': 'auto', 'model': 'Lisa', 'model_id': '158-01', 'name': 'Zone Thermostat Badkamer', From c68cadad7a5d4a792f7ed5304d0bef53b0915703 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 19 Nov 2024 23:06:57 +0200 Subject: [PATCH 0580/1070] Improve precision of HSV color conversion (#130880) --- homeassistant/util/color.py | 2 +- tests/components/abode/test_light.py | 2 +- .../color_extractor/test_service.py | 2 +- .../deconz/snapshots/test_light.ambr | 30 +++++++++---------- tests/components/deconz/test_light.py | 4 +-- tests/components/demo/test_light.py | 4 +-- .../elgato/snapshots/test_light.ambr | 18 +++++------ tests/components/esphome/test_light.py | 8 ++--- tests/components/flux_led/test_light.py | 6 ++-- .../snapshots/test_init.ambr | 18 +++++------ tests/components/lifx/test_light.py | 4 +-- tests/components/light/test_init.py | 4 +-- .../matter/snapshots/test_light.ambr | 12 ++++---- tests/components/mqtt/test_light.py | 4 +-- tests/components/mqtt/test_light_json.py | 26 ++++++++-------- tests/components/mqtt/test_light_template.py | 12 ++++---- tests/components/vera/test_light.py | 6 ++-- tests/components/yeelight/test_light.py | 28 ++++++++--------- tests/components/zerproc/test_light.py | 12 ++++---- tests/util/test_color.py | 2 +- 20 files changed, 102 insertions(+), 102 deletions(-) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 0745bc96dfb34..18f8182650b71 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -377,7 +377,7 @@ def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> tuple[int, int, int]: Val is scaled 0-100 """ fRGB = colorsys.hsv_to_rgb(iH / 360, iS / 100, iV / 100) - return (int(fRGB[0] * 255), int(fRGB[1] * 255), int(fRGB[2] * 255)) + return (round(fRGB[0] * 255), round(fRGB[1] * 255), round(fRGB[2] * 255)) def color_hs_to_RGB(iH: float, iS: float) -> tuple[int, int, int]: diff --git a/tests/components/abode/test_light.py b/tests/components/abode/test_light.py index fc9000a39f846..d556a20fa906e 100644 --- a/tests/components/abode/test_light.py +++ b/tests/components/abode/test_light.py @@ -45,7 +45,7 @@ async def test_attributes(hass: HomeAssistant) -> None: state = hass.states.get(DEVICE_ID) assert state.state == STATE_ON assert state.attributes.get(ATTR_BRIGHTNESS) == 204 - assert state.attributes.get(ATTR_RGB_COLOR) == (0, 63, 255) + assert state.attributes.get(ATTR_RGB_COLOR) == (0, 64, 255) assert state.attributes.get(ATTR_COLOR_TEMP) is None assert state.attributes.get(ATTR_DEVICE_ID) == "ZB:db5b1a" assert not state.attributes.get("battery_low") diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index 7b603420bdffa..23ba5e7808c3a 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -78,7 +78,7 @@ async def setup_light(hass: HomeAssistant): # Validate starting values assert state.state == STATE_ON assert state.attributes.get(ATTR_BRIGHTNESS) == 180 - assert state.attributes.get(ATTR_RGB_COLOR) == (255, 63, 111) + assert state.attributes.get(ATTR_RGB_COLOR) == (255, 64, 112) await hass.services.async_call( LIGHT_DOMAIN, diff --git a/tests/components/deconz/snapshots/test_light.ambr b/tests/components/deconz/snapshots/test_light.ambr index a3ec7caac6086..b73bbcca2167e 100644 --- a/tests/components/deconz/snapshots/test_light.ambr +++ b/tests/components/deconz/snapshots/test_light.ambr @@ -125,7 +125,7 @@ 'min_mireds': 153, 'rgb_color': tuple( 255, - 67, + 68, 0, ), 'supported_color_modes': list([ @@ -134,7 +134,7 @@ ]), 'supported_features': , 'xy_color': tuple( - 0.674, + 0.673, 0.322, ), }), @@ -283,7 +283,7 @@ 'min_mireds': 155, 'rgb_color': tuple( 255, - 67, + 68, 0, ), 'supported_color_modes': list([ @@ -291,7 +291,7 @@ ]), 'supported_features': , 'xy_color': tuple( - 0.674, + 0.673, 0.322, ), }), @@ -429,7 +429,7 @@ 'min_mireds': 153, 'rgb_color': tuple( 255, - 67, + 68, 0, ), 'supported_color_modes': list([ @@ -438,7 +438,7 @@ ]), 'supported_features': , 'xy_color': tuple( - 0.674, + 0.673, 0.322, ), }), @@ -587,7 +587,7 @@ 'min_mireds': 155, 'rgb_color': tuple( 255, - 67, + 68, 0, ), 'supported_color_modes': list([ @@ -595,7 +595,7 @@ ]), 'supported_features': , 'xy_color': tuple( - 0.674, + 0.673, 0.322, ), }), @@ -891,7 +891,7 @@ 'min_mireds': 155, 'rgb_color': tuple( 255, - 67, + 68, 0, ), 'supported_color_modes': list([ @@ -899,7 +899,7 @@ ]), 'supported_features': , 'xy_color': tuple( - 0.674, + 0.673, 0.322, ), }), @@ -981,7 +981,7 @@ 'rgb_color': tuple( 255, 165, - 84, + 85, ), 'supported_color_modes': list([ , @@ -990,8 +990,8 @@ ]), 'supported_features': , 'xy_color': tuple( - 0.53, - 0.388, + 0.529, + 0.387, ), }), 'context': , @@ -1180,7 +1180,7 @@ 'is_deconz_group': False, 'rgb_color': tuple( 243, - 113, + 114, 255, ), 'supported_color_modes': list([ @@ -1189,7 +1189,7 @@ 'supported_features': , 'xy_color': tuple( 0.357, - 0.188, + 0.189, ), }), 'context': , diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 8ce83d87b698d..15135a333ce51 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -830,7 +830,7 @@ async def test_groups( }, { "on": True, - "xy": (0.235, 0.164), + "xy": (0.236, 0.166), }, ), ( # Turn on group with short color loop @@ -845,7 +845,7 @@ async def test_groups( }, { "on": True, - "xy": (0.235, 0.164), + "xy": (0.236, 0.166), }, ), ], diff --git a/tests/components/demo/test_light.py b/tests/components/demo/test_light.py index e3b1efc7eec8e..8fcdb8a9c2e15 100644 --- a/tests/components/demo/test_light.py +++ b/tests/components/demo/test_light.py @@ -73,8 +73,8 @@ async def test_state_attributes(hass: HomeAssistant) -> None: ) state = hass.states.get(ENTITY_LIGHT) - assert state.attributes.get(ATTR_RGB_COLOR) == (250, 252, 255) - assert state.attributes.get(ATTR_XY_COLOR) == (0.319, 0.326) + assert state.attributes.get(ATTR_RGB_COLOR) == (251, 253, 255) + assert state.attributes.get(ATTR_XY_COLOR) == (0.319, 0.327) await hass.services.async_call( LIGHT_DOMAIN, diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index c3ab076ded294..009feefc145f9 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -17,7 +17,7 @@ 'min_mireds': 143, 'rgb_color': tuple( 255, - 188, + 189, 133, ), 'supported_color_modes': list([ @@ -25,8 +25,8 @@ ]), 'supported_features': , 'xy_color': tuple( - 0.465, - 0.376, + 0.464, + 0.377, ), }), 'context': , @@ -132,7 +132,7 @@ 'min_mireds': 153, 'rgb_color': tuple( 255, - 188, + 189, 133, ), 'supported_color_modes': list([ @@ -141,8 +141,8 @@ ]), 'supported_features': , 'xy_color': tuple( - 0.465, - 0.376, + 0.464, + 0.377, ), }), 'context': , @@ -249,7 +249,7 @@ 'min_mireds': 153, 'rgb_color': tuple( 255, - 239, + 240, 240, ), 'supported_color_modes': list([ @@ -258,8 +258,8 @@ ]), 'supported_features': , 'xy_color': tuple( - 0.34, - 0.327, + 0.339, + 0.328, ), }), 'context': , diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 2324c73b16f97..7f275fff4f2ee 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -676,7 +676,7 @@ async def test_light_rgb( color_mode=LightColorCapability.RGB | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, - rgb=(pytest.approx(0.32941176470588235), 1.0, 0.0), + rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), ) ] @@ -814,7 +814,7 @@ async def test_light_rgbw( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, white=0, - rgb=(pytest.approx(0.32941176470588235), 1.0, 0.0), + rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), ) ] @@ -993,7 +993,7 @@ async def test_light_rgbww_with_cold_warm_white_support( | LightColorCapability.BRIGHTNESS, cold_white=0, warm_white=0, - rgb=(pytest.approx(0.32941176470588235), 1.0, 0.0), + rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), ) ] @@ -1226,7 +1226,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, white=0, - rgb=(pytest.approx(0.32941176470588235), 1.0, 0.0), + rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), ) ] diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py index f5a7b3102020d..c12776eb5522f 100644 --- a/tests/components/flux_led/test_light.py +++ b/tests/components/flux_led/test_light.py @@ -517,7 +517,7 @@ async def test_rgbw_light_auto_on(hass: HomeAssistant) -> None: # enough resolution to determine which color to display bulb.async_turn_on.assert_not_called() bulb.async_set_brightness.assert_not_called() - bulb.async_set_levels.assert_called_with(2, 0, 0, 0) + bulb.async_set_levels.assert_called_with(3, 0, 0, 0) bulb.async_set_levels.reset_mock() await hass.services.async_call( @@ -534,7 +534,7 @@ async def test_rgbw_light_auto_on(hass: HomeAssistant) -> None: # enough resolution to determine which color to display bulb.async_turn_on.assert_not_called() bulb.async_set_brightness.assert_not_called() - bulb.async_set_levels.assert_called_with(2, 0, 0, 56) + bulb.async_set_levels.assert_called_with(3, 0, 0, 56) bulb.async_set_levels.reset_mock() bulb.brightness = 128 @@ -652,7 +652,7 @@ async def test_rgbww_light_auto_on(hass: HomeAssistant) -> None: # which color to display bulb.async_turn_on.assert_not_called() bulb.async_set_brightness.assert_not_called() - bulb.async_set_levels.assert_called_with(2, 0, 0, 0, 0) + bulb.async_set_levels.assert_called_with(3, 0, 0, 0, 0) bulb.async_set_levels.reset_mock() bulb.brightness = 128 diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 8304d567916a7..b96da507adfc2 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -11400,15 +11400,15 @@ 'min_mireds': 153, 'rgb_color': tuple( 255, - 167, - 89, + 168, + 90, ), 'supported_color_modes': list([ , ]), 'supported_features': , 'xy_color': tuple( - 0.524, + 0.522, 0.387, ), }), @@ -11548,15 +11548,15 @@ 'min_mireds': 153, 'rgb_color': tuple( 255, - 167, - 89, + 168, + 90, ), 'supported_color_modes': list([ , ]), 'supported_features': , 'xy_color': tuple( - 0.524, + 0.522, 0.387, ), }), @@ -14883,7 +14883,7 @@ 'min_mireds': 153, 'rgb_color': tuple( 255, - 141, + 142, 28, ), 'supported_color_modes': list([ @@ -14892,8 +14892,8 @@ ]), 'supported_features': , 'xy_color': tuple( - 0.589, - 0.385, + 0.588, + 0.386, ), }), 'entity_id': 'light.nanoleaf_strip_3b32_nanoleaf_light_strip', diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 1ce7c69d7fafe..084ea0c674bc0 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -1098,8 +1098,8 @@ async def test_color_light_with_temp( ColorMode.HS, ] assert attributes[ATTR_HS_COLOR] == (30.754, 7.122) - assert attributes[ATTR_RGB_COLOR] == (255, 246, 236) - assert attributes[ATTR_XY_COLOR] == (0.34, 0.339) + assert attributes[ATTR_RGB_COLOR] == (255, 246, 237) + assert attributes[ATTR_XY_COLOR] == (0.339, 0.338) bulb.color = [65535, 65535, 65535, 65535] await hass.services.async_call( diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index eeb32f1b17ac5..61e7f4e6c29d0 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1287,9 +1287,9 @@ async def test_light_backwards_compatibility_color_mode(hass: HomeAssistant) -> state = hass.states.get(entity2.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] assert state.attributes["color_mode"] == light.ColorMode.COLOR_TEMP - assert state.attributes["rgb_color"] == (201, 218, 255) + assert state.attributes["rgb_color"] == (202, 218, 255) assert state.attributes["hs_color"] == (221.575, 20.9) - assert state.attributes["xy_color"] == (0.277, 0.287) + assert state.attributes["xy_color"] == (0.278, 0.287) state = hass.states.get(entity3.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.HS] diff --git a/tests/components/matter/snapshots/test_light.ambr b/tests/components/matter/snapshots/test_light.ambr index 68c1b7dca740d..eff5820d27d76 100644 --- a/tests/components/matter/snapshots/test_light.ambr +++ b/tests/components/matter/snapshots/test_light.ambr @@ -59,15 +59,15 @@ 'rgb_color': tuple( 255, 193, - 141, + 142, ), 'supported_color_modes': list([ , ]), 'supported_features': , 'xy_color': tuple( - 0.453, - 0.374, + 0.452, + 0.373, ), }), 'context': , @@ -252,7 +252,7 @@ 'rgb_color': tuple( 255, 247, - 203, + 204, ), 'supported_color_modes': list([ , @@ -261,8 +261,8 @@ ]), 'supported_features': , 'xy_color': tuple( - 0.363, - 0.374, + 0.362, + 0.373, ), }), 'context': , diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 0ef7cda2a7d1a..b11484d55fba4 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -721,12 +721,12 @@ async def test_invalid_state_via_topic( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 254, 250) + assert state.attributes.get("rgb_color") == (255, 255, 251) assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_temp") == 153 assert state.attributes.get("effect") == "none" assert state.attributes.get("hs_color") == (54.768, 1.6) - assert state.attributes.get("xy_color") == (0.326, 0.333) + assert state.attributes.get("xy_color") == (0.325, 0.333) async_fire_mqtt_message(hass, "test_light_rgb/color_temp/status", "") light_state = hass.states.get("light.test") diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 31573ad88c610..f0da483e70660 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -674,12 +674,12 @@ async def test_controlling_state_via_topic( assert state.attributes.get("rgb_color") == ( 255, 253, - 248, + 249, ) # temp converted to color assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_temp") == 155 assert state.attributes.get("effect") == "colorloop" - assert state.attributes.get("xy_color") == (0.328, 0.334) # temp converted to color + assert state.attributes.get("xy_color") == (0.328, 0.333) # temp converted to color assert state.attributes.get("hs_color") == (44.098, 2.43) # temp converted to color # Turn the light off @@ -706,7 +706,7 @@ async def test_controlling_state_via_topic( ) light_state = hass.states.get("light.test") - assert light_state.attributes.get("xy_color") == (0.141, 0.14) + assert light_state.attributes.get("xy_color") == (0.141, 0.141) async_fire_mqtt_message( hass, "test_light_rgb", '{"state":"ON", "color":{"h":180,"s":50}}' @@ -1015,10 +1015,10 @@ async def test_controlling_the_state_with_legacy_color_handling( assert state.attributes.get("color_temp") == 353 assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") == (28.125, 61.661) - assert state.attributes.get("rgb_color") == (255, 171, 97) + assert state.attributes.get("rgb_color") == (255, 171, 98) assert state.attributes.get("rgbw_color") is None assert state.attributes.get("rgbww_color") is None - assert state.attributes.get("xy_color") == (0.513, 0.386) + assert state.attributes.get("xy_color") == (0.512, 0.385) @pytest.mark.parametrize( @@ -1113,8 +1113,8 @@ async def test_sending_mqtt_commands_and_optimistic( mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator( - '{"state": "ON", "color": {"r": 0, "g": 123, "b": 255,' - ' "x": 0.14, "y": 0.131, "h": 210.824, "s": 100.0},' + '{"state": "ON", "color": {"r": 0, "g": 124, "b": 255,' + ' "x": 0.14, "y": 0.133, "h": 210.824, "s": 100.0},' ' "brightness": 50}' ), 2, @@ -1125,8 +1125,8 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get("color_mode") == light.ColorMode.HS assert state.attributes["brightness"] == 50 assert state.attributes["hs_color"] == (210.824, 100.0) - assert state.attributes["rgb_color"] == (0, 123, 255) - assert state.attributes["xy_color"] == (0.14, 0.131) + assert state.attributes["rgb_color"] == (0, 124, 255) + assert state.attributes["xy_color"] == (0.14, 0.133) await common.async_turn_on(hass, "light.test", brightness=50, hs_color=(359, 78)) mqtt_mock.async_publish.assert_called_once_with( @@ -1514,7 +1514,7 @@ async def test_sending_rgb_color_no_brightness( ), call( "test_light_rgb/set", - JsonValidator('{"state": "ON", "color": {"r": 50, "g": 11, "b": 11}}'), + JsonValidator('{"state": "ON", "color": {"r": 50, "g": 11, "b": 12}}'), 0, False, ), @@ -1646,7 +1646,7 @@ async def test_sending_rgb_color_with_brightness( call( "test_light_rgb/set", JsonValidator( - '{"state": "ON", "color": {"r": 0, "g": 123, "b": 255},' + '{"state": "ON", "color": {"r": 0, "g": 124, "b": 255},' ' "brightness": 50}' ), 0, @@ -1716,7 +1716,7 @@ async def test_sending_rgb_color_with_scaled_brightness( call( "test_light_rgb/set", JsonValidator( - '{"state": "ON", "color": {"r": 0, "g": 123, "b": 255},' + '{"state": "ON", "color": {"r": 0, "g": 124, "b": 255},' ' "brightness": 20}' ), 0, @@ -1830,7 +1830,7 @@ async def test_sending_xy_color( call( "test_light_rgb/set", JsonValidator( - '{"state": "ON", "color": {"x": 0.14, "y": 0.131},' + '{"state": "ON", "color": {"x": 0.14, "y": 0.133},' ' "brightness": 50}' ), 0, diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 63e110ba7c0a4..59fd3eb88ed78 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -322,7 +322,7 @@ async def test_state_brightness_color_effect_temp_change_via_topic( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 128, 63) + assert state.attributes.get("rgb_color") == (255, 128, 64) assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_temp") is None # rgb color has priority assert state.attributes.get("effect") is None @@ -494,12 +494,12 @@ async def test_sending_mqtt_commands_and_optimistic( # Full brightness - normalization of RGB values sent over MQTT await common.async_turn_on(hass, "light.test", rgb_color=(128, 64, 0)) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,,255-127-0,30.0-100.0", 2, False + "test_light_rgb/set", "on,,,255-128-0,30.0-100.0", 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 127, 0) + assert state.attributes.get("rgb_color") == (255, 128, 0) # Set half brightness await common.async_turn_on(hass, "light.test", brightness=128) @@ -528,7 +528,7 @@ async def test_sending_mqtt_commands_and_optimistic( mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (0, 255, 127) + assert state.attributes.get("rgb_color") == (0, 255, 128) @pytest.mark.parametrize( @@ -626,7 +626,7 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( # Full brightness - normalization of RGB values sent over MQTT await common.async_turn_on(hass, "light.test", rgb_color=(128, 64, 0)) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,,255-127-0,30.0-100.0", 0, False + "test_light_rgb/set", "on,,,255-128-0,30.0-100.0", 0, False ) mqtt_mock.async_publish.reset_mock() @@ -648,7 +648,7 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( # Half brightness - normalization but no scaling of RGB values sent over MQTT await common.async_turn_on(hass, "light.test", rgb_color=(0, 32, 16)) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,,0-255-127,150.0-100.0", 0, False + "test_light_rgb/set", "on,,,0-255-128,150.0-100.0", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") diff --git a/tests/components/vera/test_light.py b/tests/components/vera/test_light.py index 6bdc3df9a64f7..e66d19ec46e08 100644 --- a/tests/components/vera/test_light.py +++ b/tests/components/vera/test_light.py @@ -52,13 +52,13 @@ async def test_light( {"entity_id": entity_id, ATTR_HS_COLOR: [300, 70]}, ) await hass.async_block_till_done() - vera_device.set_color.assert_called_with((255, 76, 255)) + vera_device.set_color.assert_called_with((255, 77, 255)) vera_device.is_switched_on.return_value = True - vera_device.get_color.return_value = (255, 76, 255) + vera_device.get_color.return_value = (255, 77, 255) update_callback(vera_device) await hass.async_block_till_done() assert hass.states.get(entity_id).state == "on" - assert hass.states.get(entity_id).attributes["hs_color"] == (300.0, 70.196) + assert hass.states.get(entity_id).attributes["hs_color"] == (300.0, 69.804) await hass.services.async_call( "light", diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index eba4d4fe2841b..518537262b2eb 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -946,8 +946,8 @@ async def _async_test( "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], "hs_color": (26.812, 34.87), - "rgb_color": (255, 205, 166), - "xy_color": (0.421, 0.364), + "rgb_color": (255, 206, 166), + "xy_color": (0.42, 0.365), }, nightlight_entity_properties={ "supported_features": 0, @@ -959,8 +959,8 @@ async def _async_test( "effect": None, "supported_features": SUPPORT_YEELIGHT, "hs_color": (28.401, 100.0), - "rgb_color": (255, 120, 0), - "xy_color": (0.621, 0.367), + "rgb_color": (255, 121, 0), + "xy_color": (0.62, 0.368), "min_color_temp_kelvin": model_specs["color_temp"]["min"], "max_color_temp_kelvin": color_temperature_mired_to_kelvin( color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) @@ -1191,8 +1191,8 @@ async def _async_test( "color_mode": "color_temp", "supported_color_modes": ["color_temp"], "hs_color": (26.812, 34.87), - "rgb_color": (255, 205, 166), - "xy_color": (0.421, 0.364), + "rgb_color": (255, 206, 166), + "xy_color": (0.42, 0.365), }, nightlight_entity_properties={ "supported_features": 0, @@ -1226,8 +1226,8 @@ async def _async_test( "color_mode": "color_temp", "supported_color_modes": ["color_temp"], "hs_color": (28.391, 65.659), - "rgb_color": (255, 166, 87), - "xy_color": (0.526, 0.387), + "rgb_color": (255, 167, 88), + "xy_color": (0.524, 0.388), }, ) @@ -1263,8 +1263,8 @@ async def _async_test( "color_mode": "color_temp", "supported_color_modes": ["color_temp"], "hs_color": (26.812, 34.87), - "rgb_color": (255, 205, 166), - "xy_color": (0.421, 0.364), + "rgb_color": (255, 206, 166), + "xy_color": (0.42, 0.365), }, nightlight_entity_properties={ "supported_features": 0, @@ -1301,8 +1301,8 @@ async def _async_test( "color_mode": "color_temp", "supported_color_modes": ["color_temp"], "hs_color": (28.391, 65.659), - "rgb_color": (255, 166, 87), - "xy_color": (0.526, 0.387), + "rgb_color": (255, 167, 88), + "xy_color": (0.524, 0.388), }, ) # Background light - color mode CT @@ -1326,8 +1326,8 @@ async def _async_test( "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], "hs_color": (27.001, 19.243), - "rgb_color": (255, 228, 205), - "xy_color": (0.372, 0.35), + "rgb_color": (255, 228, 206), + "xy_color": (0.371, 0.349), }, name=f"{UNIQUE_FRIENDLY_NAME} Ambilight", entity_id=f"{ENTITY_LIGHT}_ambilight", diff --git a/tests/components/zerproc/test_light.py b/tests/components/zerproc/test_light.py index 6e00cfbde4ca1..724414b596562 100644 --- a/tests/components/zerproc/test_light.py +++ b/tests/components/zerproc/test_light.py @@ -215,7 +215,7 @@ async def test_light_turn_on(hass: HomeAssistant, mock_light) -> None: ) await hass.async_block_till_done() - mock_set_color.assert_called_with(19, 17, 25) + mock_set_color.assert_called_with(20, 17, 25) with patch.object(mock_light, "set_color") as mock_set_color: await hass.services.async_call( @@ -226,7 +226,7 @@ async def test_light_turn_on(hass: HomeAssistant, mock_light) -> None: ) await hass.async_block_till_done() - mock_set_color.assert_called_with(220, 201, 110) + mock_set_color.assert_called_with(220, 202, 110) with patch.object( mock_light, @@ -246,7 +246,7 @@ async def test_light_turn_on(hass: HomeAssistant, mock_light) -> None: ) await hass.async_block_till_done() - mock_set_color.assert_called_with(75, 68, 37) + mock_set_color.assert_called_with(75, 69, 38) with patch.object(mock_light, "set_color") as mock_set_color: await hass.services.async_call( @@ -261,7 +261,7 @@ async def test_light_turn_on(hass: HomeAssistant, mock_light) -> None: ) await hass.async_block_till_done() - mock_set_color.assert_called_with(162, 200, 50) + mock_set_color.assert_called_with(163, 200, 50) async def test_light_turn_off(hass: HomeAssistant, mock_light) -> None: @@ -352,6 +352,6 @@ async def test_light_update(hass: HomeAssistant, mock_light) -> None: ATTR_COLOR_MODE: ColorMode.HS, ATTR_BRIGHTNESS: 220, ATTR_HS_COLOR: (261.429, 31.818), - ATTR_RGB_COLOR: (202, 173, 255), - ATTR_XY_COLOR: (0.291, 0.232), + ATTR_RGB_COLOR: (203, 174, 255), + ATTR_XY_COLOR: (0.292, 0.234), } diff --git a/tests/util/test_color.py b/tests/util/test_color.py index c8a5e0c858756..165552b8792ee 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -181,7 +181,7 @@ def test_color_hs_to_xy() -> None: assert color_util.color_hs_to_xy(350, 12.5) == (0.356, 0.321) - assert color_util.color_hs_to_xy(140, 50) == (0.229, 0.474) + assert color_util.color_hs_to_xy(140, 50) == (0.23, 0.474) assert color_util.color_hs_to_xy(0, 40) == (0.474, 0.317) From 25f922d87b5d690702ecc5b6cd450ff2107942b9 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Nov 2024 22:08:16 +0100 Subject: [PATCH 0581/1070] Add base entity to sabnzbd (#130995) --- .../components/sabnzbd/coordinator.py | 2 ++ homeassistant/components/sabnzbd/entity.py | 30 +++++++++++++++++ homeassistant/components/sabnzbd/sensor.py | 32 +++---------------- .../sabnzbd/snapshots/test_sensor.ambr | 22 ++++++------- 4 files changed, 48 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/sabnzbd/entity.py diff --git a/homeassistant/components/sabnzbd/coordinator.py b/homeassistant/components/sabnzbd/coordinator.py index 0c69a81a7f592..14f7c18e38c28 100644 --- a/homeassistant/components/sabnzbd/coordinator.py +++ b/homeassistant/components/sabnzbd/coordinator.py @@ -16,6 +16,8 @@ class SabnzbdUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The SABnzbd update coordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/sabnzbd/entity.py b/homeassistant/components/sabnzbd/entity.py new file mode 100644 index 0000000000000..f7515c3d1783c --- /dev/null +++ b/homeassistant/components/sabnzbd/entity.py @@ -0,0 +1,30 @@ +"""Base entity for Sabnzbd.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SabnzbdUpdateCoordinator + + +class SabnzbdEntity(CoordinatorEntity[SabnzbdUpdateCoordinator]): + """Defines a base Sabnzbd entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SabnzbdUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the base entity.""" + super().__init__(coordinator) + + entry_id = coordinator.config_entry.entry_id + self._attr_unique_id = f"{entry_id}_{description.key}" + self.entity_description = description + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + ) diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index d956d06f1ac92..1d2bbdc55e7bf 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -13,13 +13,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, SabnzbdUpdateCoordinator -from .const import DEFAULT_NAME +from .const import DOMAIN +from .coordinator import SabnzbdUpdateCoordinator +from .entity import SabnzbdEntity @dataclass(frozen=True, kw_only=True) @@ -139,34 +138,13 @@ async def async_setup_entry( entry_id = config_entry.entry_id coordinator: SabnzbdUpdateCoordinator = hass.data[DOMAIN][entry_id] - async_add_entities( - [SabnzbdSensor(coordinator, sensor, entry_id) for sensor in SENSOR_TYPES] - ) + async_add_entities([SabnzbdSensor(coordinator, sensor) for sensor in SENSOR_TYPES]) -class SabnzbdSensor(CoordinatorEntity[SabnzbdUpdateCoordinator], SensorEntity): +class SabnzbdSensor(SabnzbdEntity, SensorEntity): """Representation of an SABnzbd sensor.""" entity_description: SabnzbdSensorEntityDescription - _attr_should_poll = False - _attr_has_entity_name = True - - def __init__( - self, - coordinator: SabnzbdUpdateCoordinator, - description: SabnzbdSensorEntityDescription, - entry_id, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - - self._attr_unique_id = f"{entry_id}_{description.key}" - self.entity_description = description - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry_id)}, - name=DEFAULT_NAME, - ) @property def native_value(self) -> StateType: diff --git a/tests/components/sabnzbd/snapshots/test_sensor.ambr b/tests/components/sabnzbd/snapshots/test_sensor.ambr index f339906d038d4..8b977e69aa6f7 100644 --- a/tests/components/sabnzbd/snapshots/test_sensor.ambr +++ b/tests/components/sabnzbd/snapshots/test_sensor.ambr @@ -41,7 +41,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', - 'friendly_name': 'SABnzbd Daily total', + 'friendly_name': 'Sabnzbd Daily total', 'state_class': , 'unit_of_measurement': , }), @@ -92,7 +92,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', - 'friendly_name': 'SABnzbd Free disk space', + 'friendly_name': 'Sabnzbd Free disk space', 'state_class': , 'unit_of_measurement': , }), @@ -143,7 +143,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', - 'friendly_name': 'SABnzbd Left to download', + 'friendly_name': 'Sabnzbd Left to download', 'state_class': , 'unit_of_measurement': , }), @@ -197,7 +197,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', - 'friendly_name': 'SABnzbd Monthly total', + 'friendly_name': 'Sabnzbd Monthly total', 'state_class': , 'unit_of_measurement': , }), @@ -251,7 +251,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', - 'friendly_name': 'SABnzbd Overall total', + 'friendly_name': 'Sabnzbd Overall total', 'state_class': , 'unit_of_measurement': , }), @@ -302,7 +302,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', - 'friendly_name': 'SABnzbd Queue', + 'friendly_name': 'Sabnzbd Queue', 'state_class': , 'unit_of_measurement': , }), @@ -355,7 +355,7 @@ # name: test_sensor[sensor.sabnzbd_queue_count-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'SABnzbd Queue count', + 'friendly_name': 'Sabnzbd Queue count', 'state_class': , }), 'context': , @@ -411,7 +411,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_rate', - 'friendly_name': 'SABnzbd Speed', + 'friendly_name': 'Sabnzbd Speed', 'state_class': , 'unit_of_measurement': , }), @@ -459,7 +459,7 @@ # name: test_sensor[sensor.sabnzbd_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'SABnzbd Status', + 'friendly_name': 'Sabnzbd Status', }), 'context': , 'entity_id': 'sensor.sabnzbd_status', @@ -508,7 +508,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', - 'friendly_name': 'SABnzbd Total disk space', + 'friendly_name': 'Sabnzbd Total disk space', 'state_class': , 'unit_of_measurement': , }), @@ -562,7 +562,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', - 'friendly_name': 'SABnzbd Weekly total', + 'friendly_name': 'Sabnzbd Weekly total', 'state_class': , 'unit_of_measurement': , }), From 7fda534d91378e9cad6349f59b94c864a29c7da3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Nov 2024 15:11:00 -0600 Subject: [PATCH 0582/1070] Bump aiohttp to 3.11.6 (#130993) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d2101b3af9b32..285c7debe9986 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.2.0 -aiohttp==3.11.5 +aiohttp==3.11.6 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 7eb0a815506a1..2bcc058f0a53e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.5", + "aiohttp==3.11.6", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index 4a9246bf37239..30ce1d0b6f3dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.5 +aiohttp==3.11.6 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 aiozoneinfo==0.2.1 From c1f03f34b2cd899cde954fa9acfbef12b05953a7 Mon Sep 17 00:00:00 2001 From: Federico D'Amico <48856240+FedDam@users.noreply.github.com> Date: Tue, 19 Nov 2024 22:12:00 +0100 Subject: [PATCH 0583/1070] Bump microBeesPy to 0.3.3 (#130942) --- homeassistant/components/microbees/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/microbees/manifest.json b/homeassistant/components/microbees/manifest.json index 91b7d66d80fd7..7188a3496d231 100644 --- a/homeassistant/components/microbees/manifest.json +++ b/homeassistant/components/microbees/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/microbees", "iot_class": "cloud_polling", - "requirements": ["microBeesPy==0.3.2"] + "requirements": ["microBeesPy==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index e729086078309..df6e7a87fd82e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1370,7 +1370,7 @@ mficlient==0.5.0 micloud==0.5 # homeassistant.components.microbees -microBeesPy==0.3.2 +microBeesPy==0.3.3 # homeassistant.components.mill mill-local==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb4ba016224a8..f868a863b85c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1139,7 +1139,7 @@ mficlient==0.5.0 micloud==0.5 # homeassistant.components.microbees -microBeesPy==0.3.2 +microBeesPy==0.3.3 # homeassistant.components.mill mill-local==0.3.0 From ea989f7ca186c5f6434c44f2c81ed396fcb625d5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 19 Nov 2024 22:17:29 +0100 Subject: [PATCH 0584/1070] Simplify FanEntity percentage and speed_count shorthand attributes (#130935) --- homeassistant/components/fan/__init__.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index ed383202b2833..bfef182f1e2cb 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -234,10 +234,10 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): entity_description: FanEntityDescription _attr_current_direction: str | None = None _attr_oscillating: bool | None = None - _attr_percentage: int | None + _attr_percentage: int | None = 0 _attr_preset_mode: str | None = None _attr_preset_modes: list[str] | None = None - _attr_speed_count: int + _attr_speed_count: int = 100 _attr_supported_features: FanEntityFeature = FanEntityFeature(0) __mod_supported_features: FanEntityFeature = FanEntityFeature(0) @@ -463,16 +463,12 @@ def is_on(self) -> bool | None: @cached_property def percentage(self) -> int | None: """Return the current speed as a percentage.""" - if hasattr(self, "_attr_percentage"): - return self._attr_percentage - return 0 + return self._attr_percentage @cached_property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" - if hasattr(self, "_attr_speed_count"): - return self._attr_speed_count - return 100 + return self._attr_speed_count @property def percentage_step(self) -> float: From fa289369952619375867e8894e8bdcbec0fb564f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 19 Nov 2024 22:21:12 +0100 Subject: [PATCH 0585/1070] Use snapshot in Sensibo tests (#130994) --- tests/components/sensibo/conftest.py | 16 +- .../sensibo/snapshots/test_sensor.ambr | 838 +++++++++++++++++- .../sensibo/snapshots/test_switch.ambr | 192 ++++ .../sensibo/snapshots/test_update.ambr | 178 ++++ tests/components/sensibo/test_sensor.py | 22 +- tests/components/sensibo/test_switch.py | 20 +- tests/components/sensibo/test_update.py | 30 +- 7 files changed, 1244 insertions(+), 52 deletions(-) create mode 100644 tests/components/sensibo/snapshots/test_switch.ambr create mode 100644 tests/components/sensibo/snapshots/test_update.ambr diff --git a/tests/components/sensibo/conftest.py b/tests/components/sensibo/conftest.py index 1c835cd800197..eaa42e47257b2 100644 --- a/tests/components/sensibo/conftest.py +++ b/tests/components/sensibo/conftest.py @@ -10,8 +10,9 @@ from pysensibo.model import SensiboData import pytest -from homeassistant.components.sensibo.const import DOMAIN +from homeassistant.components.sensibo.const import DOMAIN, PLATFORMS from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from . import ENTRY_CONFIG @@ -20,8 +21,18 @@ from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(name="load_platforms") +async def patch_platform_constant() -> list[Platform]: + """Return list of platforms to load.""" + return PLATFORMS + + @pytest.fixture -async def load_int(hass: HomeAssistant, get_data: SensiboData) -> MockConfigEntry: +async def load_int( + hass: HomeAssistant, + get_data: SensiboData, + load_platforms: list[Platform], +) -> MockConfigEntry: """Set up the Sensibo integration in Home Assistant.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -35,6 +46,7 @@ async def load_int(hass: HomeAssistant, get_data: SensiboData) -> MockConfigEntr config_entry.add_to_hass(hass) with ( + patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), patch( "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", return_value=get_data, diff --git a/tests/components/sensibo/snapshots/test_sensor.ambr b/tests/components/sensibo/snapshots/test_sensor.ambr index cd8d510b6cc52..31e579d992929 100644 --- a/tests/components/sensibo/snapshots/test_sensor.ambr +++ b/tests/components/sensibo/snapshots/test_sensor.ambr @@ -1,28 +1,818 @@ # serializer version: 1 -# name: test_sensor - ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Kitchen Pure AQI', - 'options': list([ - 'good', - 'moderate', - 'bad', - ]), - }) -# --- -# name: test_sensor.1 - ReadOnlyDict({ - 'device_class': 'temperature', - 'fanlevel': 'low', - 'friendly_name': 'Hallway Climate React low temperature threshold', - 'horizontalswing': 'stopped', - 'light': 'on', - 'mode': 'heat', - 'on': True, - 'state_class': , - 'swing': 'stopped', - 'targettemperature': 21, - 'temperatureunit': 'c', +# name: test_sensor[load_platforms0][sensor.bedroom_filter_last_reset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_filter_last_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter last reset', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_last_reset', + 'unique_id': 'BBZZBBZZ-filter_last_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[load_platforms0][sensor.bedroom_filter_last_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Bedroom Filter last reset', + }), + 'context': , + 'entity_id': 'sensor.bedroom_filter_last_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-04-23T15:58:45+00:00', + }) +# --- +# name: test_sensor[load_platforms0][sensor.bedroom_pure_aqi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'moderate', + 'bad', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_pure_aqi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pure AQI', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pm25_pure', + 'unique_id': 'BBZZBBZZ-pm25', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[load_platforms0][sensor.bedroom_pure_aqi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Bedroom Pure AQI', + 'options': list([ + 'good', + 'moderate', + 'bad', + ]), + }), + 'context': , + 'entity_id': 'sensor.bedroom_pure_aqi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensor[load_platforms0][sensor.bedroom_pure_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_pure_sensitivity', + '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': 'Pure sensitivity', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity', + 'unique_id': 'BBZZBBZZ-pure_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[load_platforms0][sensor.bedroom_pure_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom Pure sensitivity', + }), + 'context': , + 'entity_id': 'sensor.bedroom_pure_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'n', + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_climate_react_high_temperature_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hallway_climate_react_high_temperature_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Climate React high temperature threshold', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_react_high', + 'unique_id': 'ABC999111-climate_react_high', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_climate_react_high_temperature_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'fanlevel': 'high', + 'friendly_name': 'Hallway Climate React high temperature threshold', + 'horizontalswing': 'stopped', + 'light': 'on', + 'mode': 'cool', + 'on': True, + 'state_class': , + 'swing': 'stopped', + 'targettemperature': 21, + 'temperatureunit': 'c', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hallway_climate_react_high_temperature_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.5', + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_climate_react_low_temperature_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hallway_climate_react_low_temperature_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Climate React low temperature threshold', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_react_low', + 'unique_id': 'ABC999111-climate_react_low', 'unit_of_measurement': , }) # --- +# name: test_sensor[load_platforms0][sensor.hallway_climate_react_low_temperature_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'fanlevel': 'low', + 'friendly_name': 'Hallway Climate React low temperature threshold', + 'horizontalswing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': True, + 'state_class': , + 'swing': 'stopped', + 'targettemperature': 21, + 'temperatureunit': 'c', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hallway_climate_react_low_temperature_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_climate_react_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hallway_climate_react_type', + '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': 'Climate React type', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smart_type', + 'unique_id': 'ABC999111-climate_react_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_climate_react_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hallway Climate React type', + }), + 'context': , + 'entity_id': 'sensor.hallway_climate_react_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'temperature', + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_filter_last_reset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hallway_filter_last_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter last reset', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_last_reset', + 'unique_id': 'ABC999111-filter_last_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_filter_last_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Hallway Filter last reset', + }), + 'context': , + 'entity_id': 'sensor.hallway_filter_last_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-03-12T15:24:26+00:00', + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_motion_sensor_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hallway_motion_sensor_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': 'AABBCC-battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_motion_sensor_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Hallway Motion Sensor Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hallway_motion_sensor_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3000', + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_motion_sensor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hallway_motion_sensor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AABBCC-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_motion_sensor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Hallway Motion Sensor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.hallway_motion_sensor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_motion_sensor_rssi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hallway_motion_sensor_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rssi', + 'unique_id': 'AABBCC-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_motion_sensor_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Hallway Motion Sensor RSSI', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.hallway_motion_sensor_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-72', + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_motion_sensor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hallway_motion_sensor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AABBCC-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_motion_sensor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Hallway Motion Sensor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hallway_motion_sensor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.9', + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_temperature_feels_like-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hallway_temperature_feels_like', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature feels like', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'feels_like', + 'unique_id': 'ABC999111-feels_like', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_temperature_feels_like-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Hallway Temperature feels like', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hallway_temperature_feels_like', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.2', + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_timer_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hallway_timer_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Timer end time', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'timer_time', + 'unique_id': 'ABC999111-timer_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[load_platforms0][sensor.hallway_timer_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Hallway Timer end time', + 'id': None, + 'turn_on': None, + }), + 'context': , + 'entity_id': 'sensor.hallway_timer_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[load_platforms0][sensor.kitchen_filter_last_reset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_filter_last_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter last reset', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_last_reset', + 'unique_id': 'AAZZAAZZ-filter_last_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[load_platforms0][sensor.kitchen_filter_last_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Kitchen Filter last reset', + }), + 'context': , + 'entity_id': 'sensor.kitchen_filter_last_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-04-23T15:58:45+00:00', + }) +# --- +# name: test_sensor[load_platforms0][sensor.kitchen_pure_aqi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'moderate', + 'bad', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_pure_aqi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pure AQI', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pm25_pure', + 'unique_id': 'AAZZAAZZ-pm25', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[load_platforms0][sensor.kitchen_pure_aqi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Kitchen Pure AQI', + 'options': list([ + 'good', + 'moderate', + 'bad', + ]), + }), + 'context': , + 'entity_id': 'sensor.kitchen_pure_aqi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensor[load_platforms0][sensor.kitchen_pure_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_pure_sensitivity', + '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': 'Pure sensitivity', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity', + 'unique_id': 'AAZZAAZZ-pure_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[load_platforms0][sensor.kitchen_pure_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Pure sensitivity', + }), + 'context': , + 'entity_id': 'sensor.kitchen_pure_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'n', + }) +# --- diff --git a/tests/components/sensibo/snapshots/test_switch.ambr b/tests/components/sensibo/snapshots/test_switch.ambr new file mode 100644 index 0000000000000..13cb73cef7a61 --- /dev/null +++ b/tests/components/sensibo/snapshots/test_switch.ambr @@ -0,0 +1,192 @@ +# serializer version: 1 +# name: test_switch[load_platforms0][switch.bedroom_pure_boost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_pure_boost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pure Boost', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pure_boost_switch', + 'unique_id': 'BBZZBBZZ-pure_boost_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[load_platforms0][switch.bedroom_pure_boost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Bedroom Pure Boost', + }), + 'context': , + 'entity_id': 'switch.bedroom_pure_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[load_platforms0][switch.hallway_climate_react-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.hallway_climate_react', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Climate React', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_react_switch', + 'unique_id': 'ABC999111-climate_react_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[load_platforms0][switch.hallway_climate_react-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Hallway Climate React', + 'type': 'temperature', + }), + 'context': , + 'entity_id': 'switch.hallway_climate_react', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[load_platforms0][switch.hallway_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.hallway_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Timer', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'timer_on_switch', + 'unique_id': 'ABC999111-timer_on_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[load_platforms0][switch.hallway_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Hallway Timer', + 'id': None, + 'turn_on': None, + }), + 'context': , + 'entity_id': 'switch.hallway_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[load_platforms0][switch.kitchen_pure_boost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.kitchen_pure_boost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pure Boost', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pure_boost_switch', + 'unique_id': 'AAZZAAZZ-pure_boost_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[load_platforms0][switch.kitchen_pure_boost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Kitchen Pure Boost', + }), + 'context': , + 'entity_id': 'switch.kitchen_pure_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/sensibo/snapshots/test_update.ambr b/tests/components/sensibo/snapshots/test_update.ambr new file mode 100644 index 0000000000000..3eb69c9a81267 --- /dev/null +++ b/tests/components/sensibo/snapshots/test_update.ambr @@ -0,0 +1,178 @@ +# serializer version: 1 +# name: test_update[load_platforms0][update.bedroom_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.bedroom_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'BBZZBBZZ-fw_ver_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[load_platforms0][update.bedroom_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/sensibo/icon.png', + 'friendly_name': 'Bedroom Firmware', + 'in_progress': False, + 'installed_version': 'PUR00111', + 'latest_version': 'PUR00111', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': 'pure', + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.bedroom_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update[load_platforms0][update.hallway_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.hallway_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC999111-fw_ver_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[load_platforms0][update.hallway_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/sensibo/icon.png', + 'friendly_name': 'Hallway Firmware', + 'in_progress': False, + 'installed_version': 'SKY30046', + 'latest_version': 'SKY30048', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': 'skyv2', + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.hallway_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update[load_platforms0][update.kitchen_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.kitchen_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AAZZAAZZ-fw_ver_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[load_platforms0][update.kitchen_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/sensibo/icon.png', + 'friendly_name': 'Kitchen Firmware', + 'in_progress': False, + 'installed_version': 'PUR00111', + 'latest_version': 'PUR00111', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': 'pure', + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.kitchen_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 5fc761f178ad2..32794e266b02f 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -5,37 +5,37 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from pysensibo.model import PureAQI, SensiboData import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.SENSOR]], +) async def test_sensor( hass: HomeAssistant, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, + entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo sensor.""" - state1 = hass.states.get("sensor.hallway_motion_sensor_battery_voltage") - state2 = hass.states.get("sensor.kitchen_pure_aqi") - state3 = hass.states.get("sensor.kitchen_pure_sensitivity") - state4 = hass.states.get("sensor.hallway_climate_react_low_temperature_threshold") - assert state1.state == "3000" - assert state2.state == "good" - assert state3.state == "n" - assert state4.state == "0.0" - assert state2.attributes == snapshot - assert state4.attributes == snapshot + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pm25_pure", PureAQI(2)) diff --git a/tests/components/sensibo/test_switch.py b/tests/components/sensibo/test_switch.py index cc3c8881bec99..f260af7baaa87 100644 --- a/tests/components/sensibo/test_switch.py +++ b/tests/components/sensibo/test_switch.py @@ -7,6 +7,7 @@ from pysensibo.model import SensiboData import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -16,12 +17,29 @@ SERVICE_TURN_ON, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.SWITCH]], +) +async def test_switch( + hass: HomeAssistant, + load_int: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Sensibo switch.""" + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) async def test_switch_timer( diff --git a/tests/components/sensibo/test_update.py b/tests/components/sensibo/test_update.py index 23b2719d5b514..a4eb9751243f0 100644 --- a/tests/components/sensibo/test_update.py +++ b/tests/components/sensibo/test_update.py @@ -5,32 +5,36 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from pysensibo.model import SensiboData import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, Platform from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util +from homeassistant.helpers import entity_registry as er -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.UPDATE]], +) async def test_update( hass: HomeAssistant, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo update.""" - state1 = hass.states.get("update.hallway_firmware") - state2 = hass.states.get("update.kitchen_firmware") - assert state1.state == STATE_ON - assert state1.attributes["installed_version"] == "SKY30046" - assert state1.attributes["latest_version"] == "SKY30048" - assert state1.attributes["title"] == "skyv2" - assert state2.state == STATE_OFF + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) monkeypatch.setattr(get_data.parsed["ABC999111"], "fw_ver", "SKY30048") @@ -38,10 +42,8 @@ async def test_update( "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", return_value=get_data, ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() state1 = hass.states.get("update.hallway_firmware") From 07051d0d0e93878e83915ba05b32bc37596d3acf Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 19 Nov 2024 22:28:59 +0100 Subject: [PATCH 0586/1070] Clean up old migration in Twente Milieu (#130998) --- homeassistant/components/twentemilieu/__init__.py | 6 ------ tests/components/twentemilieu/test_init.py | 15 --------------- 2 files changed, 21 deletions(-) diff --git a/homeassistant/components/twentemilieu/__init__.py b/homeassistant/components/twentemilieu/__init__.py index b6728b9653606..0a2fb50c7c469 100644 --- a/homeassistant/components/twentemilieu/__init__.py +++ b/homeassistant/components/twentemilieu/__init__.py @@ -49,12 +49,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - # For backwards compat, set unique ID - if entry.unique_id is None: - hass.config_entries.async_update_entry( - entry, unique_id=str(entry.data[CONF_ID]) - ) - entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/tests/components/twentemilieu/test_init.py b/tests/components/twentemilieu/test_init.py index d4c519d6f6606..7e08b5f49388a 100644 --- a/tests/components/twentemilieu/test_init.py +++ b/tests/components/twentemilieu/test_init.py @@ -44,18 +44,3 @@ async def test_config_entry_not_ready( assert mock_request.call_count == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -@pytest.mark.usefixtures("mock_twentemilieu") -async def test_update_config_entry_unique_id( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test the we update old config entries with an unique ID.""" - mock_config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry(mock_config_entry, unique_id=None) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.unique_id == "12345" From cdf0e1363ae9ef03b94acb696a83aa475e136df5 Mon Sep 17 00:00:00 2001 From: dotvav Date: Tue, 19 Nov 2024 22:29:49 +0100 Subject: [PATCH 0587/1070] Bump pypalazzetti to 0.1.13 (#130956) --- homeassistant/components/palazzetti/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index 9bf7287fe05c6..30134c6ac806a 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pypalazzetti==0.1.12"] + "requirements": ["pypalazzetti==0.1.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index df6e7a87fd82e..ce127ce096b31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2155,7 +2155,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.12 +pypalazzetti==0.1.13 # homeassistant.components.elv pypca==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f868a863b85c3..25dd4ae309b36 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1742,7 +1742,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.12 +pypalazzetti==0.1.13 # homeassistant.components.lcn pypck==0.7.24 From 85f3ff94ccac1862596c8ac55fbe8b0300799435 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 19 Nov 2024 22:31:57 +0100 Subject: [PATCH 0588/1070] Add more UI user-friendly description to six Supervisor actions (#130971) --- homeassistant/components/hassio/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 09ed45bd5bc2c..de42a317cc7d1 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -274,7 +274,7 @@ "fields": { "addon": { "name": "Add-on", - "description": "The add-on slug." + "description": "The add-on to start." } } }, @@ -284,17 +284,17 @@ "fields": { "addon": { "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", - "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]" + "description": "The add-on to restart." } } }, "addon_stdin": { "name": "Write data to add-on stdin.", - "description": "Writes data to add-on stdin.", + "description": "Writes data to the add-on's standard input.", "fields": { "addon": { "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", - "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]" + "description": "The add-on to write to." } } }, @@ -304,7 +304,7 @@ "fields": { "addon": { "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", - "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]" + "description": "The add-on to stop." } } }, @@ -314,7 +314,7 @@ "fields": { "addon": { "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", - "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]" + "description": "The add-on to update." } } }, From 397a299f150931a442f969ea499721de7948485a Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 19 Nov 2024 22:38:22 +0100 Subject: [PATCH 0589/1070] =?UTF-8?q?Add=20=C2=B5V=20as=20UnitOfElectricPo?= =?UTF-8?q?tential=20(#130838)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/number/const.py | 2 +- homeassistant/components/sensor/const.py | 2 +- homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 2 ++ tests/test_const.py | 8 +++++++- tests/util/test_unit_conversion.py | 4 ++++ 6 files changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 23e3ce0910bc3..374a69dedc8e7 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -369,7 +369,7 @@ class NumberDeviceClass(StrEnum): VOLTAGE = "voltage" """Voltage. - Unit of measurement: `V`, `mV` + Unit of measurement: `V`, `mV`, `µV` """ VOLUME = "volume" diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index f4573f873a280..b06353272f87e 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -391,7 +391,7 @@ class SensorDeviceClass(StrEnum): VOLTAGE = "voltage" """Voltage. - Unit of measurement: `V`, `mV` + Unit of measurement: `V`, `mV`, `µV` """ VOLUME = "volume" diff --git a/homeassistant/const.py b/homeassistant/const.py index 4082a076b940f..61b60fc3cf356 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -822,6 +822,7 @@ class UnitOfElectricCurrent(StrEnum): class UnitOfElectricPotential(StrEnum): """Electric potential units.""" + MICROVOLT = "µV" MILLIVOLT = "mV" VOLT = "V" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 1bf3561e66a35..a4c35d67ab75d 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -215,10 +215,12 @@ class ElectricPotentialConverter(BaseUnitConverter): _UNIT_CONVERSION: dict[str | None, float] = { UnitOfElectricPotential.VOLT: 1, UnitOfElectricPotential.MILLIVOLT: 1e3, + UnitOfElectricPotential.MICROVOLT: 1e6, } VALID_UNITS = { UnitOfElectricPotential.VOLT, UnitOfElectricPotential.MILLIVOLT, + UnitOfElectricPotential.MICROVOLT, } diff --git a/tests/test_const.py b/tests/test_const.py index 87a14ecfe9c31..73636b9910798 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -83,7 +83,13 @@ def test_all() -> None: "ENERGY_", ) + _create_tuples(const.UnitOfElectricCurrent, "ELECTRIC_CURRENT_") - + _create_tuples(const.UnitOfElectricPotential, "ELECTRIC_POTENTIAL_") + + _create_tuples( + [ + const.UnitOfElectricPotential.MILLIVOLT, + const.UnitOfElectricPotential.VOLT, + ], + "ELECTRIC_POTENTIAL_", + ) + _create_tuples(const.UnitOfTemperature, "TEMP_") + _create_tuples(const.UnitOfTime, "TIME_") + _create_tuples( diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 609809a96e8ba..c2c05e76ab5a4 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -374,7 +374,11 @@ ], ElectricPotentialConverter: [ (5, UnitOfElectricPotential.VOLT, 5000, UnitOfElectricPotential.MILLIVOLT), + (5, UnitOfElectricPotential.VOLT, 5e6, UnitOfElectricPotential.MICROVOLT), (5, UnitOfElectricPotential.MILLIVOLT, 0.005, UnitOfElectricPotential.VOLT), + (5, UnitOfElectricPotential.MILLIVOLT, 5e3, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.MICROVOLT, 5e-3, UnitOfElectricPotential.MILLIVOLT), + (5, UnitOfElectricPotential.MICROVOLT, 5e-6, UnitOfElectricPotential.VOLT), ], EnergyConverter: [ (10, UnitOfEnergy.WATT_HOUR, 0.01, UnitOfEnergy.KILO_WATT_HOUR), From 5daf95ec8f60f7595f1b776b8cc879fac423b192 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 20 Nov 2024 03:37:23 +0100 Subject: [PATCH 0590/1070] Add calendars for to-do and daily reminders to Habitica integration (#130789) * Add calendars for to-do and daily reminders * revert order * changes --- homeassistant/components/habitica/calendar.py | 178 +++++++++ homeassistant/components/habitica/icons.json | 6 + .../components/habitica/strings.json | 6 + tests/components/habitica/fixtures/tasks.json | 7 +- .../habitica/snapshots/test_calendar.ambr | 364 ++++++++++++++++++ tests/components/habitica/test_calendar.py | 2 + 6 files changed, 562 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py index 5a0470c3440e4..be4433cb35501 100644 --- a/homeassistant/components/habitica/calendar.py +++ b/homeassistant/components/habitica/calendar.py @@ -28,6 +28,8 @@ class HabiticaCalendar(StrEnum): DAILIES = "dailys" TODOS = "todos" + TODO_REMINDERS = "todo_reminders" + DAILY_REMINDERS = "daily_reminders" async def async_setup_entry( @@ -42,6 +44,8 @@ async def async_setup_entry( [ HabiticaTodosCalendarEntity(coordinator), HabiticaDailiesCalendarEntity(coordinator), + HabiticaTodoRemindersCalendarEntity(coordinator), + HabiticaDailyRemindersCalendarEntity(coordinator), ] ) @@ -225,3 +229,177 @@ def extra_state_attributes(self) -> dict[str, bool | None] | None: return { "yesterdaily": self.event.start < self.today.date() if self.event else None } + + +class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity): + """Habitica to-do reminders calendar entity.""" + + entity_description = CalendarEntityDescription( + key=HabiticaCalendar.TODO_REMINDERS, + translation_key=HabiticaCalendar.TODO_REMINDERS, + ) + + def reminders( + self, start_date: datetime, end_date: datetime | None = None + ) -> list[CalendarEvent]: + """Reminders for todos.""" + + events = [] + + for task in self.coordinator.data.tasks: + if task["type"] != HabiticaTaskType.TODO or task["completed"]: + continue + + for reminder in task.get("reminders", []): + # reminders are returned by the API in local time but with wrong + # timezone (UTC) and arbitrary added seconds/microseconds. When + # creating reminders in Habitica only hours and minutes can be defined. + start = datetime.fromisoformat(reminder["time"]).replace( + tzinfo=dt_util.DEFAULT_TIME_ZONE, second=0, microsecond=0 + ) + end = start + timedelta(hours=1) + + if end < start_date: + # Event ends before date range + continue + + if end_date and start > end_date: + # Event starts after date range + continue + + events.append( + CalendarEvent( + start=start, + end=end, + summary=task["text"], + description=task["notes"], + uid=f"{task["id"]}_{reminder["id"]}", + ) + ) + + return sorted( + events, + key=lambda event: event.start, + ) + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return next(iter(self.reminders(dt_util.now())), None) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + + return self.reminders(start_date, end_date) + + +class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity): + """Habitica daily reminders calendar entity.""" + + entity_description = CalendarEntityDescription( + key=HabiticaCalendar.DAILY_REMINDERS, + translation_key=HabiticaCalendar.DAILY_REMINDERS, + ) + + def start(self, reminder_time: str, reminder_date: date) -> datetime: + """Generate reminder times for dailies. + + Reminders for dailies have a datetime but the date part is arbitrary, + only the time part is evaluated. The dates for the reminders are the + dailies' due dates. + """ + return datetime.combine( + reminder_date, + datetime.fromisoformat(reminder_time) + .replace( + second=0, + microsecond=0, + ) + .time(), + tzinfo=dt_util.DEFAULT_TIME_ZONE, + ) + + @property + def today(self) -> datetime: + """Habitica daystart.""" + return dt_util.start_of_local_day( + datetime.fromisoformat(self.coordinator.data.user["lastCron"]) + ) + + def get_recurrence_dates( + self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None + ) -> list[datetime]: + """Calculate recurrence dates based on start_date and end_date.""" + if end_date: + return recurrences.between( + start_date, end_date - timedelta(days=1), inc=True + ) + # if no end_date is given, return only the next recurrence + return [recurrences.after(self.today, inc=True)] + + def reminders( + self, start_date: datetime, end_date: datetime | None = None + ) -> list[CalendarEvent]: + """Reminders for dailies.""" + + events = [] + if end_date and end_date < self.today: + return [] + start_date = max(start_date, self.today) + + for task in self.coordinator.data.tasks: + if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]): + continue + + recurrences = build_rrule(task) + recurrences_start = self.today + + recurrence_dates = self.get_recurrence_dates( + recurrences, recurrences_start, end_date + ) + for recurrence in recurrence_dates: + is_future_event = recurrence > self.today + is_current_event = recurrence <= self.today and not task["completed"] + + if not is_future_event and not is_current_event: + continue + + for reminder in task.get("reminders", []): + start = self.start(reminder["time"], recurrence) + end = start + timedelta(hours=1) + + if end < start_date: + # Event ends before date range + continue + + if end_date and start > end_date: + # Event starts after date range + continue + events.append( + CalendarEvent( + start=start, + end=end, + summary=task["text"], + description=task["notes"], + uid=f"{task["id"]}_{reminder["id"]}", + ) + ) + + return sorted( + events, + key=lambda event: event.start, + ) + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return next(iter(self.reminders(dt_util.now())), None) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + + return self.reminders(start_date, end_date) diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index ca0ae604f1440..d4ca5dba10d86 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -64,6 +64,12 @@ }, "dailys": { "default": "mdi:calendar-multiple" + }, + "todo_reminders": { + "default": "mdi:reminder" + }, + "daily_reminders": { + "default": "mdi:reminder" } }, "sensor": { diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index d32e4a048c733..08809ee05a6e7 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -109,6 +109,12 @@ } } } + }, + "todo_reminders": { + "name": "To-do reminders" + }, + "daily_reminders": { + "name": "Daily reminders" } }, "sensor": { diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 2e8305283d083..7784b9c7f497f 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -345,7 +345,12 @@ "daysOfMonth": [], "weeksOfMonth": [], "checklist": [], - "reminders": [], + "reminders": [ + { + "id": "1491d640-6b21-4d0c-8940-0b7aa61c8836", + "time": "2024-09-22T20:00:00.0000Z" + } + ], "createdAt": "2024-07-07T17:51:53.266Z", "updatedAt": "2024-09-21T22:51:41.756Z", "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr index 7325e12547048..c2f9c8e83c9f2 100644 --- a/tests/components/habitica/snapshots/test_calendar.ambr +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -577,6 +577,266 @@ }), ]) # --- +# name: test_api_events[calendar.test_user_daily_reminders] + list([ + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-21T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-21T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-22T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-22T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-23T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-23T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-24T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-24T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-25T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-25T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-26T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-26T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-27T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-27T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-28T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-28T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-29T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-29T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-30T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-30T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-01T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-01T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-02T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-02T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-03T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-03T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-04T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-04T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-05T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-05T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-06T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-06T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-07T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-07T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + ]) +# --- +# name: test_api_events[calendar.test_user_to_do_reminders] + list([ + dict({ + 'description': 'Strom- und Internetrechnungen rechtzeitig überweisen.', + 'end': dict({ + 'dateTime': '2024-09-22T03:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-22T02:00:00+02:00', + }), + 'summary': 'Rechnungen bezahlen', + 'uid': '2f6fcabc-f670-4ec3-ba65-817e8deea490_91c09432-10ac-4a49-bd20-823081ec29ed', + }), + ]) +# --- # name: test_api_events[calendar.test_user_to_do_s] list([ dict({ @@ -676,6 +936,110 @@ 'state': 'on', }) # --- +# name: test_calendar_platform[calendar.test_user_daily_reminders-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.test_user_daily_reminders', + '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': 'Daily reminders', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_daily_reminders', + 'unit_of_measurement': None, + }) +# --- +# name: test_calendar_platform[calendar.test_user_daily_reminders-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': False, + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end_time': '2024-09-21 21:00:00', + 'friendly_name': 'test-user Daily reminders', + 'location': '', + 'message': '5 Minuten ruhig durchatmen', + 'start_time': '2024-09-21 20:00:00', + }), + 'context': , + 'entity_id': 'calendar.test_user_daily_reminders', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_calendar_platform[calendar.test_user_to_do_reminders-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.test_user_to_do_reminders', + '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': 'To-do reminders', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_todo_reminders', + 'unit_of_measurement': None, + }) +# --- +# name: test_calendar_platform[calendar.test_user_to_do_reminders-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': False, + 'description': 'Strom- und Internetrechnungen rechtzeitig überweisen.', + 'end_time': '2024-09-22 03:00:00', + 'friendly_name': 'test-user To-do reminders', + 'location': '', + 'message': 'Rechnungen bezahlen', + 'start_time': '2024-09-22 02:00:00', + }), + 'context': , + 'entity_id': 'calendar.test_user_to_do_reminders', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_calendar_platform[calendar.test_user_to_do_s-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/habitica/test_calendar.py b/tests/components/habitica/test_calendar.py index 7c0a2686038b7..a6cdb1a930696 100644 --- a/tests/components/habitica/test_calendar.py +++ b/tests/components/habitica/test_calendar.py @@ -55,6 +55,8 @@ async def test_calendar_platform( [ "calendar.test_user_to_do_s", "calendar.test_user_dailies", + "calendar.test_user_daily_reminders", + "calendar.test_user_to_do_reminders", ], ) @pytest.mark.freeze_time("2024-09-20T22:00:00.000Z") From 2a1cdf6ff28cc4f6ae85c03216cb712664e9c880 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Nov 2024 03:57:57 +0100 Subject: [PATCH 0591/1070] Strip whitespaces from host in ping config flow (#130970) --- homeassistant/components/ping/config_flow.py | 9 ++++- tests/components/ping/test_config_flow.py | 36 ++++++++++++-------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py index 4f2adb0d2c0fa..27cb3f62bcda4 100644 --- a/homeassistant/components/ping/config_flow.py +++ b/homeassistant/components/ping/config_flow.py @@ -27,6 +27,12 @@ _LOGGER = logging.getLogger(__name__) +def _clean_user_input(user_input: dict[str, Any]) -> dict[str, Any]: + """Clean up the user input.""" + user_input[CONF_HOST] = user_input[CONF_HOST].strip() + return user_input + + class PingConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ping.""" @@ -46,6 +52,7 @@ async def async_step_user( ), ) + user_input = _clean_user_input(user_input) if not is_ip_address(user_input[CONF_HOST]): self.async_abort(reason="invalid_ip_address") @@ -77,7 +84,7 @@ async def async_step_init( ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry(title="", data=_clean_user_input(user_input)) return self.async_show_form( step_id="init", diff --git a/tests/components/ping/test_config_flow.py b/tests/components/ping/test_config_flow.py index 8204a000f29b4..bc13030647eb0 100644 --- a/tests/components/ping/test_config_flow.py +++ b/tests/components/ping/test_config_flow.py @@ -13,11 +13,15 @@ @pytest.mark.parametrize( - ("host", "expected_title"), - [("192.618.178.1", "192.618.178.1")], + ("host", "expected"), + [ + ("192.618.178.1", "192.618.178.1"), + (" 192.618.178.1 ", "192.618.178.1"), + (" demo.host ", "demo.host"), + ], ) @pytest.mark.usefixtures("patch_setup") -async def test_form(hass: HomeAssistant, host, expected_title) -> None: +async def test_form(hass: HomeAssistant, host, expected) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -35,21 +39,25 @@ async def test_form(hass: HomeAssistant, host, expected_title) -> None: await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == expected_title + assert result["title"] == expected assert result["data"] == {} assert result["options"] == { "count": 5, - "host": host, + "host": expected, "consider_home": 180, } @pytest.mark.parametrize( - ("host", "count", "expected_title"), - [("192.618.178.1", 10, "192.618.178.1")], + ("host", "expected_host"), + [ + ("192.618.178.1", "192.618.178.1"), + (" 192.618.178.1 ", "192.618.178.1"), + (" demo.host ", "demo.host"), + ], ) @pytest.mark.usefixtures("patch_setup") -async def test_options(hass: HomeAssistant, host, count, expected_title) -> None: +async def test_options(hass: HomeAssistant, host: str, expected_host: str) -> None: """Test options flow.""" config_entry = MockConfigEntry( @@ -57,8 +65,8 @@ async def test_options(hass: HomeAssistant, host, count, expected_title) -> None source=config_entries.SOURCE_USER, data={}, domain=DOMAIN, - options={"count": count, "host": host, "consider_home": 180}, - title=expected_title, + options={"count": 1, "host": "192.168.1.1", "consider_home": 180}, + title="192.168.1.1", ) config_entry.add_to_hass(hass) @@ -72,15 +80,15 @@ async def test_options(hass: HomeAssistant, host, count, expected_title) -> None result = await hass.config_entries.options.async_configure( result["flow_id"], { - "host": "10.10.10.1", - "count": count, + "host": host, + "count": 10, }, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "count": count, - "host": "10.10.10.1", + "count": 10, + "host": expected_host, "consider_home": 180, } From a857041570febe768a5f5165b274839291714b74 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Wed, 20 Nov 2024 08:44:34 +0100 Subject: [PATCH 0592/1070] Bump plugwise to v1.5.2 (#131012) --- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index bf29bd53b41aa..9f11433d8d31a 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], - "requirements": ["plugwise==1.5.1"], + "requirements": ["plugwise==1.5.2"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ce127ce096b31..6e5ec19d2537c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1622,7 +1622,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.5.1 +plugwise==1.5.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25dd4ae309b36..7f70132e05963 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1329,7 +1329,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.5.1 +plugwise==1.5.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From 3d495fe690fb5271f86ceaa813dc7e572dd4791e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Nov 2024 09:02:57 +0100 Subject: [PATCH 0593/1070] Bump codecov/codecov-action from 5.0.2 to 5.0.4 (#131008) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dc9270ebe9a1b..40756153ee275 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1248,7 +1248,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.4 with: fail_ci_if_error: true flags: full-suite @@ -1386,7 +1386,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.4 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From 139f3e294ac1829f64d07ca8e5b0b151d4569597 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 20 Nov 2024 09:06:31 +0100 Subject: [PATCH 0594/1070] UniFi Protect small textual fix in action description (#131009) --- homeassistant/components/unifiprotect/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 9238c825390d4..7b2a06dfef72e 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -182,7 +182,7 @@ "fields": { "device_id": { "name": "Chime", - "description": "The chimes to link to the doorbells to." + "description": "The chimes to link to the doorbells." }, "doorbells": { "name": "Doorbells", From 85610901e05d110c635d0f3d4db51c760d7f2c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 20 Nov 2024 09:09:50 +0100 Subject: [PATCH 0595/1070] Add programs to Home Connect diagnostics (#131011) --- .../components/home_connect/diagnostics.py | 7 +- .../snapshots/test_diagnostics.ambr | 660 ++++++++++-------- 2 files changed, 374 insertions(+), 293 deletions(-) diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index ae484ae1d725f..2018a8e390680 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -15,6 +15,11 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" return { - device.appliance.haId: device.appliance.status + device.appliance.haId: { + "status": device.appliance.status, + "programs": await hass.async_add_executor_job( + device.appliance.get_programs_available + ), + } for device in hass.data[DOMAIN][config_entry.entry_id].devices } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index 29591c8d9ead3..b2d29380fae62 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -2,337 +2,413 @@ # name: test_async_get_config_entry_diagnostics dict({ 'BOSCH-000000000-000000000000': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', + 'programs': list([ + ]), + 'status': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), }), 'BOSCH-HCS000000-D00000000001': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', + 'programs': list([ + 'LaundryCare.WasherDryer.Program.Mix', + 'LaundryCare.Washer.Option.Temperature', + ]), + 'status': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), }), 'BOSCH-HCS000000-D00000000002': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', + 'programs': list([ + ]), + 'status': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), }), 'BOSCH-HCS000000-D00000000003': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', + 'programs': list([ + ]), + 'status': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), }), 'BOSCH-HCS000000-D00000000004': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ColorTemperature': dict({ - 'type': 'BSH.Common.EnumType.ColorTemperature', - 'value': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Cooking.Common.Setting.Lighting': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Cooking.Common.Setting.LightingBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Cooking.Hood.Setting.ColorTemperaturePercent': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', + 'programs': list([ + ]), + 'status': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': dict({ + 'type': 'Double', + 'unit': '%', + 'value': 70, + }), + 'BSH.Common.Setting.AmbientLightColor': dict({ + 'type': 'BSH.Common.EnumType.AmbientLightColor', + 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', + }), + 'BSH.Common.Setting.AmbientLightCustomColor': dict({ + 'type': 'String', + 'value': '#4a88f8', + }), + 'BSH.Common.Setting.AmbientLightEnabled': dict({ + 'type': 'Boolean', + 'value': True, + }), + 'BSH.Common.Setting.ColorTemperature': dict({ + 'type': 'BSH.Common.EnumType.ColorTemperature', + 'value': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', + }), + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Cooking.Common.Setting.Lighting': dict({ + 'type': 'Boolean', + 'value': True, + }), + 'Cooking.Common.Setting.LightingBrightness': dict({ + 'type': 'Double', + 'unit': '%', + 'value': 70, + }), + 'Cooking.Hood.Setting.ColorTemperaturePercent': dict({ + 'type': 'Double', + 'unit': '%', + 'value': 70, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), }), 'BOSCH-HCS000000-D00000000005': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', + 'programs': list([ + ]), + 'status': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), }), 'BOSCH-HCS000000-D00000000006': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', + 'programs': list([ + ]), + 'status': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), }), 'BOSCH-HCS01OVN1-43E0065FE245': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'Cooking.Oven.Program.HeatingMode.HotAir', - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', + 'programs': list([ + 'Cooking.Oven.Program.HeatingMode.HotAir', + 'Cooking.Oven.Program.HeatingMode.TopBottomHeating', + 'Cooking.Oven.Program.HeatingMode.PizzaSetting', + ]), + 'status': dict({ + 'BSH.Common.Root.ActiveProgram': dict({ + 'value': 'Cooking.Oven.Program.HeatingMode.HotAir', + }), + 'BSH.Common.Setting.PowerState': dict({ + 'type': 'BSH.Common.EnumType.PowerState', + 'value': 'BSH.Common.EnumType.PowerState.On', + }), + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), }), 'BOSCH-HCS04DYR1-831694AE3C5A': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', + 'programs': list([ + 'LaundryCare.Dryer.Program.Cotton', + 'LaundryCare.Dryer.Program.Synthetic', + 'LaundryCare.Dryer.Program.Mix', + ]), + 'status': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), }), 'BOSCH-HCS06COM1-D70390681C2C': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', + 'programs': list([ + 'ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso', + 'ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato', + 'ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee', + 'ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino', + 'ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato', + 'ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte', + ]), + 'status': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), }), 'SIEMENS-HCS02DWH1-6BE58C26DCC1': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', + 'programs': list([ + 'Dishcare.Dishwasher.Program.Auto1', + 'Dishcare.Dishwasher.Program.Auto2', + 'Dishcare.Dishwasher.Program.Auto3', + 'Dishcare.Dishwasher.Program.Eco50', + 'Dishcare.Dishwasher.Program.Quick45', + ]), + 'status': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': dict({ + 'type': 'Double', + 'unit': '%', + 'value': 70, + }), + 'BSH.Common.Setting.AmbientLightColor': dict({ + 'type': 'BSH.Common.EnumType.AmbientLightColor', + 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', + }), + 'BSH.Common.Setting.AmbientLightCustomColor': dict({ + 'type': 'String', + 'value': '#4a88f8', + }), + 'BSH.Common.Setting.AmbientLightEnabled': dict({ + 'type': 'Boolean', + 'value': True, + }), + 'BSH.Common.Setting.ChildLock': dict({ + 'type': 'Boolean', + 'value': False, + }), + 'BSH.Common.Setting.PowerState': dict({ + 'type': 'BSH.Common.EnumType.PowerState', + 'value': 'BSH.Common.EnumType.PowerState.On', + }), + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), }), 'SIEMENS-HCS03WCH1-7BC6383CF794': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'BSH.Common.Root.ActiveProgram', - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', + 'programs': list([ + 'LaundryCare.Washer.Program.Cotton', + 'LaundryCare.Washer.Program.EasyCare', + 'LaundryCare.Washer.Program.Mix', + 'LaundryCare.Washer.Program.DelicatesSilk', + 'LaundryCare.Washer.Program.Wool', + ]), + 'status': dict({ + 'BSH.Common.Root.ActiveProgram': dict({ + 'value': 'BSH.Common.Root.ActiveProgram', + }), + 'BSH.Common.Setting.ChildLock': dict({ + 'type': 'Boolean', + 'value': False, + }), + 'BSH.Common.Setting.PowerState': dict({ + 'type': 'BSH.Common.EnumType.PowerState', + 'value': 'BSH.Common.EnumType.PowerState.On', + }), + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), }), 'SIEMENS-HCS05FRF1-304F4F9E541D': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Setting.Dispenser.Enabled': dict({ - 'constraints': dict({ - 'access': 'readWrite', + 'programs': list([ + ]), + 'status': dict({ + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.Common.Setting.Light.External.Brightness': dict({ - 'constraints': dict({ - 'access': 'readWrite', - 'max': 100, - 'min': 0, - }), - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Setting.Light.External.Power': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': dict({ - 'constraints': dict({ - 'access': 'readWrite', + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': dict({ - 'constraints': dict({ - 'access': 'readWrite', + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Setting.Dispenser.Enabled': dict({ + 'constraints': dict({ + 'access': 'readWrite', + }), + 'type': 'Boolean', + 'value': False, + }), + 'Refrigeration.Common.Setting.Light.External.Brightness': dict({ + 'constraints': dict({ + 'access': 'readWrite', + 'max': 100, + 'min': 0, + }), + 'type': 'Double', + 'unit': '%', + 'value': 70, + }), + 'Refrigeration.Common.Setting.Light.External.Power': dict({ + 'type': 'Boolean', + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': dict({ + 'constraints': dict({ + 'access': 'readWrite', + }), + 'type': 'Boolean', + 'value': False, + }), + 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': dict({ + 'constraints': dict({ + 'access': 'readWrite', + }), + 'type': 'Boolean', + 'value': False, }), - 'type': 'Boolean', - 'value': False, }), }), }) From 2cfacd8bc540185d2baacdd9eb8a494223605003 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Nov 2024 09:30:06 +0100 Subject: [PATCH 0596/1070] Add button platform to sabnzbd and deprecate custom actions (#130999) --- homeassistant/components/sabnzbd/__init__.py | 21 +++- homeassistant/components/sabnzbd/button.py | 69 +++++++++++ homeassistant/components/sabnzbd/icons.json | 10 ++ homeassistant/components/sabnzbd/strings.json | 23 ++++ tests/components/sabnzbd/conftest.py | 9 +- .../sabnzbd/snapshots/test_button.ambr | 93 ++++++++++++++ tests/components/sabnzbd/test_button.py | 116 ++++++++++++++++++ tests/components/sabnzbd/test_init.py | 45 ++++++- tests/components/sabnzbd/test_sensor.py | 7 +- 9 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/sabnzbd/button.py create mode 100644 tests/components/sabnzbd/snapshots/test_button.ambr create mode 100644 tests/components/sabnzbd/test_button.py diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 3182e628a6bc3..92b596ae2880d 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -22,6 +22,7 @@ from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries +import homeassistant.helpers.issue_registry as ir from homeassistant.helpers.typing import ConfigType from .const import ( @@ -41,7 +42,7 @@ from .sab import get_client from .sensor import OLD_SENSOR_KEYS -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) SERVICES = ( @@ -204,12 +205,30 @@ async def wrapper(call: ServiceCall) -> None: async def async_pause_queue( call: ServiceCall, coordinator: SabnzbdUpdateCoordinator ) -> None: + ir.async_create_issue( + hass, + DOMAIN, + "pause_action_deprecated", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + breaks_in_ha_version="2025.6", + translation_key="pause_action_deprecated", + ) await coordinator.sab_api.pause_queue() @extract_api async def async_resume_queue( call: ServiceCall, coordinator: SabnzbdUpdateCoordinator ) -> None: + ir.async_create_issue( + hass, + DOMAIN, + "resume_action_deprecated", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + breaks_in_ha_version="2025.6", + translation_key="resume_action_deprecated", + ) await coordinator.sab_api.resume_queue() @extract_api diff --git a/homeassistant/components/sabnzbd/button.py b/homeassistant/components/sabnzbd/button.py new file mode 100644 index 0000000000000..4efecba18a7db --- /dev/null +++ b/homeassistant/components/sabnzbd/button.py @@ -0,0 +1,69 @@ +"""Button platform for the SABnzbd component.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from pysabnzbd import SabnzbdApiException + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SabnzbdUpdateCoordinator +from .const import DOMAIN +from .entity import SabnzbdEntity + + +@dataclass(kw_only=True, frozen=True) +class SabnzbdButtonEntityDescription(ButtonEntityDescription): + """Describes SABnzbd button entity.""" + + press_fn: Callable[[SabnzbdUpdateCoordinator], Any] + + +BUTTON_DESCRIPTIONS: tuple[SabnzbdButtonEntityDescription, ...] = ( + SabnzbdButtonEntityDescription( + key="pause", + translation_key="pause", + press_fn=lambda coordinator: coordinator.sab_api.pause_queue(), + ), + SabnzbdButtonEntityDescription( + key="resume", + translation_key="resume", + press_fn=lambda coordinator: coordinator.sab_api.resume_queue(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up buttons from a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + SabnzbdButton(coordinator, description) for description in BUTTON_DESCRIPTIONS + ) + + +class SabnzbdButton(SabnzbdEntity, ButtonEntity): + """Representation of a SABnzbd button.""" + + entity_description: SabnzbdButtonEntityDescription + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.entity_description.press_fn(self.coordinator) + except SabnzbdApiException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + ) from e + else: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/sabnzbd/icons.json b/homeassistant/components/sabnzbd/icons.json index ca4f4d584ae1f..78eff1f418337 100644 --- a/homeassistant/components/sabnzbd/icons.json +++ b/homeassistant/components/sabnzbd/icons.json @@ -1,4 +1,14 @@ { + "entity": { + "button": { + "pause": { + "default": "mdi:pause" + }, + "resume": { + "default": "mdi:play" + } + } + }, "services": { "pause": { "service": "mdi:pause" diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 5b7312e3b0d70..3162aab60ecc2 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -18,6 +18,14 @@ } }, "entity": { + "button": { + "pause": { + "name": "[%key:common::action::pause%]" + }, + "resume": { + "name": "[%key:component::sabnzbd::services::resume::name%]" + } + }, "sensor": { "status": { "name": "Status" @@ -89,5 +97,20 @@ } } } + }, + "issues": { + "pause_action_deprecated": { + "title": "SABnzbd pause action deprecated", + "description": "The 'Pause' action is deprecated and will be removed in a future version. Please use the 'Pause' button instead. To remove this issue, please adjust automations accordingly and restart Home Assistant." + }, + "resume_action_deprecated": { + "title": "SABnzbd resume action deprecated", + "description": "The 'Resume' action is deprecated and will be removed in a future version. Please use the 'Resume' button instead. To remove this issue, please adjust automations accordingly and restart Home Assistant." + } + }, + "exceptions": { + "service_call_exception": { + "message": "Unable to send command to SABnzbd due to a connection error, try again later" + } } } diff --git a/tests/components/sabnzbd/conftest.py b/tests/components/sabnzbd/conftest.py index 68b17bbfeacc6..fc01429378b90 100644 --- a/tests/components/sabnzbd/conftest.py +++ b/tests/components/sabnzbd/conftest.py @@ -35,9 +35,9 @@ def mock_sabnzbd() -> Generator[AsyncMock]: @pytest.fixture(name="config_entry") -async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: +async def mock_config_entry(hass: HomeAssistant, sabnzbd: AsyncMock) -> MockConfigEntry: """Return a MockConfigEntry for testing.""" - return MockConfigEntry( + config_entry = MockConfigEntry( domain=DOMAIN, title="Sabnzbd", entry_id="01JD2YVVPBC62D620DGYNG2R8H", @@ -47,6 +47,9 @@ async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_URL: "http://localhost:8080", }, ) + config_entry.add_to_hass(hass) + + return config_entry @pytest.fixture(name="setup_integration") @@ -54,7 +57,5 @@ async def mock_setup_integration( hass: HomeAssistant, config_entry: MockConfigEntry, sabnzbd: AsyncMock ) -> None: """Fixture for setting up the component.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/sabnzbd/snapshots/test_button.ambr b/tests/components/sabnzbd/snapshots/test_button.ambr new file mode 100644 index 0000000000000..9b965e10518c2 --- /dev/null +++ b/tests/components/sabnzbd/snapshots/test_button.ambr @@ -0,0 +1,93 @@ +# serializer version: 1 +# name: test_button_setup[button.sabnzbd_pause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.sabnzbd_pause', + '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': 'Pause', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pause', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_pause', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_setup[button.sabnzbd_pause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sabnzbd Pause', + }), + 'context': , + 'entity_id': 'button.sabnzbd_pause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_setup[button.sabnzbd_resume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.sabnzbd_resume', + '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': 'Resume', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'resume', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_resume', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_setup[button.sabnzbd_resume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sabnzbd Resume', + }), + 'context': , + 'entity_id': 'button.sabnzbd_resume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sabnzbd/test_button.py b/tests/components/sabnzbd/test_button.py new file mode 100644 index 0000000000000..199d8eb03a088 --- /dev/null +++ b/tests/components/sabnzbd/test_button.py @@ -0,0 +1,116 @@ +"""Button tests for the SABnzbd component.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pysabnzbd import SabnzbdApiException +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@patch("homeassistant.components.sabnzbd.PLATFORMS", [Platform.BUTTON]) +async def test_button_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test button setup.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("button", "called_function"), + [("resume", "resume_queue"), ("pause", "pause_queue")], +) +@pytest.mark.usefixtures("setup_integration") +async def test_button_presses( + hass: HomeAssistant, + sabnzbd: AsyncMock, + button: str, + called_function: str, +) -> None: + """Test the sabnzbd button presses.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: f"button.sabnzbd_{button}", + }, + blocking=True, + ) + + function = getattr(sabnzbd, called_function) + function.assert_called_once() + + +@pytest.mark.parametrize( + ("button", "called_function"), + [("resume", "resume_queue"), ("pause", "pause_queue")], +) +@pytest.mark.usefixtures("setup_integration") +async def test_buttons_exception( + hass: HomeAssistant, + sabnzbd: AsyncMock, + button: str, + called_function: str, +) -> None: + """Test the button handles errors.""" + function = getattr(sabnzbd, called_function) + function.side_effect = SabnzbdApiException("Boom") + + with pytest.raises( + HomeAssistantError, + match="Unable to send command to SABnzbd due to a connection error, try again later", + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: f"button.sabnzbd_{button}", + }, + blocking=True, + ) + + function.assert_called_once() + + +@pytest.mark.parametrize( + "button", + ["resume", "pause"], +) +@pytest.mark.usefixtures("setup_integration") +async def test_buttons_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + sabnzbd: AsyncMock, + button: str, +) -> None: + """Test the button is unavailable when coordinator can't update data.""" + state = hass.states.get(f"button.sabnzbd_{button}") + assert state + assert state.state == STATE_UNKNOWN + + sabnzbd.refresh_data.side_effect = Exception("Boom") + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(f"button.sabnzbd_{button}") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/sabnzbd/test_init.py b/tests/components/sabnzbd/test_init.py index e666f9f1d3e97..8c77191825986 100644 --- a/tests/components/sabnzbd/test_init.py +++ b/tests/components/sabnzbd/test_init.py @@ -2,11 +2,24 @@ from unittest.mock import patch -from homeassistant.components.sabnzbd import DEFAULT_NAME, DOMAIN, OLD_SENSOR_KEYS +import pytest + +from homeassistant.components.sabnzbd import ( + ATTR_API_KEY, + DEFAULT_NAME, + DOMAIN, + OLD_SENSOR_KEYS, + SERVICE_PAUSE, + SERVICE_RESUME, +) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from tests.common import MockConfigEntry @@ -75,3 +88,31 @@ async def test_unique_id_migrate( assert device_registry.async_get(mock_d_entry.id).identifiers == { (DOMAIN, MOCK_ENTRY_ID) } + + +@pytest.mark.parametrize( + ("service", "issue_id"), + [ + (SERVICE_RESUME, "resume_action_deprecated"), + (SERVICE_PAUSE, "pause_action_deprecated"), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_deprecated_service_creates_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + service: str, + issue_id: str, +) -> None: + """Test that deprecated actions creates an issue.""" + await hass.services.async_call( + DOMAIN, + service, + {ATTR_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0"}, + blocking=True, + ) + + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + assert issue + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.breaks_in_ha_version == "2025.6" diff --git a/tests/components/sabnzbd/test_sensor.py b/tests/components/sabnzbd/test_sensor.py index aade644072c59..31c0868a5a7ce 100644 --- a/tests/components/sabnzbd/test_sensor.py +++ b/tests/components/sabnzbd/test_sensor.py @@ -1,15 +1,19 @@ """Sensor tests for the Sabnzbd component.""" +from unittest.mock import patch + import pytest from syrupy import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_integration") +@patch("homeassistant.components.sabnzbd.PLATFORMS", [Platform.SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -17,4 +21,5 @@ async def test_sensor( snapshot: SnapshotAssertion, ) -> None: """Test sensor setup.""" + await hass.config_entries.async_setup(config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) From 621c66a2147ee4fff784ee75767fa784681aca76 Mon Sep 17 00:00:00 2001 From: Myles Eftos Date: Wed, 20 Nov 2024 21:27:24 +1100 Subject: [PATCH 0597/1070] Update Amberelectric to use amberelectric version 2.0.12 (#125701) * Add price descriptor attribute to price sensors * Adding price descriptor sensor * Use correct number of sensors in spike sensor tests * Add tests for normalize_descriptor * Removing debug message * Removing price_descriptor attribute from the current sensor * Refactoring everything to use the new API * Use SiteStatus object, fix some typnig issues * fixing test * Adding predicted price to attributes * Fix advanced price in forecast * Testing advanced forecasts * WIP: Adding advanced forecast sensor. need to add attributes, and tests * Add advanced price attributes * Adding forecasts to the advanced price sensor * Appending forecasts corectly * Appending forecasts correctly. Again * Removing sensor for the moment. Will do in another PR * Fix failing test that had the wrong sign * Adding test to improve coverage on config_flow test * Bumping amberelectric dependency to version 2 * Remove advanced code from helpers * Use f-strings * Bumping to version 2.0.1 * Bumping amberelectric to version 2.0.2 * Bumping amberelectric to version 2.0.2 * Bumping verion amberelectric.py to 2.0.3. Using correct enums * Bumping amberelectric.py version to 2.0.4 * Bump version to 2.0.5 * Fix formatting * fixing mocks to include interval_length * Bumping to 2.0.6 * Bumping to 2.0.7 * Bumping to 2.0.8 * Bumping to 2.0.9 * Bumping version 2.0.12 --- .../components/amberelectric/__init__.py | 8 +- .../components/amberelectric/config_flow.py | 19 +- .../components/amberelectric/coordinator.py | 33 +-- .../components/amberelectric/manifest.json | 2 +- .../components/amberelectric/sensor.py | 20 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/amberelectric/helpers.py | 121 ++++++----- .../amberelectric/test_binary_sensor.py | 40 ++-- .../amberelectric/test_config_flow.py | 142 +++++++++---- .../amberelectric/test_coordinator.py | 194 ++++++++++-------- tests/components/amberelectric/test_sensor.py | 23 ++- 12 files changed, 352 insertions(+), 254 deletions(-) diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py index cd44886c9ef5d..29d8f166f2a7d 100644 --- a/homeassistant/components/amberelectric/__init__.py +++ b/homeassistant/components/amberelectric/__init__.py @@ -1,7 +1,6 @@ """Support for Amber Electric.""" -from amberelectric import Configuration -from amberelectric.api import amber_api +import amberelectric from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN @@ -15,8 +14,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool: """Set up Amber Electric from a config entry.""" - configuration = Configuration(access_token=entry.data[CONF_API_TOKEN]) - api_instance = amber_api.AmberApi.create(configuration) + configuration = amberelectric.Configuration(access_token=entry.data[CONF_API_TOKEN]) + api_client = amberelectric.ApiClient(configuration) + api_instance = amberelectric.AmberApi(api_client) site_id = entry.data[CONF_SITE_ID] coordinator = AmberUpdateCoordinator(hass, api_instance, site_id) diff --git a/homeassistant/components/amberelectric/config_flow.py b/homeassistant/components/amberelectric/config_flow.py index a94700c27d16b..c25258e2e3361 100644 --- a/homeassistant/components/amberelectric/config_flow.py +++ b/homeassistant/components/amberelectric/config_flow.py @@ -3,8 +3,8 @@ from __future__ import annotations import amberelectric -from amberelectric.api import amber_api -from amberelectric.model.site import Site, SiteStatus +from amberelectric.models.site import Site +from amberelectric.models.site_status import SiteStatus import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -23,11 +23,15 @@ def generate_site_selector_name(site: Site) -> str: """Generate the name to show in the site drop down in the configuration flow.""" + # For some reason the generated API key returns this as any, not a string. Thanks pydantic + nmi = str(site.nmi) if site.status == SiteStatus.CLOSED: - return site.nmi + " (Closed: " + site.closed_on.isoformat() + ")" # type: ignore[no-any-return] + if site.closed_on is None: + return f"{nmi} (Closed)" + return f"{nmi} (Closed: {site.closed_on.isoformat()})" if site.status == SiteStatus.PENDING: - return site.nmi + " (Pending)" # type: ignore[no-any-return] - return site.nmi # type: ignore[no-any-return] + return f"{nmi} (Pending)" + return nmi def filter_sites(sites: list[Site]) -> list[Site]: @@ -35,7 +39,7 @@ def filter_sites(sites: list[Site]) -> list[Site]: filtered: list[Site] = [] filtered_nmi: set[str] = set() - for site in sorted(sites, key=lambda site: site.status.value): + for site in sorted(sites, key=lambda site: site.status): if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi: filtered.append(site) filtered_nmi.add(site.nmi) @@ -56,7 +60,8 @@ def __init__(self) -> None: def _fetch_sites(self, token: str) -> list[Site] | None: configuration = amberelectric.Configuration(access_token=token) - api: amber_api.AmberApi = amber_api.AmberApi.create(configuration) + api_client = amberelectric.ApiClient(configuration) + api = amberelectric.AmberApi(api_client) try: sites: list[Site] = filter_sites(api.get_sites()) diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py index a95aa3fa52931..57028e07d21d4 100644 --- a/homeassistant/components/amberelectric/coordinator.py +++ b/homeassistant/components/amberelectric/coordinator.py @@ -5,13 +5,13 @@ from datetime import timedelta from typing import Any -from amberelectric import ApiException -from amberelectric.api import amber_api -from amberelectric.model.actual_interval import ActualInterval -from amberelectric.model.channel import ChannelType -from amberelectric.model.current_interval import CurrentInterval -from amberelectric.model.forecast_interval import ForecastInterval -from amberelectric.model.interval import Descriptor +import amberelectric +from amberelectric.models.actual_interval import ActualInterval +from amberelectric.models.channel import ChannelType +from amberelectric.models.current_interval import CurrentInterval +from amberelectric.models.forecast_interval import ForecastInterval +from amberelectric.models.price_descriptor import PriceDescriptor +from amberelectric.rest import ApiException from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -31,22 +31,22 @@ def is_forecast(interval: ActualInterval | CurrentInterval | ForecastInterval) - def is_general(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: """Return true if the supplied interval is on the general channel.""" - return interval.channel_type == ChannelType.GENERAL # type: ignore[no-any-return] + return interval.channel_type == ChannelType.GENERAL def is_controlled_load( interval: ActualInterval | CurrentInterval | ForecastInterval, ) -> bool: """Return true if the supplied interval is on the controlled load channel.""" - return interval.channel_type == ChannelType.CONTROLLED_LOAD # type: ignore[no-any-return] + return interval.channel_type == ChannelType.CONTROLLEDLOAD def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: """Return true if the supplied interval is on the feed in channel.""" - return interval.channel_type == ChannelType.FEED_IN # type: ignore[no-any-return] + return interval.channel_type == ChannelType.FEEDIN -def normalize_descriptor(descriptor: Descriptor) -> str | None: +def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None: """Return the snake case versions of descriptor names. Returns None if the name is not recognized.""" if descriptor is None: return None @@ -71,7 +71,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator): """AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read.""" def __init__( - self, hass: HomeAssistant, api: amber_api.AmberApi, site_id: str + self, hass: HomeAssistant, api: amberelectric.AmberApi, site_id: str ) -> None: """Initialise the data service.""" super().__init__( @@ -93,12 +93,13 @@ def update_price_data(self) -> dict[str, dict[str, Any]]: "grid": {}, } try: - data = self._api.get_current_price(self.site_id, next=48) + data = self._api.get_current_prices(self.site_id, next=48) + intervals = [interval.actual_instance for interval in data] except ApiException as api_exception: raise UpdateFailed("Missing price data, skipping update") from api_exception - current = [interval for interval in data if is_current(interval)] - forecasts = [interval for interval in data if is_forecast(interval)] + current = [interval for interval in intervals if is_current(interval)] + forecasts = [interval for interval in intervals if is_forecast(interval)] general = [interval for interval in current if is_general(interval)] if len(general) == 0: @@ -137,7 +138,7 @@ def update_price_data(self) -> dict[str, dict[str, Any]]: interval for interval in forecasts if is_feed_in(interval) ] - LOGGER.debug("Fetched new Amber data: %s", data) + LOGGER.debug("Fetched new Amber data: %s", intervals) return result async def _async_update_data(self) -> dict[str, Any]: diff --git a/homeassistant/components/amberelectric/manifest.json b/homeassistant/components/amberelectric/manifest.json index 51be42cfa688e..401eb1629a1e9 100644 --- a/homeassistant/components/amberelectric/manifest.json +++ b/homeassistant/components/amberelectric/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/amberelectric", "iot_class": "cloud_polling", "loggers": ["amberelectric"], - "requirements": ["amberelectric==1.1.1"] + "requirements": ["amberelectric==2.0.12"] } diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 52c0c42e7bcdc..cdf40e5804dea 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -8,9 +8,9 @@ from typing import Any -from amberelectric.model.channel import ChannelType -from amberelectric.model.current_interval import CurrentInterval -from amberelectric.model.forecast_interval import ForecastInterval +from amberelectric.models.channel import ChannelType +from amberelectric.models.current_interval import CurrentInterval +from amberelectric.models.forecast_interval import ForecastInterval from homeassistant.components.sensor import ( SensorEntity, @@ -52,7 +52,7 @@ def __init__( self, coordinator: AmberUpdateCoordinator, description: SensorEntityDescription, - channel_type: ChannelType, + channel_type: str, ) -> None: """Initialize the Sensor.""" super().__init__(coordinator) @@ -73,7 +73,7 @@ def native_value(self) -> float | None: """Return the current price in $/kWh.""" interval = self.coordinator.data[self.entity_description.key][self.channel_type] - if interval.channel_type == ChannelType.FEED_IN: + if interval.channel_type == ChannelType.FEEDIN: return format_cents_to_dollars(interval.per_kwh) * -1 return format_cents_to_dollars(interval.per_kwh) @@ -87,9 +87,9 @@ def extra_state_attributes(self) -> dict[str, Any] | None: return data data["duration"] = interval.duration - data["date"] = interval.date.isoformat() + data["date"] = interval.var_date.isoformat() data["per_kwh"] = format_cents_to_dollars(interval.per_kwh) - if interval.channel_type == ChannelType.FEED_IN: + if interval.channel_type == ChannelType.FEEDIN: data["per_kwh"] = data["per_kwh"] * -1 data["nem_date"] = interval.nem_time.isoformat() data["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh) @@ -120,7 +120,7 @@ def native_value(self) -> float | None: return None interval = intervals[0] - if interval.channel_type == ChannelType.FEED_IN: + if interval.channel_type == ChannelType.FEEDIN: return format_cents_to_dollars(interval.per_kwh) * -1 return format_cents_to_dollars(interval.per_kwh) @@ -142,10 +142,10 @@ def extra_state_attributes(self) -> dict[str, Any] | None: for interval in intervals: datum = {} datum["duration"] = interval.duration - datum["date"] = interval.date.isoformat() + datum["date"] = interval.var_date.isoformat() datum["nem_date"] = interval.nem_time.isoformat() datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh) - if interval.channel_type == ChannelType.FEED_IN: + if interval.channel_type == ChannelType.FEEDIN: datum["per_kwh"] = datum["per_kwh"] * -1 datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh) datum["start_time"] = interval.start_time.isoformat() diff --git a/requirements_all.txt b/requirements_all.txt index 6e5ec19d2537c..fe4786562b6ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -447,7 +447,7 @@ airtouch5py==0.2.10 alpha-vantage==2.3.1 # homeassistant.components.amberelectric -amberelectric==1.1.1 +amberelectric==2.0.12 # homeassistant.components.amcrest amcrest==1.9.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f70132e05963..063eedd7a95e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -426,7 +426,7 @@ airtouch4pyapi==1.0.5 airtouch5py==0.2.10 # homeassistant.components.amberelectric -amberelectric==1.1.1 +amberelectric==2.0.12 # homeassistant.components.androidtv androidtv[async]==0.0.73 diff --git a/tests/components/amberelectric/helpers.py b/tests/components/amberelectric/helpers.py index 2bc65fdd558dc..971f3690a0d81 100644 --- a/tests/components/amberelectric/helpers.py +++ b/tests/components/amberelectric/helpers.py @@ -2,73 +2,82 @@ from datetime import datetime, timedelta -from amberelectric.model.actual_interval import ActualInterval -from amberelectric.model.channel import ChannelType -from amberelectric.model.current_interval import CurrentInterval -from amberelectric.model.forecast_interval import ForecastInterval -from amberelectric.model.interval import Descriptor, SpikeStatus +from amberelectric.models.actual_interval import ActualInterval +from amberelectric.models.channel import ChannelType +from amberelectric.models.current_interval import CurrentInterval +from amberelectric.models.forecast_interval import ForecastInterval +from amberelectric.models.interval import Interval +from amberelectric.models.price_descriptor import PriceDescriptor +from amberelectric.models.spike_status import SpikeStatus from dateutil import parser -def generate_actual_interval( - channel_type: ChannelType, end_time: datetime -) -> ActualInterval: +def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> Interval: """Generate a mock actual interval.""" start_time = end_time - timedelta(minutes=30) - return ActualInterval( - duration=30, - spot_per_kwh=1.0, - per_kwh=8.0, - date=start_time.date(), - nem_time=end_time, - start_time=start_time, - end_time=end_time, - renewables=50, - channel_type=channel_type.value, - spike_status=SpikeStatus.NO_SPIKE.value, - descriptor=Descriptor.LOW.value, + return Interval( + ActualInterval( + type="ActualInterval", + duration=30, + spot_per_kwh=1.0, + per_kwh=8.0, + date=start_time.date(), + nem_time=end_time, + start_time=start_time, + end_time=end_time, + renewables=50, + channel_type=channel_type, + spike_status=SpikeStatus.NONE, + descriptor=PriceDescriptor.LOW, + ) ) def generate_current_interval( channel_type: ChannelType, end_time: datetime -) -> CurrentInterval: +) -> Interval: """Generate a mock current price.""" start_time = end_time - timedelta(minutes=30) - return CurrentInterval( - duration=30, - spot_per_kwh=1.0, - per_kwh=8.0, - date=start_time.date(), - nem_time=end_time, - start_time=start_time, - end_time=end_time, - renewables=50.6, - channel_type=channel_type.value, - spike_status=SpikeStatus.NO_SPIKE.value, - descriptor=Descriptor.EXTREMELY_LOW.value, - estimate=True, + return Interval( + CurrentInterval( + type="CurrentInterval", + duration=30, + spot_per_kwh=1.0, + per_kwh=8.0, + date=start_time.date(), + nem_time=end_time, + start_time=start_time, + end_time=end_time, + renewables=50.6, + channel_type=channel_type, + spike_status=SpikeStatus.NONE, + descriptor=PriceDescriptor.EXTREMELYLOW, + estimate=True, + ) ) def generate_forecast_interval( channel_type: ChannelType, end_time: datetime -) -> ForecastInterval: +) -> Interval: """Generate a mock forecast interval.""" start_time = end_time - timedelta(minutes=30) - return ForecastInterval( - duration=30, - spot_per_kwh=1.1, - per_kwh=8.8, - date=start_time.date(), - nem_time=end_time, - start_time=start_time, - end_time=end_time, - renewables=50, - channel_type=channel_type.value, - spike_status=SpikeStatus.NO_SPIKE.value, - descriptor=Descriptor.VERY_LOW.value, - estimate=True, + return Interval( + ForecastInterval( + type="ForecastInterval", + duration=30, + spot_per_kwh=1.1, + per_kwh=8.8, + date=start_time.date(), + nem_time=end_time, + start_time=start_time, + end_time=end_time, + renewables=50, + channel_type=channel_type, + spike_status=SpikeStatus.NONE, + descriptor=PriceDescriptor.VERYLOW, + estimate=True, + ) ) @@ -94,31 +103,31 @@ def generate_forecast_interval( CONTROLLED_LOAD_CHANNEL = [ generate_current_interval( - ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T08:30:00+10:00") + ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00") ), generate_forecast_interval( - ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T09:00:00+10:00") + ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T09:00:00+10:00") ), generate_forecast_interval( - ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T09:30:00+10:00") + ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T09:30:00+10:00") ), generate_forecast_interval( - ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T10:00:00+10:00") + ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T10:00:00+10:00") ), ] FEED_IN_CHANNEL = [ generate_current_interval( - ChannelType.FEED_IN, parser.parse("2021-09-21T08:30:00+10:00") + ChannelType.FEEDIN, parser.parse("2021-09-21T08:30:00+10:00") ), generate_forecast_interval( - ChannelType.FEED_IN, parser.parse("2021-09-21T09:00:00+10:00") + ChannelType.FEEDIN, parser.parse("2021-09-21T09:00:00+10:00") ), generate_forecast_interval( - ChannelType.FEED_IN, parser.parse("2021-09-21T09:30:00+10:00") + ChannelType.FEEDIN, parser.parse("2021-09-21T09:30:00+10:00") ), generate_forecast_interval( - ChannelType.FEED_IN, parser.parse("2021-09-21T10:00:00+10:00") + ChannelType.FEEDIN, parser.parse("2021-09-21T10:00:00+10:00") ), ] diff --git a/tests/components/amberelectric/test_binary_sensor.py b/tests/components/amberelectric/test_binary_sensor.py index 2c1ee22b64478..6a6ca372bc27b 100644 --- a/tests/components/amberelectric/test_binary_sensor.py +++ b/tests/components/amberelectric/test_binary_sensor.py @@ -5,10 +5,10 @@ from collections.abc import AsyncGenerator from unittest.mock import Mock, patch -from amberelectric.model.channel import ChannelType -from amberelectric.model.current_interval import CurrentInterval -from amberelectric.model.interval import SpikeStatus -from amberelectric.model.tariff_information import TariffInformation +from amberelectric.models.channel import ChannelType +from amberelectric.models.current_interval import CurrentInterval +from amberelectric.models.spike_status import SpikeStatus +from amberelectric.models.tariff_information import TariffInformation from dateutil import parser import pytest @@ -42,10 +42,10 @@ async def setup_no_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]: instance = Mock() with patch( - "amberelectric.api.AmberApi.create", + "amberelectric.AmberApi", return_value=instance, ) as mock_update: - instance.get_current_price = Mock(return_value=GENERAL_CHANNEL) + instance.get_current_prices = Mock(return_value=GENERAL_CHANNEL) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() yield mock_update.return_value @@ -65,7 +65,7 @@ async def setup_potential_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]: instance = Mock() with patch( - "amberelectric.api.AmberApi.create", + "amberelectric.AmberApi", return_value=instance, ) as mock_update: general_channel: list[CurrentInterval] = [ @@ -73,8 +73,8 @@ async def setup_potential_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]: ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") ), ] - general_channel[0].spike_status = SpikeStatus.POTENTIAL - instance.get_current_price = Mock(return_value=general_channel) + general_channel[0].actual_instance.spike_status = SpikeStatus.POTENTIAL + instance.get_current_prices = Mock(return_value=general_channel) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() yield mock_update.return_value @@ -94,7 +94,7 @@ async def setup_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]: instance = Mock() with patch( - "amberelectric.api.AmberApi.create", + "amberelectric.AmberApi", return_value=instance, ) as mock_update: general_channel: list[CurrentInterval] = [ @@ -102,8 +102,8 @@ async def setup_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]: ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") ), ] - general_channel[0].spike_status = SpikeStatus.SPIKE - instance.get_current_price = Mock(return_value=general_channel) + general_channel[0].actual_instance.spike_status = SpikeStatus.SPIKE + instance.get_current_prices = Mock(return_value=general_channel) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() yield mock_update.return_value @@ -156,7 +156,7 @@ async def setup_inactive_demand_window(hass: HomeAssistant) -> AsyncGenerator[Mo instance = Mock() with patch( - "amberelectric.api.AmberApi.create", + "amberelectric.AmberApi", return_value=instance, ) as mock_update: general_channel: list[CurrentInterval] = [ @@ -164,8 +164,10 @@ async def setup_inactive_demand_window(hass: HomeAssistant) -> AsyncGenerator[Mo ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") ), ] - general_channel[0].tariff_information = TariffInformation(demandWindow=False) - instance.get_current_price = Mock(return_value=general_channel) + general_channel[0].actual_instance.tariff_information = TariffInformation( + demandWindow=False + ) + instance.get_current_prices = Mock(return_value=general_channel) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() yield mock_update.return_value @@ -185,7 +187,7 @@ async def setup_active_demand_window(hass: HomeAssistant) -> AsyncGenerator[Mock instance = Mock() with patch( - "amberelectric.api.AmberApi.create", + "amberelectric.AmberApi", return_value=instance, ) as mock_update: general_channel: list[CurrentInterval] = [ @@ -193,8 +195,10 @@ async def setup_active_demand_window(hass: HomeAssistant) -> AsyncGenerator[Mock ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") ), ] - general_channel[0].tariff_information = TariffInformation(demandWindow=True) - instance.get_current_price = Mock(return_value=general_channel) + general_channel[0].actual_instance.tariff_information = TariffInformation( + demandWindow=True + ) + instance.get_current_prices = Mock(return_value=general_channel) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() yield mock_update.return_value diff --git a/tests/components/amberelectric/test_config_flow.py b/tests/components/amberelectric/test_config_flow.py index 030b82d359685..b394977b0e87c 100644 --- a/tests/components/amberelectric/test_config_flow.py +++ b/tests/components/amberelectric/test_config_flow.py @@ -5,7 +5,8 @@ from unittest.mock import Mock, patch from amberelectric import ApiException -from amberelectric.model.site import Site, SiteStatus +from amberelectric.models.site import Site +from amberelectric.models.site_status import SiteStatus import pytest from homeassistant.components.amberelectric.config_flow import filter_sites @@ -28,7 +29,7 @@ def mock_invalid_key_api() -> Generator: """Return an authentication error.""" - with patch("amberelectric.api.AmberApi.create") as mock: + with patch("amberelectric.AmberApi") as mock: mock.return_value.get_sites.side_effect = ApiException(status=403) yield mock @@ -36,7 +37,7 @@ def mock_invalid_key_api() -> Generator: @pytest.fixture(name="api_error") def mock_api_error() -> Generator: """Return an authentication error.""" - with patch("amberelectric.api.AmberApi.create") as mock: + with patch("amberelectric.AmberApi") as mock: mock.return_value.get_sites.side_effect = ApiException(status=500) yield mock @@ -45,16 +46,36 @@ def mock_api_error() -> Generator: def mock_single_site_api() -> Generator: """Return a single site.""" site = Site( - "01FG0AGP818PXK0DWHXJRRT2DH", - "11111111111", - [], - "Jemena", - SiteStatus.ACTIVE, - date(2002, 1, 1), - None, + id="01FG0AGP818PXK0DWHXJRRT2DH", + nmi="11111111111", + channels=[], + network="Jemena", + status=SiteStatus.ACTIVE, + active_from=date(2002, 1, 1), + closed_on=None, + interval_length=30, ) - with patch("amberelectric.api.AmberApi.create") as mock: + with patch("amberelectric.AmberApi") as mock: + mock.return_value.get_sites.return_value = [site] + yield mock + + +@pytest.fixture(name="single_site_closed_no_close_date_api") +def single_site_closed_no_close_date_api() -> Generator: + """Return a single closed site with no closed date.""" + site = Site( + id="01FG0AGP818PXK0DWHXJRRT2DH", + nmi="11111111111", + channels=[], + network="Jemena", + status=SiteStatus.CLOSED, + active_from=None, + closed_on=None, + interval_length=30, + ) + + with patch("amberelectric.AmberApi") as mock: mock.return_value.get_sites.return_value = [site] yield mock @@ -63,16 +84,17 @@ def mock_single_site_api() -> Generator: def mock_single_site_pending_api() -> Generator: """Return a single site.""" site = Site( - "01FG0AGP818PXK0DWHXJRRT2DH", - "11111111111", - [], - "Jemena", - SiteStatus.PENDING, - None, - None, + id="01FG0AGP818PXK0DWHXJRRT2DH", + nmi="11111111111", + channels=[], + network="Jemena", + status=SiteStatus.PENDING, + active_from=None, + closed_on=None, + interval_length=30, ) - with patch("amberelectric.api.AmberApi.create") as mock: + with patch("amberelectric.AmberApi") as mock: mock.return_value.get_sites.return_value = [site] yield mock @@ -82,35 +104,38 @@ def mock_single_site_rejoin_api() -> Generator: """Return a single site.""" instance = Mock() site_1 = Site( - "01HGD9QB72HB3DWQNJ6SSCGXGV", - "11111111111", - [], - "Jemena", - SiteStatus.CLOSED, - date(2002, 1, 1), - date(2002, 6, 1), + id="01HGD9QB72HB3DWQNJ6SSCGXGV", + nmi="11111111111", + channels=[], + network="Jemena", + status=SiteStatus.CLOSED, + active_from=date(2002, 1, 1), + closed_on=date(2002, 6, 1), + interval_length=30, ) site_2 = Site( - "01FG0AGP818PXK0DWHXJRRT2DH", - "11111111111", - [], - "Jemena", - SiteStatus.ACTIVE, - date(2003, 1, 1), - None, + id="01FG0AGP818PXK0DWHXJRRT2DH", + nmi="11111111111", + channels=[], + network="Jemena", + status=SiteStatus.ACTIVE, + active_from=date(2003, 1, 1), + closed_on=None, + interval_length=30, ) site_3 = Site( - "01FG0AGP818PXK0DWHXJRRT2DH", - "11111111112", - [], - "Jemena", - SiteStatus.CLOSED, - date(2003, 1, 1), - date(2003, 6, 1), + id="01FG0AGP818PXK0DWHXJRRT2DH", + nmi="11111111112", + channels=[], + network="Jemena", + status=SiteStatus.CLOSED, + active_from=date(2003, 1, 1), + closed_on=date(2003, 6, 1), + interval_length=30, ) instance.get_sites.return_value = [site_1, site_2, site_3] - with patch("amberelectric.api.AmberApi.create", return_value=instance): + with patch("amberelectric.AmberApi", return_value=instance): yield instance @@ -120,7 +145,7 @@ def mock_no_site_api() -> Generator: instance = Mock() instance.get_sites.return_value = [] - with patch("amberelectric.api.AmberApi.create", return_value=instance): + with patch("amberelectric.AmberApi", return_value=instance): yield instance @@ -188,6 +213,39 @@ async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None: assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH" +async def test_single_closed_site_no_closed_date( + hass: HomeAssistant, single_site_closed_no_close_date_api: Mock +) -> None: + """Test single closed site with no closed date.""" + initial_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert initial_result.get("type") is FlowResultType.FORM + assert initial_result.get("step_id") == "user" + + # Test filling in API key + enter_api_key_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_API_TOKEN: API_KEY}, + ) + assert enter_api_key_result.get("type") is FlowResultType.FORM + assert enter_api_key_result.get("step_id") == "site" + + select_site_result = await hass.config_entries.flow.async_configure( + enter_api_key_result["flow_id"], + {CONF_SITE_ID: "01FG0AGP818PXK0DWHXJRRT2DH", CONF_SITE_NAME: "Home"}, + ) + + # Show available sites + assert select_site_result.get("type") is FlowResultType.CREATE_ENTRY + assert select_site_result.get("title") == "Home" + data = select_site_result.get("data") + assert data + assert data[CONF_API_TOKEN] == API_KEY + assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH" + + async def test_single_site_rejoin( hass: HomeAssistant, single_site_rejoin_api: Mock ) -> None: diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py index cb3912cb5ac47..0a8f5b874faeb 100644 --- a/tests/components/amberelectric/test_coordinator.py +++ b/tests/components/amberelectric/test_coordinator.py @@ -7,10 +7,12 @@ from unittest.mock import Mock, patch from amberelectric import ApiException -from amberelectric.model.channel import Channel, ChannelType -from amberelectric.model.current_interval import CurrentInterval -from amberelectric.model.interval import Descriptor, SpikeStatus -from amberelectric.model.site import Site, SiteStatus +from amberelectric.models.channel import Channel, ChannelType +from amberelectric.models.interval import Interval +from amberelectric.models.price_descriptor import PriceDescriptor +from amberelectric.models.site import Site +from amberelectric.models.site_status import SiteStatus +from amberelectric.models.spike_status import SpikeStatus from dateutil import parser import pytest @@ -38,37 +40,40 @@ def mock_api_current_price() -> Generator: instance = Mock() general_site = Site( - GENERAL_ONLY_SITE_ID, - "11111111111", - [Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100")], - "Jemena", - SiteStatus.ACTIVE, - date(2021, 1, 1), - None, + id=GENERAL_ONLY_SITE_ID, + nmi="11111111111", + channels=[Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100")], + network="Jemena", + status=SiteStatus("active"), + activeFrom=date(2021, 1, 1), + closedOn=None, + interval_length=30, ) general_and_controlled_load = Site( - GENERAL_AND_CONTROLLED_SITE_ID, - "11111111112", - [ + id=GENERAL_AND_CONTROLLED_SITE_ID, + nmi="11111111112", + channels=[ Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100"), - Channel(identifier="E2", type=ChannelType.CONTROLLED_LOAD, tariff="A180"), + Channel(identifier="E2", type=ChannelType.CONTROLLEDLOAD, tariff="A180"), ], - "Jemena", - SiteStatus.ACTIVE, - date(2021, 1, 1), - None, + network="Jemena", + status=SiteStatus("active"), + activeFrom=date(2021, 1, 1), + closedOn=None, + interval_length=30, ) general_and_feed_in = Site( - GENERAL_AND_FEED_IN_SITE_ID, - "11111111113", - [ + id=GENERAL_AND_FEED_IN_SITE_ID, + nmi="11111111113", + channels=[ Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100"), - Channel(identifier="E2", type=ChannelType.FEED_IN, tariff="A100"), + Channel(identifier="E2", type=ChannelType.FEEDIN, tariff="A100"), ], - "Jemena", - SiteStatus.ACTIVE, - date(2021, 1, 1), - None, + network="Jemena", + status=SiteStatus("active"), + activeFrom=date(2021, 1, 1), + closedOn=None, + interval_length=30, ) instance.get_sites.return_value = [ general_site, @@ -76,44 +81,46 @@ def mock_api_current_price() -> Generator: general_and_feed_in, ] - with patch("amberelectric.api.AmberApi.create", return_value=instance): + with patch("amberelectric.AmberApi", return_value=instance): yield instance def test_normalize_descriptor() -> None: """Test normalizing descriptors works correctly.""" assert normalize_descriptor(None) is None - assert normalize_descriptor(Descriptor.NEGATIVE) == "negative" - assert normalize_descriptor(Descriptor.EXTREMELY_LOW) == "extremely_low" - assert normalize_descriptor(Descriptor.VERY_LOW) == "very_low" - assert normalize_descriptor(Descriptor.LOW) == "low" - assert normalize_descriptor(Descriptor.NEUTRAL) == "neutral" - assert normalize_descriptor(Descriptor.HIGH) == "high" - assert normalize_descriptor(Descriptor.SPIKE) == "spike" + assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative" + assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low" + assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low" + assert normalize_descriptor(PriceDescriptor.LOW) == "low" + assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral" + assert normalize_descriptor(PriceDescriptor.HIGH) == "high" + assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike" async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) -> None: """Test fetching a site with only a general channel.""" - current_price_api.get_current_price.return_value = GENERAL_CHANNEL + current_price_api.get_current_prices.return_value = GENERAL_CHANNEL data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) result = await data_service._async_update_data() - current_price_api.get_current_price.assert_called_with( + current_price_api.get_current_prices.assert_called_with( GENERAL_ONLY_SITE_ID, next=48 ) - assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance assert result["forecasts"].get("general") == [ - GENERAL_CHANNEL[1], - GENERAL_CHANNEL[2], - GENERAL_CHANNEL[3], + GENERAL_CHANNEL[1].actual_instance, + GENERAL_CHANNEL[2].actual_instance, + GENERAL_CHANNEL[3].actual_instance, ] assert result["current"].get("controlled_load") is None assert result["forecasts"].get("controlled_load") is None assert result["current"].get("feed_in") is None assert result["forecasts"].get("feed_in") is None - assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + assert result["grid"]["renewables"] == round( + GENERAL_CHANNEL[0].actual_instance.renewables + ) assert result["grid"]["price_spike"] == "none" @@ -122,12 +129,12 @@ async def test_fetch_no_general_site( ) -> None: """Test fetching a site with no general channel.""" - current_price_api.get_current_price.return_value = CONTROLLED_LOAD_CHANNEL + current_price_api.get_current_prices.return_value = CONTROLLED_LOAD_CHANNEL data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) with pytest.raises(UpdateFailed): await data_service._async_update_data() - current_price_api.get_current_price.assert_called_with( + current_price_api.get_current_prices.assert_called_with( GENERAL_ONLY_SITE_ID, next=48 ) @@ -135,41 +142,45 @@ async def test_fetch_no_general_site( async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) -> None: """Test that the old values are maintained if a second call fails.""" - current_price_api.get_current_price.return_value = GENERAL_CHANNEL + current_price_api.get_current_prices.return_value = GENERAL_CHANNEL data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) result = await data_service._async_update_data() - current_price_api.get_current_price.assert_called_with( + current_price_api.get_current_prices.assert_called_with( GENERAL_ONLY_SITE_ID, next=48 ) - assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance assert result["forecasts"].get("general") == [ - GENERAL_CHANNEL[1], - GENERAL_CHANNEL[2], - GENERAL_CHANNEL[3], + GENERAL_CHANNEL[1].actual_instance, + GENERAL_CHANNEL[2].actual_instance, + GENERAL_CHANNEL[3].actual_instance, ] assert result["current"].get("controlled_load") is None assert result["forecasts"].get("controlled_load") is None assert result["current"].get("feed_in") is None assert result["forecasts"].get("feed_in") is None - assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + assert result["grid"]["renewables"] == round( + GENERAL_CHANNEL[0].actual_instance.renewables + ) - current_price_api.get_current_price.side_effect = ApiException(status=403) + current_price_api.get_current_prices.side_effect = ApiException(status=403) with pytest.raises(UpdateFailed): await data_service._async_update_data() - assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance assert result["forecasts"].get("general") == [ - GENERAL_CHANNEL[1], - GENERAL_CHANNEL[2], - GENERAL_CHANNEL[3], + GENERAL_CHANNEL[1].actual_instance, + GENERAL_CHANNEL[2].actual_instance, + GENERAL_CHANNEL[3].actual_instance, ] assert result["current"].get("controlled_load") is None assert result["forecasts"].get("controlled_load") is None assert result["current"].get("feed_in") is None assert result["forecasts"].get("feed_in") is None - assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + assert result["grid"]["renewables"] == round( + GENERAL_CHANNEL[0].actual_instance.renewables + ) assert result["grid"]["price_spike"] == "none" @@ -178,7 +189,7 @@ async def test_fetch_general_and_controlled_load_site( ) -> None: """Test fetching a site with a general and controlled load channel.""" - current_price_api.get_current_price.return_value = ( + current_price_api.get_current_prices.return_value = ( GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL ) data_service = AmberUpdateCoordinator( @@ -186,25 +197,30 @@ async def test_fetch_general_and_controlled_load_site( ) result = await data_service._async_update_data() - current_price_api.get_current_price.assert_called_with( + current_price_api.get_current_prices.assert_called_with( GENERAL_AND_CONTROLLED_SITE_ID, next=48 ) - assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance assert result["forecasts"].get("general") == [ - GENERAL_CHANNEL[1], - GENERAL_CHANNEL[2], - GENERAL_CHANNEL[3], + GENERAL_CHANNEL[1].actual_instance, + GENERAL_CHANNEL[2].actual_instance, + GENERAL_CHANNEL[3].actual_instance, ] - assert result["current"].get("controlled_load") is CONTROLLED_LOAD_CHANNEL[0] + assert ( + result["current"].get("controlled_load") + is CONTROLLED_LOAD_CHANNEL[0].actual_instance + ) assert result["forecasts"].get("controlled_load") == [ - CONTROLLED_LOAD_CHANNEL[1], - CONTROLLED_LOAD_CHANNEL[2], - CONTROLLED_LOAD_CHANNEL[3], + CONTROLLED_LOAD_CHANNEL[1].actual_instance, + CONTROLLED_LOAD_CHANNEL[2].actual_instance, + CONTROLLED_LOAD_CHANNEL[3].actual_instance, ] assert result["current"].get("feed_in") is None assert result["forecasts"].get("feed_in") is None - assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + assert result["grid"]["renewables"] == round( + GENERAL_CHANNEL[0].actual_instance.renewables + ) assert result["grid"]["price_spike"] == "none" @@ -213,31 +229,35 @@ async def test_fetch_general_and_feed_in_site( ) -> None: """Test fetching a site with a general and feed_in channel.""" - current_price_api.get_current_price.return_value = GENERAL_CHANNEL + FEED_IN_CHANNEL + current_price_api.get_current_prices.return_value = ( + GENERAL_CHANNEL + FEED_IN_CHANNEL + ) data_service = AmberUpdateCoordinator( hass, current_price_api, GENERAL_AND_FEED_IN_SITE_ID ) result = await data_service._async_update_data() - current_price_api.get_current_price.assert_called_with( + current_price_api.get_current_prices.assert_called_with( GENERAL_AND_FEED_IN_SITE_ID, next=48 ) - assert result["current"].get("general") == GENERAL_CHANNEL[0] + assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance assert result["forecasts"].get("general") == [ - GENERAL_CHANNEL[1], - GENERAL_CHANNEL[2], - GENERAL_CHANNEL[3], + GENERAL_CHANNEL[1].actual_instance, + GENERAL_CHANNEL[2].actual_instance, + GENERAL_CHANNEL[3].actual_instance, ] assert result["current"].get("controlled_load") is None assert result["forecasts"].get("controlled_load") is None - assert result["current"].get("feed_in") is FEED_IN_CHANNEL[0] + assert result["current"].get("feed_in") is FEED_IN_CHANNEL[0].actual_instance assert result["forecasts"].get("feed_in") == [ - FEED_IN_CHANNEL[1], - FEED_IN_CHANNEL[2], - FEED_IN_CHANNEL[3], + FEED_IN_CHANNEL[1].actual_instance, + FEED_IN_CHANNEL[2].actual_instance, + FEED_IN_CHANNEL[3].actual_instance, ] - assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables) + assert result["grid"]["renewables"] == round( + GENERAL_CHANNEL[0].actual_instance.renewables + ) assert result["grid"]["price_spike"] == "none" @@ -246,13 +266,13 @@ async def test_fetch_potential_spike( ) -> None: """Test fetching a site with only a general channel.""" - general_channel: list[CurrentInterval] = [ + general_channel: list[Interval] = [ generate_current_interval( ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") - ), + ) ] - general_channel[0].spike_status = SpikeStatus.POTENTIAL - current_price_api.get_current_price.return_value = general_channel + general_channel[0].actual_instance.spike_status = SpikeStatus.POTENTIAL + current_price_api.get_current_prices.return_value = general_channel data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) result = await data_service._async_update_data() assert result["grid"]["price_spike"] == "potential" @@ -261,13 +281,13 @@ async def test_fetch_potential_spike( async def test_fetch_spike(hass: HomeAssistant, current_price_api: Mock) -> None: """Test fetching a site with only a general channel.""" - general_channel: list[CurrentInterval] = [ + general_channel: list[Interval] = [ generate_current_interval( ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") - ), + ) ] - general_channel[0].spike_status = SpikeStatus.SPIKE - current_price_api.get_current_price.return_value = general_channel + general_channel[0].actual_instance.spike_status = SpikeStatus.SPIKE + current_price_api.get_current_prices.return_value = general_channel data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) result = await data_service._async_update_data() assert result["grid"]["price_spike"] == "spike" diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py index 3a5626d14d51b..203b65d6df6f7 100644 --- a/tests/components/amberelectric/test_sensor.py +++ b/tests/components/amberelectric/test_sensor.py @@ -3,8 +3,9 @@ from collections.abc import AsyncGenerator from unittest.mock import Mock, patch -from amberelectric.model.current_interval import CurrentInterval -from amberelectric.model.range import Range +from amberelectric.models.current_interval import CurrentInterval +from amberelectric.models.interval import Interval +from amberelectric.models.range import Range import pytest from homeassistant.components.amberelectric.const import ( @@ -44,10 +45,10 @@ async def setup_general(hass: HomeAssistant) -> AsyncGenerator[Mock]: instance = Mock() with patch( - "amberelectric.api.AmberApi.create", + "amberelectric.AmberApi", return_value=instance, ) as mock_update: - instance.get_current_price = Mock(return_value=GENERAL_CHANNEL) + instance.get_current_prices = Mock(return_value=GENERAL_CHANNEL) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() yield mock_update.return_value @@ -68,10 +69,10 @@ async def setup_general_and_controlled_load( instance = Mock() with patch( - "amberelectric.api.AmberApi.create", + "amberelectric.AmberApi", return_value=instance, ) as mock_update: - instance.get_current_price = Mock( + instance.get_current_prices = Mock( return_value=GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL ) assert await async_setup_component(hass, DOMAIN, {}) @@ -92,10 +93,10 @@ async def setup_general_and_feed_in(hass: HomeAssistant) -> AsyncGenerator[Mock] instance = Mock() with patch( - "amberelectric.api.AmberApi.create", + "amberelectric.AmberApi", return_value=instance, ) as mock_update: - instance.get_current_price = Mock( + instance.get_current_prices = Mock( return_value=GENERAL_CHANNEL + FEED_IN_CHANNEL ) assert await async_setup_component(hass, DOMAIN, {}) @@ -126,7 +127,7 @@ async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> assert attributes.get("range_max") is None with_range: list[CurrentInterval] = GENERAL_CHANNEL - with_range[0].range = Range(7.8, 12.4) + with_range[0].actual_instance.range = Range(min=7.8, max=12.4) setup_general.get_current_price.return_value = with_range config_entry = hass.config_entries.async_entries(DOMAIN)[0] @@ -211,8 +212,8 @@ async def test_general_forecast_sensor( assert first_forecast.get("range_min") is None assert first_forecast.get("range_max") is None - with_range: list[CurrentInterval] = GENERAL_CHANNEL - with_range[1].range = Range(7.8, 12.4) + with_range: list[Interval] = GENERAL_CHANNEL + with_range[1].actual_instance.range = Range(min=7.8, max=12.4) setup_general.get_current_price.return_value = with_range config_entry = hass.config_entries.async_entries(DOMAIN)[0] From 061c19fbf862e70b1e399c06f6afb26ccb76fdc0 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 20 Nov 2024 13:04:29 +0200 Subject: [PATCH 0598/1070] Add Z-Wave `installer_mode` yaml option (#129888) Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/__init__.py | 15 ++++++-- homeassistant/components/zwave_js/api.py | 25 ++++++++++++++ homeassistant/components/zwave_js/const.py | 1 + tests/components/zwave_js/test_api.py | 34 ++++++++++++++++++- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 06b8214d9411f..c8503b1f4c6ca 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -9,6 +9,7 @@ from typing import Any from awesomeversion import AwesomeVersion +import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass, RemoveNodeReason from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion @@ -87,6 +88,7 @@ CONF_ADDON_S2_AUTHENTICATED_KEY, CONF_ADDON_S2_UNAUTHENTICATED_KEY, CONF_DATA_COLLECTION_OPTED_IN, + CONF_INSTALLER_MODE, CONF_INTEGRATION_CREATED_ADDON, CONF_LR_S2_ACCESS_CONTROL_KEY, CONF_LR_S2_AUTHENTICATED_KEY, @@ -132,12 +134,21 @@ DATA_DRIVER_EVENTS = "driver_events" DATA_START_CLIENT_TASK = "start_client_task" -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_INSTALLER_MODE, default=False): cv.boolean, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Z-Wave JS component.""" - hass.data[DOMAIN] = {} + hass.data[DOMAIN] = config.get(DOMAIN, {}) for entry in hass.config_entries.async_entries(DOMAIN): if not isinstance(entry.unique_id, str): hass.config_entries.async_update_entry( diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index bd49e85b601a1..ff0459ddbdd3a 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -83,7 +83,9 @@ ATTR_PARAMETERS, ATTR_WAIT_FOR_RESULT, CONF_DATA_COLLECTION_OPTED_IN, + CONF_INSTALLER_MODE, DATA_CLIENT, + DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, USER_AGENT, ) @@ -450,6 +452,7 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_hard_reset_controller) websocket_api.async_register_command(hass, websocket_node_capabilities) websocket_api.async_register_command(hass, websocket_invoke_cc_api) + websocket_api.async_register_command(hass, websocket_get_integration_settings) hass.http.register_view(FirmwareUploadView(dr.async_get(hass))) @@ -2682,3 +2685,25 @@ async def websocket_invoke_cc_api( msg[ID], result, ) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/get_integration_settings", + } +) +def websocket_get_integration_settings( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get Z-Wave JS integration wide configuration.""" + connection.send_result( + msg[ID], + { + # list explicitly to avoid leaking other keys and to set default + CONF_INSTALLER_MODE: hass.data[DOMAIN].get(CONF_INSTALLER_MODE, False), + }, + ) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index fd81cd7e7de03..16cf6f748bb94 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -25,6 +25,7 @@ CONF_ADDON_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key" CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key" CONF_ADDON_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key" +CONF_INSTALLER_MODE = "installer_mode" CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" CONF_NETWORK_KEY = "network_key" CONF_S0_LEGACY_KEY = "s0_legacy_key" diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index df1adbc98e5c3..0807e9e09a5f2 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5,7 +5,7 @@ from io import BytesIO import json from typing import Any -from unittest.mock import PropertyMock, patch +from unittest.mock import MagicMock, PropertyMock, patch import pytest from zwave_js_server.const import ( @@ -89,11 +89,13 @@ ATTR_PARAMETERS, ATTR_WAIT_FOR_RESULT, CONF_DATA_COLLECTION_OPTED_IN, + CONF_INSTALLER_MODE, DOMAIN, ) from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockUser from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -5163,3 +5165,33 @@ async def test_invoke_cc_api( msg = await ws_client.receive_json() assert not msg["success"] assert msg["error"] == {"code": "NotFoundError", "message": ""} + + +@pytest.mark.parametrize( + ("config", "installer_mode"), [({}, False), ({CONF_INSTALLER_MODE: True}, True)] +) +async def test_get_integration_settings( + config: dict[str, Any], + installer_mode: bool, + hass: HomeAssistant, + client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that the get_integration_settings WS API call works.""" + ws_client = await hass_ws_client(hass) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/get_integration_settings", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] == { + CONF_INSTALLER_MODE: installer_mode, + } From 5d804d3172dda47dbf7be107f2d95ba1af35ff88 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 20 Nov 2024 12:54:11 +0100 Subject: [PATCH 0599/1070] Remove code-owner from Habitica (#131024) --- CODEOWNERS | 4 ++-- homeassistant/components/habitica/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 5bea90913b00a..7a098e4696161 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -588,8 +588,8 @@ build.json @home-assistant/supervisor /tests/components/group/ @home-assistant/core /homeassistant/components/guardian/ @bachya /tests/components/guardian/ @bachya -/homeassistant/components/habitica/ @ASMfreaK @leikoilja @tr4nt0r -/tests/components/habitica/ @ASMfreaK @leikoilja @tr4nt0r +/homeassistant/components/habitica/ @tr4nt0r +/tests/components/habitica/ @tr4nt0r /homeassistant/components/hardkernel/ @home-assistant/core /tests/components/hardkernel/ @home-assistant/core /homeassistant/components/hardware/ @home-assistant/core diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 8e3396d32cf6b..a01697c394570 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -1,7 +1,7 @@ { "domain": "habitica", "name": "Habitica", - "codeowners": ["@ASMfreaK", "@leikoilja", "@tr4nt0r"], + "codeowners": ["@tr4nt0r"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", From 2cd05e224a3d201b943e50e1d5343e618428e0ef Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 20 Nov 2024 13:49:02 +0100 Subject: [PATCH 0600/1070] Add quality_scale.yaml to track IQS progress (#130953) --- .../components/airgradient/quality_scale.yaml | 2 + script/hassfest/__main__.py | 2 + script/hassfest/quality_scale.py | 57 +++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 homeassistant/components/airgradient/quality_scale.yaml create mode 100644 script/hassfest/quality_scale.py diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml new file mode 100644 index 0000000000000..d244807a656e1 --- /dev/null +++ b/homeassistant/components/airgradient/quality_scale.yaml @@ -0,0 +1,2 @@ +rules: + IQS001: done diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index f0b9ad25dd05c..81670de5afd93 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -23,6 +23,7 @@ metadata, mqtt, mypy_config, + quality_scale, requirements, services, ssdp, @@ -43,6 +44,7 @@ json, manifest, mqtt, + quality_scale, requirements, services, ssdp, diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py new file mode 100644 index 0000000000000..9fe99b922af26 --- /dev/null +++ b/script/hassfest/quality_scale.py @@ -0,0 +1,57 @@ +"""Validate integration quality scale files.""" + +from __future__ import annotations + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.yaml import load_yaml_dict + +from .model import Config, Integration + +SCHEMA = vol.Schema( + { + vol.Required("rules"): vol.Schema( + { + str: vol.Any( + vol.In(["todo", "done"]), + vol.Schema( + { + vol.Required("status"): vol.In(["todo", "done", "exempt"]), + vol.Optional("comment"): str, + } + ), + ) + } + ) + } +) + + +def validate_iqs_file(config: Config, integration: Integration) -> None: + """Validate quality scale file for integration.""" + iqs_file = integration.path / "quality_scale.yaml" + if not iqs_file.is_file(): + return + + name = str(iqs_file) + + try: + data = load_yaml_dict(name) + except HomeAssistantError: + integration.add_error("quality_scale", "Invalid quality_scale.yaml") + return + + try: + SCHEMA(data) + except vol.Invalid as err: + integration.add_error( + "quality_scale", f"Invalid {name}: {humanize_error(data, err)}" + ) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Handle YAML files inside integrations.""" + for integration in integrations.values(): + validate_iqs_file(config, integration) From c56f377cd568bb2d50a3d0764a76584a269401a8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 20 Nov 2024 11:27:57 -0500 Subject: [PATCH 0601/1070] Use now() from dt_util for Date and Time intents (#131049) Use now() from dt_util --- homeassistant/components/intent/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 1322576f11522..30aaa933072c3 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime import logging from typing import Any, Protocol @@ -42,6 +41,7 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State from homeassistant.helpers import config_validation as cv, integration_platform, intent from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util from .const import DOMAIN, TIMER_DATA from .timers import ( @@ -405,7 +405,7 @@ class GetCurrentDateIntentHandler(intent.IntentHandler): async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: response = intent_obj.create_response() - response.async_set_speech_slots({"date": datetime.now().date()}) + response.async_set_speech_slots({"date": dt_util.now().date()}) return response @@ -417,7 +417,7 @@ class GetCurrentTimeIntentHandler(intent.IntentHandler): async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: response = intent_obj.create_response() - response.async_set_speech_slots({"time": datetime.now().time()}) + response.async_set_speech_slots({"time": dt_util.now().time()}) return response From 514af896f378e2f31e6f0512f1e2e749782bd388 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 20 Nov 2024 17:43:02 +0100 Subject: [PATCH 0602/1070] Change to rule slugs (#131043) --- .../components/airgradient/quality_scale.yaml | 2 +- script/hassfest/quality_scale.py | 58 ++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml index d244807a656e1..0def17a287c6a 100644 --- a/homeassistant/components/airgradient/quality_scale.yaml +++ b/homeassistant/components/airgradient/quality_scale.yaml @@ -1,2 +1,2 @@ rules: - IQS001: done + config-flow: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 9fe99b922af26..c37283919cdd5 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -10,11 +10,66 @@ from .model import Config, Integration +RULES = [ + "action-exceptions", + "action-setup", + "appropriate-polling", + "async-dependency", + "brands", + "common-modules", + "config-entry-unloading", + "config-flow", + "config-flow-test-coverage", + "dependency-transparency", + "devices", + "diagnostics", + "discovery", + "discovery-update-info", + "docs-actions", + "docs-configuration-parameters", + "docs-data-update", + "docs-examples", + "docs-high-level-description", + "docs-installation-instructions", + "docs-installation-parameters", + "docs-known-limitations", + "docs-removal-instructions", + "docs-supported-devices", + "docs-supported-functions", + "docs-troubleshooting", + "docs-use-cases", + "dynamic-devices", + "entity-category", + "entity-device-class", + "entity-disabled-by-default", + "entity-event-setup", + "entity-translations", + "entity-unavailable", + "entity-unique-id", + "exception-translations", + "has-entity-name", + "icon-translations", + "inject-websession", + "integration-owner", + "log-when-unavailable", + "parallel-updates", + "reauthentication-flow", + "reconfiguration-flow", + "repair-issues", + "runtime-data", + "stale-devices", + "strict-typing", + "test-before-configure", + "test-before-setup", + "test-coverage", + "unique-config-entry", +] + SCHEMA = vol.Schema( { vol.Required("rules"): vol.Schema( { - str: vol.Any( + vol.Optional(rule): vol.Any( vol.In(["todo", "done"]), vol.Schema( { @@ -23,6 +78,7 @@ } ), ) + for rule in RULES } ) } From e7a2377c7e39ef31a1f8473b8d9b5094e11e2805 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 20 Nov 2024 17:54:09 +0100 Subject: [PATCH 0603/1070] Update forecast-solar to 4.0.0 (#131044) --- homeassistant/components/forecast_solar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index f5dd79281e6de..6f34d7918579e 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["forecast-solar==3.1.0"] + "requirements": ["forecast-solar==4.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fe4786562b6ca..14d350fcfbc5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -931,7 +931,7 @@ fnv-hash-fast==1.0.2 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast-solar==3.1.0 +forecast-solar==4.0.0 # homeassistant.components.fortios fortiosapi==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 063eedd7a95e7..ab925cdb883da 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -790,7 +790,7 @@ fnv-hash-fast==1.0.2 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast-solar==3.1.0 +forecast-solar==4.0.0 # homeassistant.components.freebox freebox-api==1.1.0 From 94bf77606b72b5d1eb8b93c73fbd65917f6e8684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Conde=20G=C3=B3mez?= Date: Wed, 20 Nov 2024 18:21:16 +0100 Subject: [PATCH 0604/1070] Unscape HTML Entities from RSS feeds (#130915) * Unscape HTML Entities from RSS feeds * Improve tests --- .../components/feedreader/config_flow.py | 3 +- .../components/feedreader/coordinator.py | 4 ++- homeassistant/components/feedreader/event.py | 12 +++++-- tests/components/feedreader/conftest.py | 12 +++++++ .../feedreader/fixtures/feedreader10.xml | 19 ++++++++++ .../feedreader/fixtures/feedreader9.xml | 21 +++++++++++ .../feedreader/snapshots/test_event.ambr | 27 ++++++++++++++ .../components/feedreader/test_config_flow.py | 35 +++++++++++++++++++ tests/components/feedreader/test_event.py | 31 ++++++++++++++++ tests/components/feedreader/test_init.py | 21 +++++++++++ 10 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 tests/components/feedreader/fixtures/feedreader10.xml create mode 100644 tests/components/feedreader/fixtures/feedreader9.xml create mode 100644 tests/components/feedreader/snapshots/test_event.ambr diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index b902d48a1c8eb..72042de25edd6 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import html import logging from typing import Any import urllib.error @@ -107,7 +108,7 @@ async def async_step_user( return self.abort_on_import_error(user_input[CONF_URL], "url_error") return self.show_user_form(user_input, {"base": "url_error"}) - feed_title = feed["feed"]["title"] + feed_title = html.unescape(feed["feed"]["title"]) return self.async_create_entry( title=feed_title, diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py index 6608c4312fe18..f45b303946aae 100644 --- a/homeassistant/components/feedreader/coordinator.py +++ b/homeassistant/components/feedreader/coordinator.py @@ -4,6 +4,7 @@ from calendar import timegm from datetime import datetime +import html from logging import getLogger from time import gmtime, struct_time from typing import TYPE_CHECKING @@ -102,7 +103,8 @@ async def async_setup(self) -> None: """Set up the feed manager.""" feed = await self._async_fetch_feed() self.logger.debug("Feed data fetched from %s : %s", self.url, feed["feed"]) - self.feed_author = feed["feed"].get("author") + if feed_author := feed["feed"].get("author"): + self.feed_author = html.unescape(feed_author) self.feed_version = feedparser.api.SUPPORTED_VERSIONS.get(feed["version"]) self._feed = feed diff --git a/homeassistant/components/feedreader/event.py b/homeassistant/components/feedreader/event.py index 4b3fb2e252490..ad6aed0fc7655 100644 --- a/homeassistant/components/feedreader/event.py +++ b/homeassistant/components/feedreader/event.py @@ -2,6 +2,7 @@ from __future__ import annotations +import html import logging from feedparser import FeedParserDict @@ -76,15 +77,22 @@ def _async_handle_update(self) -> None: # so we always take the first entry in list, since we only care about the latest entry feed_data: FeedParserDict = data[0] + if description := feed_data.get("description"): + description = html.unescape(description) + + if title := feed_data.get("title"): + title = html.unescape(title) + if content := feed_data.get("content"): if isinstance(content, list) and isinstance(content[0], dict): content = content[0].get("value") + content = html.unescape(content) self._trigger_event( EVENT_FEEDREADER, { - ATTR_DESCRIPTION: feed_data.get("description"), - ATTR_TITLE: feed_data.get("title"), + ATTR_DESCRIPTION: description, + ATTR_TITLE: title, ATTR_LINK: feed_data.get("link"), ATTR_CONTENT: content, }, diff --git a/tests/components/feedreader/conftest.py b/tests/components/feedreader/conftest.py index 8eeb89e00cda1..1e7d50c3835c3 100644 --- a/tests/components/feedreader/conftest.py +++ b/tests/components/feedreader/conftest.py @@ -64,6 +64,18 @@ def fixture_feed_only_summary(hass: HomeAssistant) -> bytes: return load_fixture_bytes("feedreader8.xml") +@pytest.fixture(name="feed_htmlentities") +def fixture_feed_htmlentities(hass: HomeAssistant) -> bytes: + """Load test feed data with HTML Entities.""" + return load_fixture_bytes("feedreader9.xml") + + +@pytest.fixture(name="feed_atom_htmlentities") +def fixture_feed_atom_htmlentities(hass: HomeAssistant) -> bytes: + """Load test ATOM feed data with HTML Entities.""" + return load_fixture_bytes("feedreader10.xml") + + @pytest.fixture(name="events") async def fixture_events(hass: HomeAssistant) -> list[Event]: """Fixture that catches alexa events.""" diff --git a/tests/components/feedreader/fixtures/feedreader10.xml b/tests/components/feedreader/fixtures/feedreader10.xml new file mode 100644 index 0000000000000..17ec8069ae1a9 --- /dev/null +++ b/tests/components/feedreader/fixtures/feedreader10.xml @@ -0,0 +1,19 @@ + + + <![CDATA[ATOM RSS en español]]> + + 2024-11-18T14:00:00Z + + + + urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6 + + <![CDATA[Título]]> + + urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a + 2024-11-18T14:00:00Z + + + + diff --git a/tests/components/feedreader/fixtures/feedreader9.xml b/tests/components/feedreader/fixtures/feedreader9.xml new file mode 100644 index 0000000000000..580a42cbd3f60 --- /dev/null +++ b/tests/components/feedreader/fixtures/feedreader9.xml @@ -0,0 +1,21 @@ + + + + <![CDATA[RSS en español]]> + + http://www.example.com/main.html + Mon, 18 Nov 2024 15:00:00 +1000 + Mon, 18 Nov 2024 15:00:00 +1000 + 1800 + + + <![CDATA[Título 1]]> + + http://www.example.com/link/1 + GUID 1 + Mon, 18 Nov 2024 15:00:00 +1000 + + + + + diff --git a/tests/components/feedreader/snapshots/test_event.ambr b/tests/components/feedreader/snapshots/test_event.ambr new file mode 100644 index 0000000000000..9cce035ea87d2 --- /dev/null +++ b/tests/components/feedreader/snapshots/test_event.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_event_htmlentities[feed_atom_htmlentities] + ReadOnlyDict({ + 'content': 'Contenido en español', + 'description': 'Resumen en español', + 'event_type': 'feedreader', + 'event_types': list([ + 'feedreader', + ]), + 'friendly_name': 'Mock Title', + 'link': 'http://example.org/2003/12/13/atom03', + 'title': 'Título', + }) +# --- +# name: test_event_htmlentities[feed_htmlentities] + ReadOnlyDict({ + 'content': 'Contenido 1 en español', + 'description': 'Descripción 1', + 'event_type': 'feedreader', + 'event_types': list([ + 'feedreader', + ]), + 'friendly_name': 'Mock Title', + 'link': 'http://www.example.com/link/1', + 'title': 'Título 1', + }) +# --- diff --git a/tests/components/feedreader/test_config_flow.py b/tests/components/feedreader/test_config_flow.py index 2a434306c0f62..e801227293c36 100644 --- a/tests/components/feedreader/test_config_flow.py +++ b/tests/components/feedreader/test_config_flow.py @@ -246,3 +246,38 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["data"] == { CONF_MAX_ENTRIES: 10, } + + +@pytest.mark.parametrize( + ("fixture_name", "expected_title"), + [ + ("feed_htmlentities", "RSS en español"), + ("feed_atom_htmlentities", "ATOM RSS en español"), + ], +) +async def test_feed_htmlentities( + hass: HomeAssistant, + feedparser, + setup_entry, + fixture_name, + expected_title, + request: pytest.FixtureRequest, +) -> None: + """Test starting a flow by user from a feed with HTML Entities in the title.""" + with patch( + "homeassistant.components.feedreader.config_flow.feedparser.http.get", + side_effect=[request.getfixturevalue(fixture_name)], + ): + # init user flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # success + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: URL} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == expected_title diff --git a/tests/components/feedreader/test_event.py b/tests/components/feedreader/test_event.py index 491c7e38d022f..32f8ecb8080fb 100644 --- a/tests/components/feedreader/test_event.py +++ b/tests/components/feedreader/test_event.py @@ -3,6 +3,9 @@ from datetime import timedelta from unittest.mock import patch +import pytest +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.feedreader.event import ( ATTR_CONTENT, ATTR_DESCRIPTION, @@ -59,3 +62,31 @@ async def test_event_entity( assert state.attributes[ATTR_LINK] == "http://www.example.com/link/1" assert state.attributes[ATTR_CONTENT] == "This is a summary" assert state.attributes[ATTR_DESCRIPTION] == "Description 1" + + +@pytest.mark.parametrize( + ("fixture_name"), + [ + ("feed_htmlentities"), + ("feed_atom_htmlentities"), + ], +) +async def test_event_htmlentities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + fixture_name, + request: pytest.FixtureRequest, +) -> None: + """Test feed event entity with HTML Entities.""" + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.http.get", + side_effect=[request.getfixturevalue(fixture_name)], + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("event.mock_title") + assert state + assert state.attributes == snapshot diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index d7700d79e3b2f..bc7a66dc86ea5 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -12,6 +12,7 @@ from homeassistant.components.feedreader.const import DOMAIN from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import device_registry as dr import homeassistant.util.dt as dt_util from . import async_setup_config_entry, create_mock_entry @@ -357,3 +358,23 @@ async def test_feed_errors( freezer.tick(timedelta(hours=1, seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) + + +async def test_feed_atom_htmlentities( + hass: HomeAssistant, feed_atom_htmlentities, device_registry: dr.DeviceRegistry +) -> None: + """Test ATOM feed author with HTML Entities.""" + + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.http.get", + side_effect=[feed_atom_htmlentities], + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + assert device_entry.manufacturer == "Juan Pérez" From 771952d2925a8fb3d5f0dee1c01e21101618cf11 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Nov 2024 18:36:20 +0100 Subject: [PATCH 0605/1070] Remove deprecated yaml import from sabnzbd (#131052) --- homeassistant/components/sabnzbd/__init__.py | 61 +------------------- tests/components/sabnzbd/test_config_flow.py | 40 +------------ tests/components/sabnzbd/test_init.py | 4 +- 3 files changed, 6 insertions(+), 99 deletions(-) diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 92b596ae2880d..b7a3482bd932d 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -8,31 +8,18 @@ import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState -from homeassistant.const import ( - CONF_API_KEY, - CONF_HOST, - CONF_NAME, - CONF_PORT, - CONF_SENSORS, - CONF_SSL, - Platform, -) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries import homeassistant.helpers.issue_registry as ir -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_API_KEY, ATTR_SPEED, - DEFAULT_HOST, - DEFAULT_NAME, - DEFAULT_PORT, DEFAULT_SPEED_LIMIT, - DEFAULT_SSL, DOMAIN, SERVICE_PAUSE, SERVICE_RESUME, @@ -40,7 +27,6 @@ ) from .coordinator import SabnzbdUpdateCoordinator from .sab import get_client -from .sensor import OLD_SENSOR_KEYS PLATFORMS = [Platform.BUTTON, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -63,49 +49,6 @@ } ) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - vol.All( - cv.deprecated(CONF_HOST), - cv.deprecated(CONF_PORT), - cv.deprecated(CONF_SENSORS), - cv.deprecated(CONF_SSL), - { - vol.Required(CONF_API_KEY): str, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [vol.In(OLD_SENSOR_KEYS)] - ), - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, - }, - ) - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the SABnzbd component.""" - hass.data.setdefault(DOMAIN, {}) - - if hass.config_entries.async_entries(DOMAIN): - return True - - if DOMAIN in config: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - - return True - @callback def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) -> str: diff --git a/tests/components/sabnzbd/test_config_flow.py b/tests/components/sabnzbd/test_config_flow.py index 7f5394902b460..fc0205915b841 100644 --- a/tests/components/sabnzbd/test_config_flow.py +++ b/tests/components/sabnzbd/test_config_flow.py @@ -7,15 +7,8 @@ from homeassistant import config_entries from homeassistant.components.sabnzbd import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import ( - CONF_API_KEY, - CONF_HOST, - CONF_NAME, - CONF_PORT, - CONF_SSL, - CONF_URL, -) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -25,14 +18,6 @@ CONF_URL: "http://localhost:8080", } -VALID_CONFIG_OLD = { - CONF_NAME: "Sabnzbd", - CONF_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0", - CONF_HOST: "localhost", - CONF_PORT: 8080, - CONF_SSL: False, -} - pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -77,24 +62,3 @@ async def test_auth_error(hass: HomeAssistant) -> None: ) assert result["errors"] == {"base": "cannot_connect"} - - -async def test_import_flow(hass: HomeAssistant) -> None: - """Test the import configuration flow.""" - with patch( - "homeassistant.components.sabnzbd.sab.SabnzbdApi.check_available", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=VALID_CONFIG_OLD, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "edc3eee7330e" - assert result["data"][CONF_NAME] == "Sabnzbd" - assert result["data"][CONF_API_KEY] == "edc3eee7330e4fdda04489e3fbc283d0" - assert result["data"][CONF_HOST] == "localhost" - assert result["data"][CONF_PORT] == 8080 - assert result["data"][CONF_SSL] is False diff --git a/tests/components/sabnzbd/test_init.py b/tests/components/sabnzbd/test_init.py index 8c77191825986..889ea81d656aa 100644 --- a/tests/components/sabnzbd/test_init.py +++ b/tests/components/sabnzbd/test_init.py @@ -4,14 +4,14 @@ import pytest -from homeassistant.components.sabnzbd import ( +from homeassistant.components.sabnzbd.const import ( ATTR_API_KEY, DEFAULT_NAME, DOMAIN, - OLD_SENSOR_KEYS, SERVICE_PAUSE, SERVICE_RESUME, ) +from homeassistant.components.sabnzbd.sensor import OLD_SENSOR_KEYS from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant From 74f68316c8d1d5ee9709b9929552f4030d5bf9c7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 20 Nov 2024 18:37:07 +0100 Subject: [PATCH 0606/1070] Ensure a comment is required when making an exempt for the IQS (#131051) --- script/hassfest/quality_scale.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index c37283919cdd5..dda6c73946a96 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -73,10 +73,16 @@ vol.In(["todo", "done"]), vol.Schema( { - vol.Required("status"): vol.In(["todo", "done", "exempt"]), + vol.Required("status"): vol.In(["todo", "done"]), vol.Optional("comment"): str, } ), + vol.Schema( + { + vol.Required("status"): "exempt", + vol.Required("comment"): str, + } + ), ) for rule in RULES } From a0ee8eac37cb3cb653581b2231259fb69c277c18 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 20 Nov 2024 18:38:24 +0100 Subject: [PATCH 0607/1070] Use ConfigEntry runtime_data in P1 Monitor (#131048) --- .../components/p1_monitor/__init__.py | 14 +++++------ .../components/p1_monitor/diagnostics.py | 14 ++++------- homeassistant/components/p1_monitor/sensor.py | 23 ++++++++++--------- tests/components/p1_monitor/test_init.py | 1 - 4 files changed, 23 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/p1_monitor/__init__.py b/homeassistant/components/p1_monitor/__init__.py index 3361506dafb84..d2ccc83972abb 100644 --- a/homeassistant/components/p1_monitor/__init__.py +++ b/homeassistant/components/p1_monitor/__init__.py @@ -7,10 +7,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, LOGGER +from .const import LOGGER from .coordinator import P1MonitorDataUpdateCoordinator -PLATFORMS = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR] + +type P1MonitorConfigEntry = ConfigEntry[P1MonitorDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -23,8 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.p1monitor.close() raise - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -55,7 +56,4 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload P1 Monitor config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/p1_monitor/diagnostics.py b/homeassistant/components/p1_monitor/diagnostics.py index c8b4e99099e8d..d2e2ec5c24e4c 100644 --- a/homeassistant/components/p1_monitor/diagnostics.py +++ b/homeassistant/components/p1_monitor/diagnostics.py @@ -11,13 +11,11 @@ from homeassistant.core import HomeAssistant from .const import ( - DOMAIN, SERVICE_PHASES, SERVICE_SETTINGS, SERVICE_SMARTMETER, SERVICE_WATERMETER, ) -from .coordinator import P1MonitorDataUpdateCoordinator if TYPE_CHECKING: from _typeshed import DataclassInstance @@ -29,23 +27,21 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: P1MonitorDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - data = { "entry": { "title": entry.title, "data": async_redact_data(entry.data, TO_REDACT), }, "data": { - "smartmeter": asdict(coordinator.data[SERVICE_SMARTMETER]), - "phases": asdict(coordinator.data[SERVICE_PHASES]), - "settings": asdict(coordinator.data[SERVICE_SETTINGS]), + "smartmeter": asdict(entry.runtime_data.data[SERVICE_SMARTMETER]), + "phases": asdict(entry.runtime_data.data[SERVICE_PHASES]), + "settings": asdict(entry.runtime_data.data[SERVICE_SETTINGS]), }, } - if coordinator.has_water_meter: + if entry.runtime_data.has_water_meter: data["data"]["watermeter"] = asdict( - cast("DataclassInstance", coordinator.data[SERVICE_WATERMETER]) + cast("DataclassInstance", entry.runtime_data.data[SERVICE_WATERMETER]) ) return data diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index 88f6d165f140d..771ef0e19af71 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -239,11 +239,10 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up P1 Monitor Sensors based on a config entry.""" - coordinator: P1MonitorDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities: list[P1MonitorSensorEntity] = [] entities.extend( P1MonitorSensorEntity( - coordinator=coordinator, + entry=entry, description=description, name="SmartMeter", service=SERVICE_SMARTMETER, @@ -252,7 +251,7 @@ async def async_setup_entry( ) entities.extend( P1MonitorSensorEntity( - coordinator=coordinator, + entry=entry, description=description, name="Phases", service=SERVICE_PHASES, @@ -261,17 +260,17 @@ async def async_setup_entry( ) entities.extend( P1MonitorSensorEntity( - coordinator=coordinator, + entry=entry, description=description, name="Settings", service=SERVICE_SETTINGS, ) for description in SENSORS_SETTINGS ) - if coordinator.has_water_meter: + if entry.runtime_data.has_water_meter: entities.extend( P1MonitorSensorEntity( - coordinator=coordinator, + entry=entry, description=description, name="WaterMeter", service=SERVICE_WATERMETER, @@ -291,24 +290,26 @@ class P1MonitorSensorEntity( def __init__( self, *, - coordinator: P1MonitorDataUpdateCoordinator, + entry: ConfigEntry, description: SensorEntityDescription, name: str, service: Literal["smartmeter", "watermeter", "phases", "settings"], ) -> None: """Initialize P1 Monitor sensor.""" - super().__init__(coordinator=coordinator) + super().__init__(coordinator=entry.runtime_data) self._service_key = service self.entity_description = description self._attr_unique_id = ( - f"{coordinator.config_entry.entry_id}_{service}_{description.key}" + f"{entry.runtime_data.config_entry.entry_id}_{service}_{description.key}" ) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{service}")}, - configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}", + identifiers={ + (DOMAIN, f"{entry.runtime_data.config_entry.entry_id}_{service}") + }, + configuration_url=f"http://{entry.runtime_data.config_entry.data[CONF_HOST]}", manufacturer="P1 Monitor", name=name, ) diff --git a/tests/components/p1_monitor/test_init.py b/tests/components/p1_monitor/test_init.py index 20714740385b5..3b7426051d4ac 100644 --- a/tests/components/p1_monitor/test_init.py +++ b/tests/components/p1_monitor/test_init.py @@ -26,7 +26,6 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED From 8e2b78178d9cfaafcd016c61c27d03d07bd01dad Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Wed, 20 Nov 2024 18:40:07 +0100 Subject: [PATCH 0608/1070] Bump pysuezV2 to 1.3.2 (#131037) --- homeassistant/components/suez_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 5eb05b9acb7c9..240be0f37bd20 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/suez_water", "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], - "requirements": ["pysuezV2==1.3.1"] + "requirements": ["pysuezV2==1.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 14d350fcfbc5b..c1f774653d3af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2293,7 +2293,7 @@ pysqueezebox==0.10.0 pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuezV2==1.3.1 +pysuezV2==1.3.2 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab925cdb883da..ac52a8b4f50de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1850,7 +1850,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.10.0 # homeassistant.components.suez_water -pysuezV2==1.3.1 +pysuezV2==1.3.2 # homeassistant.components.switchbee pyswitchbee==1.8.3 From 3542bca13dcfb4900fdd9394da52fc6af3e96567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 20 Nov 2024 18:41:14 +0100 Subject: [PATCH 0609/1070] Update aioairzone to v0.9.7 (#131033) --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 6bf374087a6ca..01fde7eb2fbf9 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.6"] + "requirements": ["aioairzone==0.9.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index c1f774653d3af..dbc682ccdad70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairq==0.4.3 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.6 +aioairzone==0.9.7 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac52a8b4f50de..1c238c2044629 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.4.3 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.6 +aioairzone==0.9.7 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 4776865584b0b066cfe5aa1b28b591a8de6f752b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 20 Nov 2024 17:43:17 +0000 Subject: [PATCH 0610/1070] Add unit translations for github integration (#130538) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/github/sensor.py | 6 --- homeassistant/components/github/strings.json | 18 ++++++--- script/hassfest/translations.py | 3 ++ tests/helpers/test_translation.py | 38 +++++++++++++++++-- .../test/translations/de.json | 5 ++- .../test/translations/en.json | 7 +++- 6 files changed, 58 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index 9a2b5ef5ac4ff..614ebe254c481 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -37,7 +37,6 @@ class GitHubSensorEntityDescription(SensorEntityDescription): GitHubSensorEntityDescription( key="discussions_count", translation_key="discussions_count", - native_unit_of_measurement="Discussions", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["discussion"]["total"], @@ -45,7 +44,6 @@ class GitHubSensorEntityDescription(SensorEntityDescription): GitHubSensorEntityDescription( key="stargazers_count", translation_key="stargazers_count", - native_unit_of_measurement="Stars", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["stargazers_count"], @@ -53,7 +51,6 @@ class GitHubSensorEntityDescription(SensorEntityDescription): GitHubSensorEntityDescription( key="subscribers_count", translation_key="subscribers_count", - native_unit_of_measurement="Watchers", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["watchers"]["total"], @@ -61,7 +58,6 @@ class GitHubSensorEntityDescription(SensorEntityDescription): GitHubSensorEntityDescription( key="forks_count", translation_key="forks_count", - native_unit_of_measurement="Forks", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["forks_count"], @@ -69,7 +65,6 @@ class GitHubSensorEntityDescription(SensorEntityDescription): GitHubSensorEntityDescription( key="issues_count", translation_key="issues_count", - native_unit_of_measurement="Issues", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["issue"]["total"], @@ -77,7 +72,6 @@ class GitHubSensorEntityDescription(SensorEntityDescription): GitHubSensorEntityDescription( key="pulls_count", translation_key="pulls_count", - native_unit_of_measurement="Pull Requests", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["pull_request"]["total"], diff --git a/homeassistant/components/github/strings.json b/homeassistant/components/github/strings.json index 38b796e2fd22b..bcda47d72fb79 100644 --- a/homeassistant/components/github/strings.json +++ b/homeassistant/components/github/strings.json @@ -19,22 +19,28 @@ "entity": { "sensor": { "discussions_count": { - "name": "Discussions" + "name": "Discussions", + "unit_of_measurement": "discussions" }, "stargazers_count": { - "name": "Stars" + "name": "Stars", + "unit_of_measurement": "stars" }, "subscribers_count": { - "name": "Watchers" + "name": "Watchers", + "unit_of_measurement": "watchers" }, "forks_count": { - "name": "Forks" + "name": "Forks", + "unit_of_measurement": "forks" }, "issues_count": { - "name": "Issues" + "name": "Issues", + "unit_of_measurement": "issues" }, "pulls_count": { - "name": "Pull requests" + "name": "Pull requests", + "unit_of_measurement": "pull requests" }, "latest_commit": { "name": "Latest commit" diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 2c3b9b4d99b98..2965ccb740601 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -368,6 +368,9 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: }, slug_validator=translation_key_validator, ), + vol.Optional( + "unit_of_measurement" + ): translation_value_validator, }, slug_validator=translation_key_validator, ), diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 3b60c7f695bc6..d4a78807e2b20 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -64,10 +64,16 @@ def test_load_translations_files_by_language( "test": { "entity": { "switch": { - "other1": {"name": "Other 1"}, + "other1": { + "name": "Other 1", + "unit_of_measurement": "units", + }, "other2": {"name": "Other 2"}, "other3": {"name": "Other 3"}, - "other4": {"name": "Other 4"}, + "other4": { + "name": "Other 4", + "unit_of_measurement": "quantities", + }, "outlet": {"name": "Outlet " "{placeholder}"}, } }, @@ -87,9 +93,11 @@ def test_load_translations_files_by_language( "en", { "component.test.entity.switch.other1.name": "Other 1", + "component.test.entity.switch.other1.unit_of_measurement": "units", "component.test.entity.switch.other2.name": "Other 2", "component.test.entity.switch.other3.name": "Other 3", "component.test.entity.switch.other4.name": "Other 4", + "component.test.entity.switch.other4.unit_of_measurement": "quantities", "component.test.entity.switch.outlet.name": "Outlet {placeholder}", }, [], @@ -98,9 +106,11 @@ def test_load_translations_files_by_language( "es", { "component.test.entity.switch.other1.name": "Otra 1", + "component.test.entity.switch.other1.unit_of_measurement": "units", "component.test.entity.switch.other2.name": "Otra 2", "component.test.entity.switch.other3.name": "Otra 3", "component.test.entity.switch.other4.name": "Otra 4", + "component.test.entity.switch.other4.unit_of_measurement": "quantities", "component.test.entity.switch.outlet.name": "Enchufe {placeholder}", }, [], @@ -110,12 +120,14 @@ def test_load_translations_files_by_language( { # Correct "component.test.entity.switch.other1.name": "Anderes 1", + "component.test.entity.switch.other1.unit_of_measurement": "einheiten", # Translation has placeholder missing in English "component.test.entity.switch.other2.name": "Other 2", # Correct (empty translation) "component.test.entity.switch.other3.name": "", # Translation missing "component.test.entity.switch.other4.name": "Other 4", + "component.test.entity.switch.other4.unit_of_measurement": "quantities", # Mismatch in placeholders "component.test.entity.switch.outlet.name": "Outlet {placeholder}", }, @@ -166,9 +178,11 @@ async def test_get_translations(hass: HomeAssistant, mock_config_flows) -> None: assert translations == { "component.test.entity.switch.other1.name": "Other 1", + "component.test.entity.switch.other1.unit_of_measurement": "units", "component.test.entity.switch.other2.name": "Other 2", "component.test.entity.switch.other3.name": "Other 3", "component.test.entity.switch.other4.name": "Other 4", + "component.test.entity.switch.other4.unit_of_measurement": "quantities", "component.test.entity.switch.outlet.name": "Outlet {placeholder}", } @@ -176,24 +190,33 @@ async def test_get_translations(hass: HomeAssistant, mock_config_flows) -> None: hass, "de", "entity", {"test"} ) + # Test a partial translation assert translations == { + # Correct "component.test.entity.switch.other1.name": "Anderes 1", + "component.test.entity.switch.other1.unit_of_measurement": "einheiten", + # Translation has placeholder missing in English "component.test.entity.switch.other2.name": "Other 2", + # Correct (empty translation) "component.test.entity.switch.other3.name": "", + # Translation missing "component.test.entity.switch.other4.name": "Other 4", + "component.test.entity.switch.other4.unit_of_measurement": "quantities", + # Mismatch in placeholders "component.test.entity.switch.outlet.name": "Outlet {placeholder}", } - # Test a partial translation translations = await translation.async_get_translations( hass, "es", "entity", {"test"} ) assert translations == { "component.test.entity.switch.other1.name": "Otra 1", + "component.test.entity.switch.other1.unit_of_measurement": "units", "component.test.entity.switch.other2.name": "Otra 2", "component.test.entity.switch.other3.name": "Otra 3", "component.test.entity.switch.other4.name": "Otra 4", + "component.test.entity.switch.other4.unit_of_measurement": "quantities", "component.test.entity.switch.outlet.name": "Enchufe {placeholder}", } @@ -204,9 +227,11 @@ async def test_get_translations(hass: HomeAssistant, mock_config_flows) -> None: assert translations == { "component.test.entity.switch.other1.name": "Other 1", + "component.test.entity.switch.other1.unit_of_measurement": "units", "component.test.entity.switch.other2.name": "Other 2", "component.test.entity.switch.other3.name": "Other 3", "component.test.entity.switch.other4.name": "Other 4", + "component.test.entity.switch.other4.unit_of_measurement": "quantities", "component.test.entity.switch.outlet.name": "Outlet {placeholder}", } @@ -507,9 +532,11 @@ async def test_get_cached_translations(hass: HomeAssistant, mock_config_flows) - ) assert translations == { "component.test.entity.switch.other1.name": "Other 1", + "component.test.entity.switch.other1.unit_of_measurement": "units", "component.test.entity.switch.other2.name": "Other 2", "component.test.entity.switch.other3.name": "Other 3", "component.test.entity.switch.other4.name": "Other 4", + "component.test.entity.switch.other4.unit_of_measurement": "quantities", "component.test.entity.switch.outlet.name": "Outlet {placeholder}", } @@ -522,9 +549,11 @@ async def test_get_cached_translations(hass: HomeAssistant, mock_config_flows) - assert translations == { "component.test.entity.switch.other1.name": "Otra 1", + "component.test.entity.switch.other1.unit_of_measurement": "units", "component.test.entity.switch.other2.name": "Otra 2", "component.test.entity.switch.other3.name": "Otra 3", "component.test.entity.switch.other4.name": "Otra 4", + "component.test.entity.switch.other4.unit_of_measurement": "quantities", "component.test.entity.switch.outlet.name": "Enchufe {placeholder}", } @@ -539,9 +568,11 @@ async def test_get_cached_translations(hass: HomeAssistant, mock_config_flows) - assert translations == { "component.test.entity.switch.other1.name": "Other 1", + "component.test.entity.switch.other1.unit_of_measurement": "units", "component.test.entity.switch.other2.name": "Other 2", "component.test.entity.switch.other3.name": "Other 3", "component.test.entity.switch.other4.name": "Other 4", + "component.test.entity.switch.other4.unit_of_measurement": "quantities", "component.test.entity.switch.outlet.name": "Outlet {placeholder}", } @@ -678,7 +709,6 @@ async def test_get_translations_still_has_title_without_translations_files( ) assert translations == translations_again - assert translations == { "component.component1.title": "Component 1", } diff --git a/tests/testing_config/custom_components/test/translations/de.json b/tests/testing_config/custom_components/test/translations/de.json index 57d26f28ec032..8cac140c75332 100644 --- a/tests/testing_config/custom_components/test/translations/de.json +++ b/tests/testing_config/custom_components/test/translations/de.json @@ -1,7 +1,10 @@ { "entity": { "switch": { - "other1": { "name": "Anderes 1" }, + "other1": { + "name": "Anderes 1", + "unit_of_measurement": "einheiten" + }, "other2": { "name": "Anderes 2 {placeholder}" }, "other3": { "name": "" }, "outlet": { "name": "Steckdose {something}" } diff --git a/tests/testing_config/custom_components/test/translations/en.json b/tests/testing_config/custom_components/test/translations/en.json index 7ed32c224a71b..802c859e922cd 100644 --- a/tests/testing_config/custom_components/test/translations/en.json +++ b/tests/testing_config/custom_components/test/translations/en.json @@ -1,10 +1,13 @@ { "entity": { "switch": { - "other1": { "name": "Other 1" }, + "other1": { "name": "Other 1", "unit_of_measurement": "units" }, "other2": { "name": "Other 2" }, "other3": { "name": "Other 3" }, - "other4": { "name": "Other 4" }, + "other4": { + "name": "Other 4", + "unit_of_measurement": "quantities" + }, "outlet": { "name": "Outlet {placeholder}" } } }, From 60774575c651c2bd3422eccf4b4b8f486b18d376 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 20 Nov 2024 12:29:36 -0600 Subject: [PATCH 0611/1070] Set HA time zone with freeze_time in agent test (#131058) * Patch dt_util instead of using freeze_time * Use freeze_time but set HA timezone --- .../conversation/test_default_agent_intents.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index 7bae9c43f700b..244fa6bda7bdc 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -36,6 +36,7 @@ intent, ) from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from tests.common import async_mock_service @@ -445,12 +446,22 @@ async def test_todo_add_item_fr( assert intent_obj.slots.get("item", {}).get("value", "").strip() == "farine" -@freeze_time(datetime(year=2013, month=9, day=17, hour=1, minute=2)) +@freeze_time( + datetime( + year=2013, + month=9, + day=17, + hour=1, + minute=2, + tzinfo=dt_util.UTC, + ) +) async def test_date_time( hass: HomeAssistant, init_components, ) -> None: """Test the date and time intents.""" + await hass.config.async_set_time_zone("UTC") result = await conversation.async_converse( hass, "what is the date", None, Context(), None ) From b188f8284c66c19debad90c150e1e2fb2604023e Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Wed, 20 Nov 2024 19:55:17 +0100 Subject: [PATCH 0612/1070] Bump pynina to 0.3.4 (#131059) --- homeassistant/components/nina/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index 53a54f26dcf56..45212c0220b2b 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/nina", "iot_class": "cloud_polling", "loggers": ["pynina"], - "requirements": ["PyNINA==0.3.3"], + "requirements": ["PyNINA==0.3.4"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index dbc682ccdad70..9bcc9f0515b65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -70,7 +70,7 @@ PyMetno==0.13.0 PyMicroBot==0.0.17 # homeassistant.components.nina -PyNINA==0.3.3 +PyNINA==0.3.4 # homeassistant.components.mobile_app # homeassistant.components.owntracks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c238c2044629..8a0841903f34a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -67,7 +67,7 @@ PyMetno==0.13.0 PyMicroBot==0.0.17 # homeassistant.components.nina -PyNINA==0.3.3 +PyNINA==0.3.4 # homeassistant.components.mobile_app # homeassistant.components.owntracks From 309dd5ed1b2ed7db433d5d4e7df34196987bbc54 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Nov 2024 19:55:54 +0100 Subject: [PATCH 0613/1070] Remove old entity unique id migration from sabnzbd (#131064) --- homeassistant/components/sabnzbd/__init__.py | 52 +------------ homeassistant/components/sabnzbd/sensor.py | 14 ---- tests/components/sabnzbd/test_init.py | 80 +------------------- 3 files changed, 2 insertions(+), 144 deletions(-) diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index b7a3482bd932d..ade281a5de1f9 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -12,8 +12,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries +from homeassistant.helpers import config_validation as cv import homeassistant.helpers.issue_registry as ir from .const import ( @@ -62,52 +61,6 @@ def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) raise ValueError(f"No api for API key: {call_data_api_key}") -def update_device_identifiers(hass: HomeAssistant, entry: ConfigEntry): - """Update device identifiers to new identifiers.""" - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get_device(identifiers={(DOMAIN, DOMAIN)}) - if device_entry and entry.entry_id in device_entry.config_entries: - new_identifiers = {(DOMAIN, entry.entry_id)} - _LOGGER.debug( - "Updating device id <%s> with new identifiers <%s>", - device_entry.id, - new_identifiers, - ) - device_registry.async_update_device( - device_entry.id, new_identifiers=new_identifiers - ) - - -async def migrate_unique_id(hass: HomeAssistant, entry: ConfigEntry): - """Migrate entities to new unique ids (with entry_id).""" - - @callback - def async_migrate_callback(entity_entry: RegistryEntry) -> dict | None: - """Define a callback to migrate appropriate SabnzbdSensor entities to new unique IDs. - - Old: description.key - New: {entry_id}_description.key - """ - entry_id = entity_entry.config_entry_id - if entry_id is None: - return None - if entity_entry.unique_id.startswith(entry_id): - return None - - new_unique_id = f"{entry_id}_{entity_entry.unique_id}" - - _LOGGER.debug( - "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", - entity_entry.entity_id, - entity_entry.unique_id, - new_unique_id, - ) - - return {"new_unique_id": new_unique_id} - - await async_migrate_entries(hass, entry.entry_id, async_migrate_callback) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the SabNzbd Component.""" @@ -115,9 +68,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not sab_api: raise ConfigEntryNotReady - await migrate_unique_id(hass, entry) - update_device_identifiers(hass, entry) - coordinator = SabnzbdUpdateCoordinator(hass, entry, sab_api) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index 1d2bbdc55e7bf..1c6a1279263d3 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -113,20 +113,6 @@ class SabnzbdSensorEntityDescription(SensorEntityDescription): ), ) -OLD_SENSOR_KEYS = [ - "current_status", - "speed", - "queue_size", - "queue_remaining", - "disk_size", - "disk_free", - "queue_count", - "day_size", - "week_size", - "month_size", - "total_size", -] - async def async_setup_entry( hass: HomeAssistant, diff --git a/tests/components/sabnzbd/test_init.py b/tests/components/sabnzbd/test_init.py index 889ea81d656aa..1c7415ceccafb 100644 --- a/tests/components/sabnzbd/test_init.py +++ b/tests/components/sabnzbd/test_init.py @@ -1,93 +1,15 @@ """Tests for the SABnzbd Integration.""" -from unittest.mock import patch - import pytest from homeassistant.components.sabnzbd.const import ( ATTR_API_KEY, - DEFAULT_NAME, DOMAIN, SERVICE_PAUSE, SERVICE_RESUME, ) -from homeassistant.components.sabnzbd.sensor import OLD_SENSOR_KEYS -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) - -from tests.common import MockConfigEntry - -MOCK_ENTRY_ID = "mock_entry_id" - -MOCK_UNIQUE_ID = "someuniqueid" - -MOCK_DEVICE_ID = "somedeviceid" - -MOCK_DATA_VERSION_1 = { - CONF_API_KEY: "api_key", - CONF_URL: "http://127.0.0.1:8080", - CONF_NAME: "name", -} - -MOCK_ENTRY_VERSION_1 = MockConfigEntry( - domain=DOMAIN, data=MOCK_DATA_VERSION_1, entry_id=MOCK_ENTRY_ID, version=1 -) - - -async def test_unique_id_migrate( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test that config flow entry is migrated correctly.""" - # Start with the config entry at Version 1. - mock_entry = MOCK_ENTRY_VERSION_1 - mock_entry.add_to_hass(hass) - - mock_d_entry = device_registry.async_get_or_create( - config_entry_id=mock_entry.entry_id, - identifiers={(DOMAIN, DOMAIN)}, - name=DEFAULT_NAME, - entry_type=dr.DeviceEntryType.SERVICE, - ) - - entity_id_sensor_key = [] - - for sensor_key in OLD_SENSOR_KEYS: - mock_entity_id = f"{SENSOR_DOMAIN}.{DOMAIN}_{sensor_key}" - entity_registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - unique_id=sensor_key, - config_entry=mock_entry, - device_id=mock_d_entry.id, - ) - entity = entity_registry.async_get(mock_entity_id) - assert entity.entity_id == mock_entity_id - assert entity.unique_id == sensor_key - entity_id_sensor_key.append((mock_entity_id, sensor_key)) - - with patch( - "homeassistant.components.sabnzbd.sab.SabnzbdApi.check_available", - return_value=True, - ): - await hass.config_entries.async_setup(mock_entry.entry_id) - - await hass.async_block_till_done() - - for mock_entity_id, sensor_key in entity_id_sensor_key: - entity = entity_registry.async_get(mock_entity_id) - assert entity.unique_id == f"{MOCK_ENTRY_ID}_{sensor_key}" - - assert device_registry.async_get(mock_d_entry.id).identifiers == { - (DOMAIN, MOCK_ENTRY_ID) - } +from homeassistant.helpers import issue_registry as ir @pytest.mark.parametrize( From 06db5a55f87907c8434d4da6c2317e33b1cb1599 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Nov 2024 19:59:10 +0100 Subject: [PATCH 0614/1070] Add number platform to sabnzbd and deprecate custom action (#131029) * Add number platform to sabnzbd * Copy & waste error * Move to icon translations * Update snapshot --- homeassistant/components/sabnzbd/__init__.py | 11 +- homeassistant/components/sabnzbd/icons.json | 3 + homeassistant/components/sabnzbd/number.py | 82 ++++++++++++ homeassistant/components/sabnzbd/strings.json | 9 ++ .../sabnzbd/snapshots/test_number.ambr | 57 ++++++++ tests/components/sabnzbd/test_init.py | 2 + tests/components/sabnzbd/test_number.py | 123 ++++++++++++++++++ 7 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/sabnzbd/number.py create mode 100644 tests/components/sabnzbd/snapshots/test_number.ambr create mode 100644 tests/components/sabnzbd/test_number.py diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index ade281a5de1f9..abc9b1b135633 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -27,7 +27,7 @@ from .coordinator import SabnzbdUpdateCoordinator from .sab import get_client -PLATFORMS = [Platform.BUTTON, Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) SERVICES = ( @@ -128,6 +128,15 @@ async def async_resume_queue( async def async_set_queue_speed( call: ServiceCall, coordinator: SabnzbdUpdateCoordinator ) -> None: + ir.async_create_issue( + hass, + DOMAIN, + "set_speed_action_deprecated", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + breaks_in_ha_version="2025.6", + translation_key="set_speed_action_deprecated", + ) speed = call.data.get(ATTR_SPEED) await coordinator.sab_api.set_speed_limit(speed) diff --git a/homeassistant/components/sabnzbd/icons.json b/homeassistant/components/sabnzbd/icons.json index 78eff1f418337..190aefe4b1250 100644 --- a/homeassistant/components/sabnzbd/icons.json +++ b/homeassistant/components/sabnzbd/icons.json @@ -6,6 +6,9 @@ }, "resume": { "default": "mdi:play" + }, + "speedlimit": { + "default": "mdi:speedometer" } } }, diff --git a/homeassistant/components/sabnzbd/number.py b/homeassistant/components/sabnzbd/number.py new file mode 100644 index 0000000000000..31faca3f78bee --- /dev/null +++ b/homeassistant/components/sabnzbd/number.py @@ -0,0 +1,82 @@ +"""Number entities for the SABnzbd integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from pysabnzbd import SabnzbdApiException + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import SabnzbdUpdateCoordinator +from .entity import SabnzbdEntity + + +@dataclass(frozen=True, kw_only=True) +class SabnzbdNumberEntityDescription(NumberEntityDescription): + """Class describing a SABnzbd number entities.""" + + set_fn: Callable[[SabnzbdUpdateCoordinator, float], Awaitable] + + +NUMBER_DESCRIPTIONS: tuple[SabnzbdNumberEntityDescription, ...] = ( + SabnzbdNumberEntityDescription( + key="speedlimit", + translation_key="speedlimit", + mode=NumberMode.BOX, + native_max_value=100, + native_min_value=0, + native_step=1, + native_unit_of_measurement=PERCENTAGE, + set_fn=lambda coordinator, speed: ( + coordinator.sab_api.set_speed_limit(int(speed)) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the SABnzbd number entity.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + SabnzbdNumber(coordinator, description) for description in NUMBER_DESCRIPTIONS + ) + + +class SabnzbdNumber(SabnzbdEntity, NumberEntity): + """Representation of a SABnzbd number.""" + + entity_description: SabnzbdNumberEntityDescription + + @property + def native_value(self) -> float: + """Return latest value for number.""" + return self.coordinator.data[self.entity_description.key] + + async def async_set_native_value(self, value: float) -> None: + """Set the new number value.""" + try: + await self.entity_description.set_fn(self.coordinator, value) + except SabnzbdApiException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + ) from e + else: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 3162aab60ecc2..5d573c6a8cb5a 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -26,6 +26,11 @@ "name": "[%key:component::sabnzbd::services::resume::name%]" } }, + "number": { + "speedlimit": { + "name": "Speedlimit" + } + }, "sensor": { "status": { "name": "Status" @@ -106,6 +111,10 @@ "resume_action_deprecated": { "title": "SABnzbd resume action deprecated", "description": "The 'Resume' action is deprecated and will be removed in a future version. Please use the 'Resume' button instead. To remove this issue, please adjust automations accordingly and restart Home Assistant." + }, + "set_speed_action_deprecated": { + "title": "SABnzbd set_speed action deprecated", + "description": "The 'Set speed' action is deprecated and will be removed in a future version. Please use the 'Speedlimit' number entity instead. To remove this issue, please adjust automations accordingly and restart Home Assistant." } }, "exceptions": { diff --git a/tests/components/sabnzbd/snapshots/test_number.ambr b/tests/components/sabnzbd/snapshots/test_number.ambr new file mode 100644 index 0000000000000..6a37079726435 --- /dev/null +++ b/tests/components/sabnzbd/snapshots/test_number.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_number_setup[number.sabnzbd_speedlimit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.sabnzbd_speedlimit', + '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': 'Speedlimit', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'speedlimit', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_speedlimit', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_setup[number.sabnzbd_speedlimit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sabnzbd Speedlimit', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.sabnzbd_speedlimit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '85', + }) +# --- diff --git a/tests/components/sabnzbd/test_init.py b/tests/components/sabnzbd/test_init.py index 1c7415ceccafb..9b833875bbc76 100644 --- a/tests/components/sabnzbd/test_init.py +++ b/tests/components/sabnzbd/test_init.py @@ -7,6 +7,7 @@ DOMAIN, SERVICE_PAUSE, SERVICE_RESUME, + SERVICE_SET_SPEED, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -17,6 +18,7 @@ [ (SERVICE_RESUME, "resume_action_deprecated"), (SERVICE_PAUSE, "pause_action_deprecated"), + (SERVICE_SET_SPEED, "set_speed_action_deprecated"), ], ) @pytest.mark.usefixtures("setup_integration") diff --git a/tests/components/sabnzbd/test_number.py b/tests/components/sabnzbd/test_number.py new file mode 100644 index 0000000000000..61f7ea45ab174 --- /dev/null +++ b/tests/components/sabnzbd/test_number.py @@ -0,0 +1,123 @@ +"""Number tests for the SABnzbd component.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pysabnzbd import SabnzbdApiException +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@patch("homeassistant.components.sabnzbd.PLATFORMS", [Platform.NUMBER]) +async def test_number_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test number setup.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("number", "input_number", "called_function", "expected_state"), + [ + ("speedlimit", 50.0, "set_speed_limit", 50), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_number_set( + hass: HomeAssistant, + sabnzbd: AsyncMock, + number: str, + input_number: float, + called_function: str, + expected_state: str, +) -> None: + """Test the sabnzbd number set.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_VALUE: input_number, + ATTR_ENTITY_ID: f"number.sabnzbd_{number}", + }, + blocking=True, + ) + + function = getattr(sabnzbd, called_function) + function.assert_called_with(int(input_number)) + + +@pytest.mark.parametrize( + ("number", "input_number", "called_function"), + [("speedlimit", 55.0, "set_speed_limit")], +) +@pytest.mark.usefixtures("setup_integration") +async def test_number_exception( + hass: HomeAssistant, + sabnzbd: AsyncMock, + number: str, + input_number: float, + called_function: str, +) -> None: + """Test the number entity handles errors.""" + function = getattr(sabnzbd, called_function) + function.side_effect = SabnzbdApiException("Boom") + + with pytest.raises( + HomeAssistantError, + match="Unable to send command to SABnzbd due to a connection error, try again later", + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_VALUE: input_number, + ATTR_ENTITY_ID: f"number.sabnzbd_{number}", + }, + blocking=True, + ) + + function.assert_called_once() + + +@pytest.mark.parametrize( + ("number", "initial_state"), + [("speedlimit", "85")], +) +@pytest.mark.usefixtures("setup_integration") +async def test_number_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + sabnzbd: AsyncMock, + number: str, + initial_state: str, +) -> None: + """Test the number is unavailable when coordinator can't update data.""" + state = hass.states.get(f"number.sabnzbd_{number}") + assert state + assert state.state == initial_state + + sabnzbd.refresh_data.side_effect = Exception("Boom") + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(f"number.sabnzbd_{number}") + assert state + assert state.state == STATE_UNAVAILABLE From 75e15ec6eabac2bcf6e1af4e0a1c989cdac00762 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 20 Nov 2024 20:01:32 +0100 Subject: [PATCH 0615/1070] Use ConfigEntry runtime_data in Pure Energie (#131061) --- .../components/pure_energie/__init__.py | 17 ++++++------ .../components/pure_energie/diagnostics.py | 14 +++++----- .../components/pure_energie/sensor.py | 26 ++++++++++--------- tests/components/pure_energie/test_init.py | 2 -- 4 files changed, 29 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/pure_energie/__init__.py b/homeassistant/components/pure_energie/__init__.py index 459dc5c055ccb..4de1ce0281064 100644 --- a/homeassistant/components/pure_energie/__init__.py +++ b/homeassistant/components/pure_energie/__init__.py @@ -7,13 +7,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN from .coordinator import PureEnergieDataUpdateCoordinator -PLATFORMS = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR] +type PureEnergieConfigEntry = ConfigEntry[PureEnergieDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: PureEnergieConfigEntry) -> bool: """Set up Pure Energie from a config entry.""" coordinator = PureEnergieDataUpdateCoordinator(hass) @@ -23,14 +24,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.gridnet.close() raise - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: PureEnergieConfigEntry +) -> bool: """Unload Pure Energie config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/pure_energie/diagnostics.py b/homeassistant/components/pure_energie/diagnostics.py index 6e2b8ee7a3539..de9134129ed65 100644 --- a/homeassistant/components/pure_energie/diagnostics.py +++ b/homeassistant/components/pure_energie/diagnostics.py @@ -6,12 +6,10 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import PureEnergieDataUpdateCoordinator +from . import PureEnergieConfigEntry TO_REDACT = { CONF_HOST, @@ -20,18 +18,18 @@ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: PureEnergieConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: PureEnergieDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return { "entry": { "title": entry.title, "data": async_redact_data(entry.data, TO_REDACT), }, "data": { - "device": async_redact_data(asdict(coordinator.data.device), TO_REDACT), - "smartbridge": asdict(coordinator.data.smartbridge), + "device": async_redact_data( + asdict(entry.runtime_data.data.device), TO_REDACT + ), + "smartbridge": asdict(entry.runtime_data.data.smartbridge), }, } diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py index 85f4672a61889..468858f117f0c 100644 --- a/homeassistant/components/pure_energie/sensor.py +++ b/homeassistant/components/pure_energie/sensor.py @@ -12,13 +12,13 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import PureEnergieConfigEntry from .const import DOMAIN from .coordinator import PureEnergieData, PureEnergieDataUpdateCoordinator @@ -59,12 +59,13 @@ class PureEnergieSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: PureEnergieConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Pure Energie Sensors based on a config entry.""" async_add_entities( PureEnergieSensorEntity( - coordinator=hass.data[DOMAIN][entry.entry_id], description=description, entry=entry, ) @@ -83,21 +84,22 @@ class PureEnergieSensorEntity( def __init__( self, *, - coordinator: PureEnergieDataUpdateCoordinator, description: PureEnergieSensorEntityDescription, - entry: ConfigEntry, + entry: PureEnergieConfigEntry, ) -> None: """Initialize Pure Energie sensor.""" - super().__init__(coordinator=coordinator) + super().__init__(coordinator=entry.runtime_data) self.entity_id = f"{SENSOR_DOMAIN}.pem_{description.key}" self.entity_description = description - self._attr_unique_id = f"{coordinator.data.device.n2g_id}_{description.key}" + self._attr_unique_id = ( + f"{entry.runtime_data.data.device.n2g_id}_{description.key}" + ) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.data.device.n2g_id)}, - configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}", - sw_version=coordinator.data.device.firmware, - manufacturer=coordinator.data.device.manufacturer, - model=coordinator.data.device.model, + identifiers={(DOMAIN, entry.runtime_data.data.device.n2g_id)}, + configuration_url=f"http://{entry.runtime_data.config_entry.data[CONF_HOST]}", + sw_version=entry.runtime_data.data.device.firmware, + manufacturer=entry.runtime_data.data.device.manufacturer, + model=entry.runtime_data.data.device.model, name=entry.title, ) diff --git a/tests/components/pure_energie/test_init.py b/tests/components/pure_energie/test_init.py index 0dbd8a753e6ed..c0d0724866489 100644 --- a/tests/components/pure_energie/test_init.py +++ b/tests/components/pure_energie/test_init.py @@ -5,7 +5,6 @@ from gridnet import GridNetConnectionError import pytest -from homeassistant.components.pure_energie.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -32,7 +31,6 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED From e389ef9920e4fd2d157b22f6133acae2184d4daa Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Nov 2024 20:20:12 +0100 Subject: [PATCH 0616/1070] Use runtime_data in sabnzbd (#131069) --- homeassistant/components/sabnzbd/__init__.py | 20 +++++++++++--------- homeassistant/components/sabnzbd/button.py | 8 ++++---- homeassistant/components/sabnzbd/number.py | 6 +++--- homeassistant/components/sabnzbd/sensor.py | 10 +++------- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index abc9b1b135633..74920ba6465be 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -48,20 +48,24 @@ } ) +type SabnzbdConfigEntry = ConfigEntry[SabnzbdUpdateCoordinator] + @callback -def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) -> str: +def async_get_entry_for_service_call( + hass: HomeAssistant, call: ServiceCall +) -> SabnzbdConfigEntry: """Get the entry ID related to a service call (by device ID).""" call_data_api_key = call.data[ATTR_API_KEY] for entry in hass.config_entries.async_entries(DOMAIN): if entry.data[ATTR_API_KEY] == call_data_api_key: - return entry.entry_id + return entry raise ValueError(f"No api for API key: {call_data_api_key}") -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> bool: """Set up the SabNzbd Component.""" sab_api = await get_client(hass, entry.data) @@ -70,7 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = SabnzbdUpdateCoordinator(hass, entry, sab_api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator @callback def extract_api( @@ -82,8 +86,8 @@ def extract_api( async def wrapper(call: ServiceCall) -> None: """Wrap the service function.""" - entry_id = async_get_entry_id_for_service_call(hass, call) - coordinator: SabnzbdUpdateCoordinator = hass.data[DOMAIN][entry_id] + config_entry = async_get_entry_for_service_call(hass, call) + coordinator = config_entry.runtime_data try: await func(call, coordinator) @@ -155,11 +159,9 @@ async def async_set_queue_speed( return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> bool: """Unload a Sabnzbd config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) loaded_entries = [ entry diff --git a/homeassistant/components/sabnzbd/button.py b/homeassistant/components/sabnzbd/button.py index 4efecba18a7db..79038e847754b 100644 --- a/homeassistant/components/sabnzbd/button.py +++ b/homeassistant/components/sabnzbd/button.py @@ -7,13 +7,13 @@ from pysabnzbd import SabnzbdApiException from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SabnzbdUpdateCoordinator +from . import SabnzbdConfigEntry from .const import DOMAIN +from .coordinator import SabnzbdUpdateCoordinator from .entity import SabnzbdEntity @@ -40,11 +40,11 @@ class SabnzbdButtonEntityDescription(ButtonEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SabnzbdConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up buttons from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SabnzbdButton(coordinator, description) for description in BUTTON_DESCRIPTIONS diff --git a/homeassistant/components/sabnzbd/number.py b/homeassistant/components/sabnzbd/number.py index 31faca3f78bee..d8536cb6b3734 100644 --- a/homeassistant/components/sabnzbd/number.py +++ b/homeassistant/components/sabnzbd/number.py @@ -12,12 +12,12 @@ NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import SabnzbdConfigEntry from .const import DOMAIN from .coordinator import SabnzbdUpdateCoordinator from .entity import SabnzbdEntity @@ -48,11 +48,11 @@ class SabnzbdNumberEntityDescription(NumberEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SabnzbdConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SABnzbd number entity.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( SabnzbdNumber(coordinator, description) for description in NUMBER_DESCRIPTIONS diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index 1c6a1279263d3..115b9de37938c 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -10,14 +10,12 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN -from .coordinator import SabnzbdUpdateCoordinator +from . import SabnzbdConfigEntry from .entity import SabnzbdEntity @@ -116,13 +114,11 @@ class SabnzbdSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SabnzbdConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Sabnzbd sensor entry.""" - - entry_id = config_entry.entry_id - coordinator: SabnzbdUpdateCoordinator = hass.data[DOMAIN][entry_id] + coordinator = config_entry.runtime_data async_add_entities([SabnzbdSensor(coordinator, sensor) for sensor in SENSOR_TYPES]) From f29c6963dc538da51029d9d33014ff6dd69400df Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Nov 2024 20:22:04 +0100 Subject: [PATCH 0617/1070] Bump codecov/codecov-action to v5.0.5 (#131055) --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 40756153ee275..4c0ebe2fda37c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1248,7 +1248,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.0.4 + uses: codecov/codecov-action@v5.0.5 with: fail_ci_if_error: true flags: full-suite @@ -1386,7 +1386,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.0.4 + uses: codecov/codecov-action@v5.0.5 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From 8d6fe8b54643d9390ce6aab1e794619c23daa4d2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 20 Nov 2024 20:25:19 +0100 Subject: [PATCH 0618/1070] Record current IQS state for Twente Milieu (#131063) --- .../twentemilieu/quality_scale.yaml | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 homeassistant/components/twentemilieu/quality_scale.yaml diff --git a/homeassistant/components/twentemilieu/quality_scale.yaml b/homeassistant/components/twentemilieu/quality_scale.yaml new file mode 100644 index 0000000000000..f8fd813b03d32 --- /dev/null +++ b/homeassistant/components/twentemilieu/quality_scale.yaml @@ -0,0 +1,118 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: + status: todo + comment: | + The coordinator isn't in the common module yet. + config-flow-test-coverage: done + config-flow: + status: todo + comment: | + data_description's are missing. + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: + status: todo + comment: | + The introduction can be improved and is missing links to the provider. + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + parallel-updates: + status: exempt + comment: | + This integration only polls data using a coordinator. + Since the integration is read-only and poll-only (only provide sensor + data), there is no need to implement parallel updates. + test-coverage: done + integration-owner: done + docs-installation-parameters: todo + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have an options flow. + + # Gold + entity-translations: + status: todo + comment: | + The calendar entity name isn't translated yet. + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: + status: exempt + comment: | + This integration cannot be discovered, it is a connecting to a service + provider, which uses the users home address to get the data. + stale-devices: + status: exempt + comment: | + This integration has a fixed single device which represents the service. + diagnostics: done + exception-translations: + status: todo + comment: | + The coordinator raises, and currently, doesn't provide a translation for it. + icon-translations: done + reconfiguration-flow: todo + dynamic-devices: + status: exempt + comment: | + This integration has a fixed single device which represents the service. + discovery-update-info: + status: exempt + comment: | + This integration cannot be discovered, it is a connecting to a service + provider, which uses the users home address to get the data. + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + docs-use-cases: todo + docs-supported-devices: + status: exempt + comment: | + This is an service, which doesn't integrate with any devices. + docs-supported-functions: done + docs-data-update: todo + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done From 74f24e86c1390387c4315b7d8c3d4b7da72dae1d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Nov 2024 20:51:43 +0100 Subject: [PATCH 0619/1070] Remove import from config flow in SABnzbd (#131078) --- homeassistant/components/sabnzbd/config_flow.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/homeassistant/components/sabnzbd/config_flow.py b/homeassistant/components/sabnzbd/config_flow.py index 2637659e91a98..0d0934fee11b7 100644 --- a/homeassistant/components/sabnzbd/config_flow.py +++ b/homeassistant/components/sabnzbd/config_flow.py @@ -8,14 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import ( - CONF_API_KEY, - CONF_HOST, - CONF_NAME, - CONF_PORT, - CONF_SSL, - CONF_URL, -) +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL from .const import DEFAULT_NAME, DOMAIN from .sab import get_client @@ -64,11 +57,3 @@ async def async_step_user( data_schema=USER_SCHEMA, errors=errors, ) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import sabnzbd config from configuration.yaml.""" - protocol = "https://" if import_data[CONF_SSL] else "http://" - import_data[CONF_URL] = ( - f"{protocol}{import_data[CONF_HOST]}:{import_data[CONF_PORT]}" - ) - return await self.async_step_user(import_data) From e6225e3dcc0cb2c99128e27313c1e136f39d4d88 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 20 Nov 2024 20:54:15 +0100 Subject: [PATCH 0620/1070] Drop current quality scale (#131072) --- .../components/accuweather/manifest.json | 1 - .../components/advantage_air/manifest.json | 1 - homeassistant/components/airly/manifest.json | 1 - .../components/androidtv_remote/manifest.json | 1 - .../components/apcupsd/manifest.json | 1 - homeassistant/components/axis/manifest.json | 1 - .../bmw_connected_drive/manifest.json | 1 - homeassistant/components/bond/manifest.json | 1 - .../components/brother/manifest.json | 1 - .../components/comelit/manifest.json | 1 - homeassistant/components/deconz/manifest.json | 1 - .../devolo_home_control/manifest.json | 1 - .../devolo_home_network/manifest.json | 1 - .../components/directv/manifest.json | 1 - .../components/dlna_dms/manifest.json | 1 - .../components/dsmr_reader/manifest.json | 3 +- .../components/duotecno/manifest.json | 1 - .../components/easyenergy/manifest.json | 1 - homeassistant/components/elgato/manifest.json | 1 - .../components/energyzero/manifest.json | 1 - .../components/eq3btsmart/manifest.json | 1 - .../components/esphome/manifest.json | 1 - .../components/fastdotcom/manifest.json | 1 - .../components/forecast_solar/manifest.json | 1 - .../components/fritzbox/manifest.json | 1 - .../components/fronius/manifest.json | 1 - homeassistant/components/fyta/manifest.json | 1 - homeassistant/components/gdacs/manifest.json | 1 - .../components/geonetnz_quakes/manifest.json | 1 - homeassistant/components/gios/manifest.json | 1 - .../components/goalzero/manifest.json | 1 - .../google_assistant_sdk/manifest.json | 1 - .../manifest.json | 1 - .../homematicip_cloud/manifest.json | 1 - .../components/homewizard/manifest.json | 1 - homeassistant/components/hue/manifest.json | 1 - .../components/hyperion/manifest.json | 1 - .../components/idasen_desk/manifest.json | 1 - .../components/imgw_pib/manifest.json | 1 - homeassistant/components/ipp/manifest.json | 1 - .../components/jewish_calendar/manifest.json | 1 - homeassistant/components/knx/manifest.json | 1 - .../components/lametric/manifest.json | 1 - .../components/litejet/manifest.json | 1 - .../components/luftdaten/manifest.json | 1 - homeassistant/components/lyric/manifest.json | 1 - .../components/minecraft_server/manifest.json | 1 - homeassistant/components/modbus/manifest.json | 1 - homeassistant/components/mqtt/manifest.json | 1 - homeassistant/components/nam/manifest.json | 1 - homeassistant/components/nest/manifest.json | 1 - .../components/nextdns/manifest.json | 1 - .../components/nightscout/manifest.json | 1 - homeassistant/components/nws/manifest.json | 1 - .../components/onewire/manifest.json | 1 - .../components/p1_monitor/manifest.json | 1 - homeassistant/components/point/manifest.json | 1 - .../components/pure_energie/manifest.json | 1 - .../components/pvoutput/manifest.json | 1 - .../pvpc_hourly_pricing/manifest.json | 1 - homeassistant/components/pyload/manifest.json | 1 - homeassistant/components/rdw/manifest.json | 1 - .../components/renault/manifest.json | 1 - homeassistant/components/ring/manifest.json | 1 - homeassistant/components/risco/manifest.json | 1 - .../rituals_perfume_genie/manifest.json | 1 - homeassistant/components/roku/manifest.json | 1 - .../components/russound_rio/manifest.json | 1 - .../components/sensibo/manifest.json | 1 - homeassistant/components/shelly/manifest.json | 1 - .../components/smarttub/manifest.json | 1 - homeassistant/components/sonarr/manifest.json | 1 - .../components/songpal/manifest.json | 1 - .../components/spotify/manifest.json | 1 - .../components/starlink/manifest.json | 1 - .../components/switcher_kis/manifest.json | 1 - .../components/syncthing/manifest.json | 1 - .../components/system_bridge/manifest.json | 1 - .../components/tailscale/manifest.json | 1 - .../components/tailwind/manifest.json | 1 - .../components/tankerkoenig/manifest.json | 1 - .../components/technove/manifest.json | 1 - homeassistant/components/tedee/manifest.json | 1 - .../components/tellduslive/manifest.json | 1 - .../components/tesla_fleet/manifest.json | 1 - .../components/teslemetry/manifest.json | 1 - homeassistant/components/tessie/manifest.json | 1 - homeassistant/components/tibber/manifest.json | 1 - homeassistant/components/tplink/manifest.json | 1 - .../components/twentemilieu/manifest.json | 1 - homeassistant/components/unifi/manifest.json | 1 - .../components/uptimerobot/manifest.json | 1 - homeassistant/components/vizio/manifest.json | 1 - .../components/vodafone_station/manifest.json | 1 - homeassistant/components/vulcan/manifest.json | 1 - .../components/webostv/manifest.json | 1 - .../components/wilight/manifest.json | 1 - .../components/withings/manifest.json | 1 - homeassistant/components/wiz/manifest.json | 1 - homeassistant/components/wled/manifest.json | 1 - homeassistant/components/ws66i/manifest.json | 1 - .../components/yeelight/manifest.json | 1 - homeassistant/components/zodiac/manifest.json | 3 +- .../components/zwave_js/manifest.json | 1 - script/hassfest/manifest.py | 35 ------------------- 105 files changed, 2 insertions(+), 141 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 1c21a72ee1aa2..75f4a265b5f94 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -7,7 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["accuweather"], - "quality_scale": "platinum", "requirements": ["accuweather==4.0.0"], "single_config_entry": true } diff --git a/homeassistant/components/advantage_air/manifest.json b/homeassistant/components/advantage_air/manifest.json index a07d14896eb06..553a641b6036c 100644 --- a/homeassistant/components/advantage_air/manifest.json +++ b/homeassistant/components/advantage_air/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/advantage_air", "iot_class": "local_polling", "loggers": ["advantage_air"], - "quality_scale": "platinum", "requirements": ["advantage-air==0.4.4"] } diff --git a/homeassistant/components/airly/manifest.json b/homeassistant/components/airly/manifest.json index 233625ab04a72..ccd37589e8c58 100644 --- a/homeassistant/components/airly/manifest.json +++ b/homeassistant/components/airly/manifest.json @@ -7,6 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["airly"], - "quality_scale": "platinum", "requirements": ["airly==1.1.0"] } diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index a06152fa57045..d9c2dd05c4464 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -7,7 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], - "quality_scale": "platinum", "requirements": ["androidtvremote2==0.1.2"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index b20e0c8aacfd1..3713b74fff7b5 100644 --- a/homeassistant/components/apcupsd/manifest.json +++ b/homeassistant/components/apcupsd/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/apcupsd", "iot_class": "local_polling", "loggers": ["apcaccess"], - "quality_scale": "silver", "requirements": ["aioapcaccess==0.4.2"] } diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index d2265307d4754..7163437361a48 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -29,7 +29,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["axis"], - "quality_scale": "platinum", "requirements": ["axis==63"], "ssdp": [ { diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 584eb1eebb554..ed0919a5dcfe3 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "quality_scale": "platinum", "requirements": ["bimmer-connected[china]==0.16.4"] } diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 08e4fb007b7ee..1d4c110f4fd6a 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/bond", "iot_class": "local_push", "loggers": ["bond_async"], - "quality_scale": "platinum", "requirements": ["bond-async==0.2.1"], "zeroconf": ["_bond._tcp.local."] } diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 4e773a6cff26f..fa70f3a5dc501 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -8,7 +8,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], - "quality_scale": "platinum", "requirements": ["brother==4.3.1"], "zeroconf": [ { diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index d25d5c1d7d53f..d7417ad4aadc4 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,6 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "quality_scale": "silver", "requirements": ["aiocomelit==0.9.1"] } diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 04aaa6bc3246c..93ae8e392c8d2 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -7,7 +7,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pydeconz"], - "quality_scale": "platinum", "requirements": ["pydeconz==118"], "ssdp": [ { diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json index eb85e82755111..a9715fffa8411 100644 --- a/homeassistant/components/devolo_home_control/manifest.json +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -8,7 +8,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["devolo_home_control_api"], - "quality_scale": "gold", "requirements": ["devolo-home-control-api==0.18.3"], "zeroconf": ["_dvl-deviceapi._tcp.local."] } diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index 27fd08898c06e..d10e14f908151 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -7,7 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["devolo_plc_api"], - "quality_scale": "platinum", "requirements": ["devolo-plc-api==1.4.1"], "zeroconf": [ { diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json index 957bbff0acc22..bee2c29763570 100644 --- a/homeassistant/components/directv/manifest.json +++ b/homeassistant/components/directv/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/directv", "iot_class": "local_polling", "loggers": ["directv"], - "quality_scale": "silver", "requirements": ["directv==0.4.0"], "ssdp": [ { diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 091e083ceda10..1913bb9d5d79a 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -7,7 +7,6 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", - "quality_scale": "platinum", "requirements": ["async-upnp-client==0.41.0"], "ssdp": [ { diff --git a/homeassistant/components/dsmr_reader/manifest.json b/homeassistant/components/dsmr_reader/manifest.json index 7adb664fbd874..9c0e6da2c46a3 100644 --- a/homeassistant/components/dsmr_reader/manifest.json +++ b/homeassistant/components/dsmr_reader/manifest.json @@ -6,6 +6,5 @@ "dependencies": ["mqtt"], "documentation": "https://www.home-assistant.io/integrations/dsmr_reader", "iot_class": "local_push", - "mqtt": ["dsmr/#"], - "quality_scale": "gold" + "mqtt": ["dsmr/#"] } diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 2a427e36e8425..7a79902eae32c 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/duotecno", "iot_class": "local_push", "loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"], - "quality_scale": "silver", "requirements": ["pyDuotecno==2024.10.1"], "single_config_entry": true } diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 4d45dc2d399e8..2543219616946 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -5,6 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", - "quality_scale": "platinum", "requirements": ["easyenergy==2.1.2"] } diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index c68902560b9ee..734ad5ec93008 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/elgato", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "platinum", "requirements": ["elgato==5.1.2"], "zeroconf": ["_elg._tcp.local."] } diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 807a0419967a3..bb867e88d850b 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -5,6 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/energyzero", "iot_class": "cloud_polling", - "quality_scale": "platinum", "requirements": ["energyzero==2.1.1"] } diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index b30f806bf63a8..ed80ad9aabfe6 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,6 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "quality_scale": "silver", "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==1.1.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b9b6a98dcd13d..2d6fae0816ac6 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,6 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], - "quality_scale": "platinum", "requirements": [ "aioesphomeapi==27.0.1", "esphome-dashboard-api==1.2.3", diff --git a/homeassistant/components/fastdotcom/manifest.json b/homeassistant/components/fastdotcom/manifest.json index 9e2e077858cb8..10b6fdb5b5d6d 100644 --- a/homeassistant/components/fastdotcom/manifest.json +++ b/homeassistant/components/fastdotcom/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/fastdotcom", "iot_class": "cloud_polling", "loggers": ["fastdotcom"], - "quality_scale": "gold", "requirements": ["fastdotcom==0.0.3"], "single_config_entry": true } diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index 6f34d7918579e..1eb9c98701dc9 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/forecast_solar", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "platinum", "requirements": ["forecast-solar==4.0.0"] } diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 3735c16571e82..1a127597b81a0 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,7 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], - "quality_scale": "gold", "requirements": ["pyfritzhome==0.6.12"], "ssdp": [ { diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index c2f635119aa9b..227234f993796 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -11,6 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/fronius", "iot_class": "local_polling", "loggers": ["pyfronius"], - "quality_scale": "platinum", "requirements": ["PyFronius==0.7.3"] } diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index 17fe5199eee19..cc052e9e11fe4 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -7,6 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["fyta_cli"], - "quality_scale": "platinum", "requirements": ["fyta_cli==0.6.10"] } diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json index fab47e0090431..a40dc8cf91baa 100644 --- a/homeassistant/components/gdacs/manifest.json +++ b/homeassistant/components/gdacs/manifest.json @@ -7,6 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_georss_gdacs", "aio_georss_client"], - "quality_scale": "platinum", "requirements": ["aio-georss-gdacs==0.10"] } diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json index 2314dabcf0fac..e8f4ee1a8c13a 100644 --- a/homeassistant/components/geonetnz_quakes/manifest.json +++ b/homeassistant/components/geonetnz_quakes/manifest.json @@ -7,6 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_geonetnz_quakes"], - "quality_scale": "platinum", "requirements": ["aio-geojson-geonetnz-quakes==0.16"] } diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index b1eae512688a1..3d2e719fab62f 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,6 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "quality_scale": "platinum", "requirements": ["gios==5.0.0"] } diff --git a/homeassistant/components/goalzero/manifest.json b/homeassistant/components/goalzero/manifest.json index f1bfc7de87674..a9fcbf26d3696 100644 --- a/homeassistant/components/goalzero/manifest.json +++ b/homeassistant/components/goalzero/manifest.json @@ -15,6 +15,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["goalzero"], - "quality_scale": "silver", "requirements": ["goalzero==0.2.2"] } diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json index 9c3a3e03dfda3..85469a464b3bc 100644 --- a/homeassistant/components/google_assistant_sdk/manifest.json +++ b/homeassistant/components/google_assistant_sdk/manifest.json @@ -7,7 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "platinum", "requirements": ["gassist-text==0.0.11"], "single_config_entry": true } diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index f390b1f83e9d4..7b687b7da6fcf 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -8,6 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "platinum", "requirements": ["google-generativeai==0.8.2"] } diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 97af964ffc782..7878a8b4e0aca 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "quality_scale": "silver", "requirements": ["homematicip==1.1.3"] } diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 65672903eb8ef..233c39e5275ca 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/homewizard", "iot_class": "local_polling", "loggers": ["homewizard_energy"], - "quality_scale": "platinum", "requirements": ["python-homewizard-energy==v6.3.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index dbd9b5119771e..22f1d3991e7a5 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -10,7 +10,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiohue"], - "quality_scale": "platinum", "requirements": ["aiohue==4.7.3"], "zeroconf": ["_hue._tcp.local."] } diff --git a/homeassistant/components/hyperion/manifest.json b/homeassistant/components/hyperion/manifest.json index f18491044fa58..684fb276f53a9 100644 --- a/homeassistant/components/hyperion/manifest.json +++ b/homeassistant/components/hyperion/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hyperion", "iot_class": "local_push", "loggers": ["hyperion"], - "quality_scale": "platinum", "requirements": ["hyperion-py==0.7.5"], "ssdp": [ { diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 17a5f519274ae..0f8c9eaafc9f2 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -11,6 +11,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/idasen_desk", "iot_class": "local_push", - "quality_scale": "silver", "requirements": ["idasen-ha==2.6.2"] } diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index c01be10fc6886..b5c35f3f1eb62 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -5,6 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", - "quality_scale": "platinum", "requirements": ["imgw_pib==1.0.6"] } diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index baa41cf00bd0d..54c26b63585f8 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -7,7 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], - "quality_scale": "platinum", "requirements": ["pyipp==0.17.0"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index 2642f6c81e9ed..6d2fe8ecfa1e0 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "iot_class": "calculated", "loggers": ["hdate"], - "quality_scale": "silver", "requirements": ["hdate==0.10.9"], "single_config_entry": true } diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 39e3dced0d55d..aed7f3ed45588 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -9,7 +9,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["xknx", "xknxproject"], - "quality_scale": "platinum", "requirements": [ "xknx==3.3.0", "xknxproject==3.8.1", diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json index 92ccd29c916aa..b0c6f8fd96e61 100644 --- a/homeassistant/components/lametric/manifest.json +++ b/homeassistant/components/lametric/manifest.json @@ -13,7 +13,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["demetriek"], - "quality_scale": "platinum", "requirements": ["demetriek==0.4.0"], "ssdp": [ { diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json index 1df907029a924..cd2e5fda11ac3 100644 --- a/homeassistant/components/litejet/manifest.json +++ b/homeassistant/components/litejet/manifest.json @@ -7,7 +7,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pylitejet"], - "quality_scale": "platinum", "requirements": ["pylitejet==0.6.3"], "single_config_entry": true } diff --git a/homeassistant/components/luftdaten/manifest.json b/homeassistant/components/luftdaten/manifest.json index 96927bdd4a85c..bafffe4d6ae8f 100644 --- a/homeassistant/components/luftdaten/manifest.json +++ b/homeassistant/components/luftdaten/manifest.json @@ -7,6 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["luftdaten"], - "quality_scale": "gold", "requirements": ["luftdaten==0.7.4"] } diff --git a/homeassistant/components/lyric/manifest.json b/homeassistant/components/lyric/manifest.json index 8bed909ace2a1..cca69969f7008 100644 --- a/homeassistant/components/lyric/manifest.json +++ b/homeassistant/components/lyric/manifest.json @@ -21,6 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/lyric", "iot_class": "cloud_polling", "loggers": ["aiolyric"], - "quality_scale": "silver", "requirements": ["aiolyric==2.0.1"] } diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index 8e098f98a15d9..d6ade4853c960 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/minecraft_server", "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], - "quality_scale": "platinum", "requirements": ["mcstatus==11.1.1"] } diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 4482801482fca..7cba4692eb652 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -5,6 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], - "quality_scale": "silver", "requirements": ["pymodbus==3.6.9"] } diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index 25e98c01aafe2..081449b142a66 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -7,7 +7,6 @@ "dependencies": ["file_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/mqtt", "iot_class": "local_push", - "quality_scale": "platinum", "requirements": ["paho-mqtt==1.6.1"], "single_config_entry": true } diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 7b37d1f7ede59..d837aa69b9df5 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -7,7 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], - "quality_scale": "platinum", "requirements": ["nettigo-air-monitor==3.3.0"], "zeroconf": [ { diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 44eaeeaf62d19..07c34c515685d 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,6 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "quality_scale": "platinum", "requirements": ["google-nest-sdm==6.1.5"] } diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index ab80c83357b61..d10a1728a94a2 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -7,6 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["nextdns"], - "quality_scale": "platinum", "requirements": ["nextdns==4.0.0"] } diff --git a/homeassistant/components/nightscout/manifest.json b/homeassistant/components/nightscout/manifest.json index 3551b29ee0b0a..9b075a6df8754 100644 --- a/homeassistant/components/nightscout/manifest.json +++ b/homeassistant/components/nightscout/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nightscout", "iot_class": "cloud_polling", "loggers": ["py_nightscout"], - "quality_scale": "platinum", "requirements": ["py-nightscout==1.2.2"] } diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index d11a0e62bcf04..0e02e652b49e0 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nws", "iot_class": "cloud_polling", "loggers": ["metar", "pynws"], - "quality_scale": "platinum", "requirements": ["pynws[retry]==1.8.2"] } diff --git a/homeassistant/components/onewire/manifest.json b/homeassistant/components/onewire/manifest.json index 32a0822307580..4f3cb5d04ab5d 100644 --- a/homeassistant/components/onewire/manifest.json +++ b/homeassistant/components/onewire/manifest.json @@ -7,6 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyownet"], - "quality_scale": "gold", "requirements": ["pyownet==0.10.0.post1"] } diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json index dfc681977a5b8..28016242a6a30 100644 --- a/homeassistant/components/p1_monitor/manifest.json +++ b/homeassistant/components/p1_monitor/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/p1_monitor", "iot_class": "local_polling", "loggers": ["p1monitor"], - "quality_scale": "platinum", "requirements": ["p1monitor==3.1.0"] } diff --git a/homeassistant/components/point/manifest.json b/homeassistant/components/point/manifest.json index 7b0a2f0e01e8c..5aa733b510f38 100644 --- a/homeassistant/components/point/manifest.json +++ b/homeassistant/components/point/manifest.json @@ -7,6 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/point", "iot_class": "cloud_polling", "loggers": ["pypoint"], - "quality_scale": "silver", "requirements": ["pypoint==3.0.0"] } diff --git a/homeassistant/components/pure_energie/manifest.json b/homeassistant/components/pure_energie/manifest.json index ff52ec0ecf9a9..9efb1734f8488 100644 --- a/homeassistant/components/pure_energie/manifest.json +++ b/homeassistant/components/pure_energie/manifest.json @@ -5,7 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/pure_energie", "iot_class": "local_polling", - "quality_scale": "platinum", "requirements": ["gridnet==5.0.1"], "zeroconf": [ { diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index 61bd6fd616426..bc96bc5061dad 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pvoutput", "integration_type": "device", "iot_class": "cloud_polling", - "quality_scale": "platinum", "requirements": ["pvo==2.1.1"] } diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json index 8db978135f62c..ccddbece7e43f 100644 --- a/homeassistant/components/pvpc_hourly_pricing/manifest.json +++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pvpc_hourly_pricing", "iot_class": "cloud_polling", "loggers": ["aiopvpc"], - "quality_scale": "platinum", "requirements": ["aiopvpc==4.2.2"] } diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index 788cdd1eb05d6..e21167cf10be9 100644 --- a/homeassistant/components/pyload/manifest.json +++ b/homeassistant/components/pyload/manifest.json @@ -7,6 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["pyloadapi"], - "quality_scale": "platinum", "requirements": ["PyLoadAPI==1.3.2"] } diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index 7af3e86134797..2ab90e55ef082 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rdw", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "platinum", "requirements": ["vehicle==2.2.2"] } diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 716f2086bf1d9..396410dfc2021 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -7,6 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["renault_api"], - "quality_scale": "platinum", "requirements": ["renault-api==0.2.7"] } diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index e431c68008132..22a7171332f0b 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -29,6 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "quality_scale": "silver", "requirements": ["ring-doorbell==0.9.12"] } diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 372d8e0c6298c..c226c1c590d56 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/risco", "iot_class": "local_push", "loggers": ["pyrisco"], - "quality_scale": "platinum", "requirements": ["pyrisco==0.6.4"] } diff --git a/homeassistant/components/rituals_perfume_genie/manifest.json b/homeassistant/components/rituals_perfume_genie/manifest.json index 996dd1faecf96..114491d91223a 100644 --- a/homeassistant/components/rituals_perfume_genie/manifest.json +++ b/homeassistant/components/rituals_perfume_genie/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie", "iot_class": "cloud_polling", "loggers": ["pyrituals"], - "quality_scale": "silver", "requirements": ["pyrituals==0.0.6"] } diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index fa9823de17251..7fe2fb3b68631 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -10,7 +10,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["rokuecp"], - "quality_scale": "silver", "requirements": ["rokuecp==0.19.3"], "ssdp": [ { diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index ab77ca3ab6af3..2cd153c232c43 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/russound_rio", "iot_class": "local_push", "loggers": ["aiorussound"], - "quality_scale": "silver", "requirements": ["aiorussound==4.1.0"] } diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index 610695aaf7be1..e6398c5076e1c 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -14,6 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["pysensibo"], - "quality_scale": "platinum", "requirements": ["pysensibo==1.1.0"] } diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 38437fb213761..c4a22a77739f5 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "quality_scale": "platinum", "requirements": ["aioshelly==12.0.1"], "zeroconf": [ { diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index 432f6338d9f99..d5102f14437f1 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smarttub", "iot_class": "cloud_polling", "loggers": ["smarttub"], - "quality_scale": "platinum", "requirements": ["python-smarttub==0.0.38"] } diff --git a/homeassistant/components/sonarr/manifest.json b/homeassistant/components/sonarr/manifest.json index bfc2b6f787fdd..c81dc9c39729d 100644 --- a/homeassistant/components/sonarr/manifest.json +++ b/homeassistant/components/sonarr/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sonarr", "iot_class": "local_polling", "loggers": ["aiopyarr"], - "quality_scale": "silver", "requirements": ["aiopyarr==23.4.0"] } diff --git a/homeassistant/components/songpal/manifest.json b/homeassistant/components/songpal/manifest.json index c4dec6b938da4..a04bea0c48dea 100644 --- a/homeassistant/components/songpal/manifest.json +++ b/homeassistant/components/songpal/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/songpal", "iot_class": "local_push", "loggers": ["songpal"], - "quality_scale": "gold", "requirements": ["python-songpal==0.16.2"], "ssdp": [ { diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 8f8f7e0d5882a..e7b24cb3e1d86 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -8,7 +8,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["spotipy"], - "quality_scale": "silver", "requirements": ["spotifyaio==0.8.8"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index ab5e2345795c3..070cbf1b44c22 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -5,6 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/starlink", "iot_class": "local_polling", - "quality_scale": "silver", "requirements": ["starlink-grpc-core==1.2.0"] } diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index bdedab03f1628..987dac65077b2 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/switcher_kis", "iot_class": "local_push", "loggers": ["aioswitcher"], - "quality_scale": "platinum", "requirements": ["aioswitcher==5.0.0"], "single_config_entry": true } diff --git a/homeassistant/components/syncthing/manifest.json b/homeassistant/components/syncthing/manifest.json index f7fd2b7ece661..612665913d0f0 100644 --- a/homeassistant/components/syncthing/manifest.json +++ b/homeassistant/components/syncthing/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/syncthing", "iot_class": "local_polling", "loggers": ["aiosyncthing"], - "quality_scale": "silver", "requirements": ["aiosyncthing==0.5.1"] } diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index e886bcad15014..2799cf31fdd31 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -9,7 +9,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["systembridgeconnector"], - "quality_scale": "silver", "requirements": ["systembridgeconnector==4.1.5", "systembridgemodels==4.2.4"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/tailscale/manifest.json b/homeassistant/components/tailscale/manifest.json index 24f485fcdbdfb..7d571fe0675a9 100644 --- a/homeassistant/components/tailscale/manifest.json +++ b/homeassistant/components/tailscale/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tailscale", "integration_type": "hub", "iot_class": "cloud_polling", - "quality_scale": "platinum", "requirements": ["tailscale==0.6.1"] } diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json index 97d08737a8708..705f591785f50 100644 --- a/homeassistant/components/tailwind/manifest.json +++ b/homeassistant/components/tailwind/manifest.json @@ -11,7 +11,6 @@ "documentation": "https://www.home-assistant.io/integrations/tailwind", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "platinum", "requirements": ["gotailwind==0.2.4"], "zeroconf": [ { diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index eeb8646bea74c..72248d006e0d4 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tankerkoenig", "iot_class": "cloud_polling", "loggers": ["aiotankerkoenig"], - "quality_scale": "platinum", "requirements": ["aiotankerkoenig==0.4.2"] } diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json index ae0e491235f46..722aa4004e1e7 100644 --- a/homeassistant/components/technove/manifest.json +++ b/homeassistant/components/technove/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/technove", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "platinum", "requirements": ["python-technove==1.3.1"], "zeroconf": ["_technove-stations._tcp.local."] } diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index bca51f08f935a..67871f4c434a1 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -7,6 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", "loggers": ["aiotedee"], - "quality_scale": "platinum", "requirements": ["aiotedee==0.2.20"] } diff --git a/homeassistant/components/tellduslive/manifest.json b/homeassistant/components/tellduslive/manifest.json index dc1389c15c573..4ebf1a334bd66 100644 --- a/homeassistant/components/tellduslive/manifest.json +++ b/homeassistant/components/tellduslive/manifest.json @@ -5,6 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tellduslive", "iot_class": "cloud_polling", - "quality_scale": "silver", "requirements": ["tellduslive==0.10.12"] } diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 8d6e5f110683f..f27929032d7b3 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,6 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "quality_scale": "gold", "requirements": ["tesla-fleet-api==0.8.4"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 6b667094d6290..fc82dea6445ae 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "quality_scale": "platinum", "requirements": ["tesla-fleet-api==0.8.4", "teslemetry-stream==0.4.2"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 92aa289ca470b..cab9f4c706df1 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "quality_scale": "platinum", "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.8.4"] } diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index bc9304ab59d37..3a3a772a93432 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -7,6 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tibber", "iot_class": "cloud_polling", "loggers": ["tibber"], - "quality_scale": "silver", "requirements": ["pyTibber==0.30.8"] } diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index cb8a55b3db21a..67ae1af90034c 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -300,6 +300,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], - "quality_scale": "platinum", "requirements": ["python-kasa[speedups]==0.7.7"] } diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index 8ba4f3b760e73..a89091948c281 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -7,6 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["twentemilieu"], - "quality_scale": "platinum", "requirements": ["twentemilieu==2.1.0"] } diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 6f92dec5361eb..66d0a53284b1c 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "quality_scale": "platinum", "requirements": ["aiounifi==80"], "ssdp": [ { diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 254409cff7e8e..67e57f46986f1 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/uptimerobot", "iot_class": "cloud_polling", "loggers": ["pyuptimerobot"], - "quality_scale": "platinum", "requirements": ["pyuptimerobot==22.2.0"] } diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index e6812ed58b106..91b2ff4649588 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -7,7 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pyvizio"], - "quality_scale": "platinum", "requirements": ["pyvizio==0.1.61"], "zeroconf": ["_viziocast._tcp.local."] } diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 29cb3c070abcc..4acafc8df3a3c 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -7,6 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "quality_scale": "silver", "requirements": ["aiovodafone==0.6.1"] } diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json index 47ab7ec53cb8a..554a82e9c2ce5 100644 --- a/homeassistant/components/vulcan/manifest.json +++ b/homeassistant/components/vulcan/manifest.json @@ -5,6 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vulcan", "iot_class": "cloud_polling", - "quality_scale": "silver", "requirements": ["vulcan-api==2.3.2"] } diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 679bad9b9f5b7..6c826c2f997be 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "quality_scale": "platinum", "requirements": ["aiowebostv==0.4.2"], "ssdp": [ { diff --git a/homeassistant/components/wilight/manifest.json b/homeassistant/components/wilight/manifest.json index 8da0ffd92411c..7f7e16d55fbaa 100644 --- a/homeassistant/components/wilight/manifest.json +++ b/homeassistant/components/wilight/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/wilight", "iot_class": "local_polling", "loggers": ["pywilight"], - "quality_scale": "silver", "requirements": ["pywilight==0.0.74"], "ssdp": [ { diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index f9e8328ae53c9..57d4bafdc7b09 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -8,6 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/withings", "iot_class": "cloud_push", "loggers": ["aiowithings"], - "quality_scale": "platinum", "requirements": ["aiowithings==3.1.3"] } diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json index bb5527bc4679f..7b1ecdcdb6ba2 100644 --- a/homeassistant/components/wiz/manifest.json +++ b/homeassistant/components/wiz/manifest.json @@ -26,6 +26,5 @@ ], "documentation": "https://www.home-assistant.io/integrations/wiz", "iot_class": "local_push", - "quality_scale": "platinum", "requirements": ["pywizlight==0.5.14"] } diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 71939127356f3..c731f8181af20 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/wled", "integration_type": "device", "iot_class": "local_push", - "quality_scale": "platinum", "requirements": ["wled==0.20.2"], "zeroconf": ["_wled._tcp.local."] } diff --git a/homeassistant/components/ws66i/manifest.json b/homeassistant/components/ws66i/manifest.json index d259823d5af24..c465a9f9f3779 100644 --- a/homeassistant/components/ws66i/manifest.json +++ b/homeassistant/components/ws66i/manifest.json @@ -5,6 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ws66i", "iot_class": "local_polling", - "quality_scale": "silver", "requirements": ["pyws66i==1.1"] } diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 8d0a2e31185b0..4da2e0cfc3e77 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,6 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "quality_scale": "platinum", "requirements": ["yeelight==0.7.14", "async-upnp-client==0.41.0"], "zeroconf": [ { diff --git a/homeassistant/components/zodiac/manifest.json b/homeassistant/components/zodiac/manifest.json index 88f3d7fadef9e..f641826ca7b17 100644 --- a/homeassistant/components/zodiac/manifest.json +++ b/homeassistant/components/zodiac/manifest.json @@ -4,6 +4,5 @@ "codeowners": ["@JulienTant"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zodiac", - "iot_class": "calculated", - "quality_scale": "silver" + "iot_class": "calculated" } diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 3631bf1163b97..ad435b97cbcaf 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "quality_scale": "platinum", "requirements": ["pyserial==3.5", "zwave-js-server-python==0.59.1"], "usb": [ { diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 4013c8a6c195a..f69b22e8a2b62 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -111,19 +111,6 @@ class QualityScale(IntEnum): "websocket_api", "zone", ] -# Grandfather rule for older integrations -# https://github.com/home-assistant/developers.home-assistant/pull/1512 -NO_DIAGNOSTICS = [ - "dlna_dms", - "hyperion", - "nightscout", - "pvpc_hourly_pricing", - "risco", - "smarttub", - "songpal", - "vizio", - "yeelight", -] def documentation_url(value: str) -> str: @@ -367,28 +354,6 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No "manifest", f"{quality_scale} integration does not have a code owner", ) - if ( - domain not in NO_DIAGNOSTICS - and not (integration.path / "diagnostics.py").exists() - ): - integration.add_error( - "manifest", - f"{quality_scale} integration does not implement diagnostics", - ) - - if domain in NO_DIAGNOSTICS: - if quality_scale and QualityScale[quality_scale.upper()] < QualityScale.GOLD: - integration.add_error( - "manifest", - "{quality_scale} integration should be " - "removed from NO_DIAGNOSTICS in script/hassfest/manifest.py", - ) - elif (integration.path / "diagnostics.py").exists(): - integration.add_error( - "manifest", - "Implements diagnostics and can be " - "removed from NO_DIAGNOSTICS in script/hassfest/manifest.py", - ) if not integration.core: validate_version(integration) From ae0cd431a0f48de4bc725323c8a6ae4d32fd6984 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 20 Nov 2024 21:39:24 +0100 Subject: [PATCH 0621/1070] Implement new Integration Quality Scale (#130518) --- script/hassfest/manifest.py | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index f69b22e8a2b62..53e63e0f1e65f 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from enum import IntEnum +from enum import IntEnum, StrEnum, auto import json from pathlib import Path import subprocess @@ -28,16 +28,29 @@ DOCUMENTATION_URL_EXCEPTIONS = {"https://www.home-assistant.io/hassio"} -class QualityScale(IntEnum): +class ScaledQualityScaleTiers(IntEnum): """Supported manifest quality scales.""" - INTERNAL = -1 - SILVER = 1 - GOLD = 2 - PLATINUM = 3 + BRONZE = 1 + SILVER = 2 + GOLD = 3 + PLATINUM = 4 -SUPPORTED_QUALITY_SCALES = [enum.name.lower() for enum in QualityScale] +class NonScaledQualityScaleTiers(StrEnum): + """Supported manifest quality scales.""" + + CUSTOM = auto() + NO_SCORE = auto() + INTERNAL = auto() + LEGACY = auto() + + +SUPPORTED_QUALITY_SCALES = [ + value.name.lower() + for enum in [ScaledQualityScaleTiers, NonScaledQualityScaleTiers] + for value in enum +] SUPPORTED_IOT_CLASSES = [ "assumed_state", "calculated", @@ -346,9 +359,12 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No "Virtual integration points to non-existing supported_by integration", ) - if (quality_scale := integration.manifest.get("quality_scale")) and QualityScale[ - quality_scale.upper() - ] > QualityScale.SILVER: + if ( + (quality_scale := integration.manifest.get("quality_scale")) + and quality_scale.upper() in ScaledQualityScaleTiers + and ScaledQualityScaleTiers[quality_scale.upper()] + >= ScaledQualityScaleTiers.SILVER + ): if not integration.manifest.get("codeowners"): integration.add_error( "manifest", From 5539228ba2a42da578091e8bd614e322b570da9a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 20 Nov 2024 21:41:38 +0100 Subject: [PATCH 0622/1070] Split async_get_issue_tracker loader function (#130856) --- homeassistant/loader.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index d2e04df04c4b0..e63de0c80262a 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1685,6 +1685,29 @@ def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool: return module in hass.data[DATA_COMPONENTS] +@callback +def async_get_issue_integration( + hass: HomeAssistant | None, + integration_domain: str | None, +) -> Integration | None: + """Return details of an integration for issue reporting.""" + integration: Integration | None = None + if not hass or not integration_domain: + # We are unable to get the integration + return None + + if (comps_or_future := hass.data.get(DATA_CUSTOM_COMPONENTS)) and not isinstance( + comps_or_future, asyncio.Future + ): + integration = comps_or_future.get(integration_domain) + + if not integration: + with suppress(IntegrationNotLoaded): + integration = async_get_loaded_integration(hass, integration_domain) + + return integration + + @callback def async_get_issue_tracker( hass: HomeAssistant | None, @@ -1698,20 +1721,11 @@ def async_get_issue_tracker( "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" ) if not integration and not integration_domain and not module: - # If we know nothing about the entity, suggest opening an issue on HA core + # If we know nothing about the integration, suggest opening an issue on HA core return issue_tracker - if ( - not integration - and (hass and integration_domain) - and (comps_or_future := hass.data.get(DATA_CUSTOM_COMPONENTS)) - and not isinstance(comps_or_future, asyncio.Future) - ): - integration = comps_or_future.get(integration_domain) - - if not integration and (hass and integration_domain): - with suppress(IntegrationNotLoaded): - integration = async_get_loaded_integration(hass, integration_domain) + if not integration: + integration = async_get_issue_integration(hass, integration_domain) if integration and not integration.is_built_in: return integration.issue_tracker From deeb55ac5089dd7fb92ff1515d29bb6a4b15032b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 20 Nov 2024 21:41:57 +0100 Subject: [PATCH 0623/1070] Add ability to set HA breaking version in report_usage (#130858) * Add ability to set breaking version in report_usage * Adjust tests * Adjust test * Adjust tests * Rename breaks_in_version => breaks_in_ha_version --- homeassistant/config_entries.py | 13 ++++++------ homeassistant/helpers/frame.py | 27 +++++++++++++++++++----- tests/helpers/test_aiohttp_client.py | 8 +++---- tests/helpers/test_event.py | 6 +++--- tests/helpers/test_frame.py | 26 +++++++++++++++++++++-- tests/helpers/test_httpx_client.py | 8 +++---- tests/helpers/test_update_coordinator.py | 4 ++-- tests/test_config_entries.py | 16 ++++++++------ tests/test_core.py | 2 +- tests/test_core_config.py | 2 +- tests/util/test_async.py | 2 +- 11 files changed, 78 insertions(+), 36 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index dd298ae378681..56931fe289dac 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1195,9 +1195,9 @@ def _report_non_awaited_platform_forwards(entry: ConfigEntry, what: str) -> None f"calls {what} for integration {entry.domain} with " f"title: {entry.title} and entry_id: {entry.entry_id}, " f"during setup without awaiting {what}, which can cause " - "the setup lock to be released before the setup is done. " - "This will stop working in Home Assistant 2025.1", + "the setup lock to be released before the setup is done", core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2025.1", ) @@ -1267,6 +1267,7 @@ async def async_init( # Deprecated in 2024.12, should fail in 2025.12 report_usage( f"initialises a {source} flow without a link to the config entry", + breaks_in_ha_version="2025.12", ) flow_id = ulid_util.ulid_now() @@ -2321,10 +2322,10 @@ async def async_forward_entry_setup( report_usage( "calls async_forward_entry_setup for " f"integration, {entry.domain} with title: {entry.title} " - f"and entry_id: {entry.entry_id}, which is deprecated and " - "will stop working in Home Assistant 2025.6, " + f"and entry_id: {entry.entry_id}, which is deprecated, " "await async_forward_entry_setups instead", core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2025.6", ) if not entry.setup_lock.locked(): async with entry.setup_lock: @@ -3155,11 +3156,11 @@ def config_entry(self) -> ConfigEntry: def config_entry(self, value: ConfigEntry) -> None: """Set the config entry value.""" report_usage( - "sets option flow config_entry explicitly, which is deprecated " - "and will stop working in 2025.12", + "sets option flow config_entry explicitly, which is deprecated", core_behavior=ReportBehavior.ERROR, core_integration_behavior=ReportBehavior.ERROR, custom_integration_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2025.12", ) self._config_entry = value diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index eda980997139d..3ebe6fdba295c 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -181,6 +181,7 @@ class ReportBehavior(enum.Enum): def report_usage( what: str, *, + breaks_in_ha_version: str | None = None, core_behavior: ReportBehavior = ReportBehavior.ERROR, core_integration_behavior: ReportBehavior = ReportBehavior.LOG, custom_integration_behavior: ReportBehavior = ReportBehavior.LOG, @@ -189,17 +190,25 @@ def report_usage( ) -> None: """Report incorrect code usage. - Similar to `report` but allows more fine-grained reporting. + :param what: will be wrapped with "Detected that integration 'integration' {what}. + Please create a bug report at https://..." + :param breaks_in_ha_version: if set, the report will be adjusted to specify the + breaking version """ try: integration_frame = get_integration_frame( exclude_integrations=exclude_integrations ) except MissingIntegrationFrame as err: - msg = f"Detected code that {what}. Please report this issue." + msg = f"Detected code that {what}. Please report this issue" if core_behavior is ReportBehavior.ERROR: raise RuntimeError(msg) from err if core_behavior is ReportBehavior.LOG: + if breaks_in_ha_version: + msg = ( + f"Detected code that {what}. This will stop working in Home " + f"Assistant {breaks_in_ha_version}, please report this issue" + ) _LOGGER.warning(msg, stack_info=True) return @@ -209,12 +218,17 @@ def report_usage( if integration_behavior is not ReportBehavior.IGNORE: _report_integration( - what, integration_frame, level, integration_behavior is ReportBehavior.ERROR + what, + breaks_in_ha_version, + integration_frame, + level, + integration_behavior is ReportBehavior.ERROR, ) def _report_integration( what: str, + breaks_in_ha_version: str | None, integration_frame: IntegrationFrame, level: int = logging.WARNING, error: bool = False, @@ -237,13 +251,16 @@ def _report_integration( integration_type = "custom " if integration_frame.custom_integration else "" _LOGGER.log( level, - "Detected that %sintegration '%s' %s at %s, line %s: %s, please %s", + "Detected that %sintegration '%s' %s at %s, line %s: %s. %s %s", integration_type, integration_frame.integration, what, integration_frame.relative_filename, integration_frame.line_number, integration_frame.line, + f"This will stop working in Home Assistant {breaks_in_ha_version}, please" + if breaks_in_ha_version + else "Please", report_issue, ) if not error: @@ -253,7 +270,7 @@ def _report_integration( f"'{integration_frame.integration}' {what} at " f"{integration_frame.relative_filename}, line " f"{integration_frame.line_number}: {integration_frame.line}. " - f"Please {report_issue}." + f"Please {report_issue}" ) diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 126ed3f9287d4..1788da74c3b84 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -286,8 +286,8 @@ async def test_warning_close_session_integration( await session.close() assert ( "Detected that integration 'hue' closes the Home Assistant aiohttp session at " - "homeassistant/components/hue/light.py, line 23: await session.close(), " - "please create a bug report at https://github.com/home-assistant/core/issues?" + "homeassistant/components/hue/light.py, line 23: await session.close(). " + "Please create a bug report at https://github.com/home-assistant/core/issues?" "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" ) in caplog.text @@ -330,8 +330,8 @@ async def test_warning_close_session_custom( await session.close() assert ( "Detected that custom integration 'hue' closes the Home Assistant aiohttp " - "session at custom_components/hue/light.py, line 23: await session.close(), " - "please report it to the author of the 'hue' custom integration" + "session at custom_components/hue/light.py, line 23: await session.close(). " + "Please report it to the author of the 'hue' custom integration" ) in caplog.text diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index a45b418c52693..69e3a10d4d456 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4895,7 +4895,7 @@ async def test_track_state_change_deprecated( assert ( "Detected code that calls `async_track_state_change` instead " "of `async_track_state_change_event` which is deprecated and " - "will be removed in Home Assistant 2025.5. Please report this issue." + "will be removed in Home Assistant 2025.5. Please report this issue" ) in caplog.text @@ -4946,7 +4946,7 @@ async def test_async_track_template_no_hass_deprecated( """Test async_track_template with a template without hass is deprecated.""" message = ( "Detected code that calls async_track_template_result with template without " - "hass, which will stop working in HA Core 2025.10. Please report this issue." + "hass, which will stop working in HA Core 2025.10. Please report this issue" ) async_track_template(hass, Template("blah"), lambda x, y, z: None) @@ -4964,7 +4964,7 @@ async def test_async_track_template_result_no_hass_deprecated( """Test async_track_template_result with a template without hass is deprecated.""" message = ( "Detected code that calls async_track_template_result with template without " - "hass, which will stop working in HA Core 2025.10. Please report this issue." + "hass, which will stop working in HA Core 2025.10. Please report this issue" ) async_track_template_result( diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index a2a4890810b83..1c1dc8eb71eec 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -261,8 +261,8 @@ async def test_prevent_flooding( expected_message = ( f"Detected that integration '{integration}' {what} at {filename}, line " - f"{mock_integration_frame.lineno}: {mock_integration_frame.line}, " - f"please create a bug report at https://github.com/home-assistant/core/issues?" + f"{mock_integration_frame.lineno}: {mock_integration_frame.line}. " + f"Please create a bug report at https://github.com/home-assistant/core/issues?" f"q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+{integration}%22" ) @@ -279,6 +279,28 @@ async def test_prevent_flooding( assert len(frame._REPORTED_INTEGRATIONS) == 1 +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_breaks_in_ha_version( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock +) -> None: + """Test to ensure a report is only written once to the log.""" + + what = "accessed hi instead of hello" + integration = "hue" + filename = "homeassistant/components/hue/light.py" + + expected_message = ( + f"Detected that integration '{integration}' {what} at {filename}, line " + f"{mock_integration_frame.lineno}: {mock_integration_frame.line}. " + f"This will stop working in Home Assistant 2024.11, please create a bug " + "report at https://github.com/home-assistant/core/issues?" + f"q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+{integration}%22" + ) + + frame.report_usage(what, breaks_in_ha_version="2024.11") + assert expected_message in caplog.text + + async def test_report_missing_integration_frame( caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/helpers/test_httpx_client.py b/tests/helpers/test_httpx_client.py index ccfccb3d69820..684778fe1b130 100644 --- a/tests/helpers/test_httpx_client.py +++ b/tests/helpers/test_httpx_client.py @@ -138,8 +138,8 @@ async def test_warning_close_session_integration( assert ( "Detected that integration 'hue' closes the Home Assistant httpx client at " - "homeassistant/components/hue/light.py, line 23: await session.aclose(), " - "please create a bug report at https://github.com/home-assistant/core/issues?" + "homeassistant/components/hue/light.py, line 23: await session.aclose(). " + "Please create a bug report at https://github.com/home-assistant/core/issues?" "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" ) in caplog.text @@ -182,6 +182,6 @@ async def test_warning_close_session_custom( await httpx_session.aclose() assert ( "Detected that custom integration 'hue' closes the Home Assistant httpx client " - "at custom_components/hue/light.py, line 23: await session.aclose(), " - "please report it to the author of the 'hue' custom integration" + "at custom_components/hue/light.py, line 23: await session.aclose(). " + "Please report it to the author of the 'hue' custom integration" ) in caplog.text diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 50da0ab6332e7..70a95c6bec848 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -629,7 +629,7 @@ async def test_async_config_entry_first_refresh_invalid_state( match="Detected code that uses `async_config_entry_first_refresh`, which " "is only supported when entry state is ConfigEntryState.SETUP_IN_PROGRESS, " "but it is in state ConfigEntryState.NOT_LOADED. This will stop working " - "in Home Assistant 2025.11. Please report this issue.", + "in Home Assistant 2025.11. Please report this issue", ): await crd.async_config_entry_first_refresh() @@ -666,7 +666,7 @@ async def test_async_config_entry_first_refresh_no_entry(hass: HomeAssistant) -> RuntimeError, match="Detected code that uses `async_config_entry_first_refresh`, " "which is only supported for coordinators with a config entry and will " - "stop working in Home Assistant 2025.11. Please report this issue.", + "stop working in Home Assistant 2025.11. Please report this issue", ): await crd.async_config_entry_first_refresh() diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 41af8af3f21d2..44f741242aa83 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1115,8 +1115,8 @@ async def test_async_forward_entry_setup_deprecated( assert ( "Detected code that calls async_forward_entry_setup for integration, " f"original with title: Mock Title and entry_id: {entry_id}, " - "which is deprecated and will stop working in Home Assistant 2025.6, " - "await async_forward_entry_setups instead. Please report this issue." + "which is deprecated, await async_forward_entry_setups instead. " + "This will stop working in Home Assistant 2025.6, please report this issue" ) in caplog.text @@ -4802,7 +4802,7 @@ async def test_reauth_reconfigure_missing_entry( with pytest.raises( RuntimeError, match=f"Detected code that initialises a {source} flow without a link " - "to the config entry. Please report this issue.", + "to the config entry. Please report this issue", ): await manager.flow.async_init("test", context={"source": source}) await hass.async_block_till_done() @@ -6244,7 +6244,7 @@ async def mock_setup_entry_platform( "test with title: Mock Title and entry_id: test2, during setup without " "awaiting async_forward_entry_setups, which can cause the setup lock " "to be released before the setup is done. This will stop working in " - "Home Assistant 2025.1. Please report this issue." + "Home Assistant 2025.1, please report this issue" ) in caplog.text @@ -6316,7 +6316,7 @@ async def mock_setup_entry_platform( "test with title: Mock Title and entry_id: test2, during setup without " "awaiting async_forward_entry_setup, which can cause the setup lock " "to be released before the setup is done. This will stop working in " - "Home Assistant 2025.1. Please report this issue." + "Home Assistant 2025.1, please report this issue" ) in caplog.text @@ -7560,8 +7560,10 @@ async def async_step_init(self, user_input=None): assert ( "Detected that custom integration 'my_integration' sets option flow " - "config_entry explicitly, which is deprecated and will stop working " - "in 2025.12" in caplog.text + "config_entry explicitly, which is deprecated at " + "custom_components/my_integration/light.py, line 23: " + "self.light.is_on. This will stop working in Home Assistant 2025.12, please " + "create a bug report at " in caplog.text ) diff --git a/tests/test_core.py b/tests/test_core.py index 67ed99daa09ac..ed1aad15a2dc2 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3310,7 +3310,7 @@ async def test_thread_safety_message(hass: HomeAssistant) -> None: "which may cause Home Assistant to crash or data to corrupt. For more " "information, see " "https://developers.home-assistant.io/docs/asyncio_thread_safety/#test" - ". Please report this issue.", + ". Please report this issue", ), ): await hass.async_add_executor_job(hass.verify_event_loop_thread, "test") diff --git a/tests/test_core_config.py b/tests/test_core_config.py index 3e0c0999ad32f..4de7ab1e07849 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -1077,7 +1077,7 @@ async def test_set_time_zone_deprecated(hass: HomeAssistant) -> None: match=re.escape( "Detected code that set the time zone using set_time_zone instead of " "async_set_time_zone which will stop working in Home Assistant 2025.6. " - "Please report this issue.", + "Please report this issue", ), ): await hass.config.set_time_zone("America/New_York") diff --git a/tests/util/test_async.py b/tests/util/test_async.py index cda10b69c3f47..cfa78228f0c9e 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -140,7 +140,7 @@ def create_task(): with pytest.raises( RuntimeError, match=( - "Detected code that attempted to create an asyncio task from a thread. Please report this issue." + "Detected code that attempted to create an asyncio task from a thread. Please report this issue" ), ): await hass.async_add_executor_job(create_task) From 80e8b8d61b2ed8377ed93ca6072960b4331d3017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 20 Nov 2024 22:01:57 +0100 Subject: [PATCH 0624/1070] Add diagnostics per device to Home Connect (#131010) * Add diagnostics per device to Home Connect * Include programs at device diagnostics * Applied suggestions from epenet Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Applied more suggestions from epenet Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Test naming consistency Co-authored-by: abmantis * Add return type to `_generate_entry_diagnostics` --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: abmantis --- .../components/home_connect/__init__.py | 3 +- .../components/home_connect/diagnostics.py | 41 ++++++++++---- .../snapshots/test_diagnostics.ambr | 53 +++++++++++++++++++ .../home_connect/test_diagnostics.py | 28 +++++++++- 4 files changed, 114 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index c60515eb57f39..175545c9665c1 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -6,6 +6,7 @@ import logging from typing import Any +from homeconnect.api import HomeConnectAppliance from requests import HTTPError import voluptuous as vol @@ -91,7 +92,7 @@ def _get_appliance_by_device_id( hass: HomeAssistant, device_id: str -) -> api.HomeConnectDevice: +) -> HomeConnectAppliance: """Return a Home Connect appliance instance given an device_id.""" for hc_api in hass.data[DOMAIN].values(): for device in hc_api.devices: diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index 2018a8e390680..beedafe671598 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -4,22 +4,45 @@ from typing import Any +from homeconnect.api import HomeConnectAppliance + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry +from . import _get_appliance_by_device_id +from .api import HomeConnectDevice from .const import DOMAIN +def _generate_appliance_diagnostics(appliance: HomeConnectAppliance) -> dict[str, Any]: + return { + "status": appliance.status, + "programs": appliance.get_programs_available(), + } + + +def _generate_entry_diagnostics( + devices: list[HomeConnectDevice], +) -> dict[str, dict[str, Any]]: + return { + device.appliance.haId: _generate_appliance_diagnostics(device.appliance) + for device in devices + } + + async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return { - device.appliance.haId: { - "status": device.appliance.status, - "programs": await hass.async_add_executor_job( - device.appliance.get_programs_available - ), - } - for device in hass.data[DOMAIN][config_entry.entry_id].devices - } + return await hass.async_add_executor_job( + _generate_entry_diagnostics, hass.data[DOMAIN][config_entry.entry_id].devices + ) + + +async def async_get_device_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device.""" + appliance = _get_appliance_by_device_id(hass, device.id) + return await hass.async_add_executor_job(_generate_appliance_diagnostics, appliance) diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index b2d29380fae62..99f10fe284757 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -413,3 +413,56 @@ }), }) # --- +# name: test_async_get_device_diagnostics + dict({ + 'programs': list([ + 'Dishcare.Dishwasher.Program.Auto1', + 'Dishcare.Dishwasher.Program.Auto2', + 'Dishcare.Dishwasher.Program.Auto3', + 'Dishcare.Dishwasher.Program.Eco50', + 'Dishcare.Dishwasher.Program.Quick45', + ]), + 'status': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': dict({ + 'type': 'Double', + 'unit': '%', + 'value': 70, + }), + 'BSH.Common.Setting.AmbientLightColor': dict({ + 'type': 'BSH.Common.EnumType.AmbientLightColor', + 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', + }), + 'BSH.Common.Setting.AmbientLightCustomColor': dict({ + 'type': 'String', + 'value': '#4a88f8', + }), + 'BSH.Common.Setting.AmbientLightEnabled': dict({ + 'type': 'Boolean', + 'value': True, + }), + 'BSH.Common.Setting.ChildLock': dict({ + 'type': 'Boolean', + 'value': False, + }), + 'BSH.Common.Setting.PowerState': dict({ + 'type': 'BSH.Common.EnumType.PowerState', + 'value': 'BSH.Common.EnumType.PowerState.On', + }), + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), + }), + }) +# --- diff --git a/tests/components/home_connect/test_diagnostics.py b/tests/components/home_connect/test_diagnostics.py index a8c8223ae506d..a56ccef360fd0 100644 --- a/tests/components/home_connect/test_diagnostics.py +++ b/tests/components/home_connect/test_diagnostics.py @@ -6,11 +6,14 @@ import pytest from syrupy import SnapshotAssertion +from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.home_connect.diagnostics import ( async_get_config_entry_diagnostics, + async_get_device_diagnostics, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .conftest import get_all_appliances @@ -26,10 +29,33 @@ async def test_async_get_config_entry_diagnostics( get_appliances: MagicMock, snapshot: SnapshotAssertion, ) -> None: - """Test setup and unload.""" + """Test config entry diagnostics.""" get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup() assert config_entry.state == ConfigEntryState.LOADED assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot + + +@pytest.mark.usefixtures("bypass_throttle") +async def test_async_get_device_diagnostics( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device config entry diagnostics.""" + get_appliances.side_effect = get_all_appliances + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "SIEMENS-HCS02DWH1-6BE58C26DCC1")} + ) + + assert await async_get_device_diagnostics(hass, config_entry, device) == snapshot From 926689ee4fe52279d5f15cad4b2241ed85577efa Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 20 Nov 2024 22:54:51 +0100 Subject: [PATCH 0625/1070] Add startup exception handling to nordpool (#131104) --- homeassistant/components/nordpool/__init__.py | 9 +++- .../components/nordpool/strings.json | 5 +++ tests/components/nordpool/test_init.py | 41 ++++++++++++++++++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py index b688bf74a3705..82db98e2148d2 100644 --- a/homeassistant/components/nordpool/__init__.py +++ b/homeassistant/components/nordpool/__init__.py @@ -4,9 +4,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.util import dt as dt_util -from .const import PLATFORMS +from .const import DOMAIN, PLATFORMS from .coordinator import NordPoolDataUpdateCoordinator type NordPoolConfigEntry = ConfigEntry[NordPoolDataUpdateCoordinator] @@ -17,6 +18,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) -> coordinator = NordPoolDataUpdateCoordinator(hass, entry) await coordinator.fetch_data(dt_util.utcnow()) + if not coordinator.last_update_success: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="initial_update_failed", + translation_placeholders={"error": str(coordinator.last_exception)}, + ) entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index 59ba009eb900f..1a4551fe61afd 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -61,5 +61,10 @@ "name": "Daily average" } } + }, + "exceptions": { + "initial_update_failed": { + "message": "Initial update failed on startup with error {error}" + } } } diff --git a/tests/components/nordpool/test_init.py b/tests/components/nordpool/test_init.py index 5ec1c4b3a0bf9..ebebb8b60c1da 100644 --- a/tests/components/nordpool/test_init.py +++ b/tests/components/nordpool/test_init.py @@ -4,7 +4,14 @@ from unittest.mock import patch -from pynordpool import DeliveryPeriodData +from pynordpool import ( + DeliveryPeriodData, + NordPoolConnectionError, + NordPoolEmptyResponseError, + NordPoolError, + NordPoolResponseError, +) +import pytest from homeassistant.components.nordpool.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState @@ -37,3 +44,35 @@ async def test_unload_entry(hass: HomeAssistant, get_data: DeliveryPeriodData) - assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("error"), + [ + (NordPoolConnectionError), + (NordPoolEmptyResponseError), + (NordPoolError), + (NordPoolResponseError), + ], +) +async def test_initial_startup_fails( + hass: HomeAssistant, get_data: DeliveryPeriodData, error: Exception +) -> None: + """Test load and unload an entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=error, + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert entry.state is ConfigEntryState.SETUP_RETRY From d03fc71bfb75bd05dc2db7797baa288789aae47c Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 21 Nov 2024 00:19:17 +0100 Subject: [PATCH 0626/1070] Bump uiprotect to 6.6.1 (#131107) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 8ba35aad93b20..4b5714d0aeeab 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==6.6.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==6.6.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 9bcc9f0515b65..6868e58e2e5c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2897,7 +2897,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.0 +uiprotect==6.6.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a0841903f34a..4099ae34896b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2310,7 +2310,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.0 +uiprotect==6.6.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From bab9ef7ada4017f39880e56072e99f83e441150e Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 21 Nov 2024 01:11:41 +0100 Subject: [PATCH 0627/1070] Set UniFi Protect `icr_lux` min to 0 allowing "below 1 lux" (#131115) unifiprotect set icr_lux min 0 to allow setting "below 1 lux" Co-authored-by: TheJulianJES --- homeassistant/components/unifiprotect/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index f6aacf8116110..767128337badd 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -124,7 +124,7 @@ def _get_chime_duration(obj: Camera) -> int: name="Infrared custom lux trigger", icon="mdi:white-balance-sunny", entity_category=EntityCategory.CONFIG, - ufp_min=1, + ufp_min=0, ufp_max=30, ufp_step=1, ufp_required_field="feature_flags.has_led_ir", From 782cad97afb1bd087bc9c0075bdf3b61d99e5ff4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 01:47:26 +0100 Subject: [PATCH 0628/1070] Replace "service" with "action" in zha:reconfigure_device (#131111) Replace "service" with "action" in one description As services are now actions in HA this needs to be fixed. --- homeassistant/components/zha/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index b77eaa21faba3..40b6e1c947467 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -297,7 +297,7 @@ }, "reconfigure_device": { "name": "Reconfigure device", - "description": "Reconfigures a ZHA device (heal device). Use this if you are having issues with the device. If the device in question is a battery-powered device, ensure it is awake and accepting commands when you use this service.", + "description": "Reconfigures a ZHA device (heal device). Use this if you are having issues with the device. If the device in question is a battery-powered device, ensure it is awake and accepting commands when you use this action.", "fields": { "ieee": { "name": "[%key:component::zha::services::permit::fields::ieee::name%]", From b8e0d928485fd6784d1fd7579a1df0a74ec5fd78 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 21 Nov 2024 01:48:14 +0100 Subject: [PATCH 0629/1070] Add Reolink push for battery info and sleep status (#131103) Add push for battery info and sleep status --- homeassistant/components/reolink/binary_sensor.py | 1 + homeassistant/components/reolink/sensor.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index f6c64d0b0606f..38132d299417e 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -103,6 +103,7 @@ class ReolinkBinarySensorEntityDescription( BINARY_SENSORS = ( ReolinkBinarySensorEntityDescription( key="sleep", + cmd_id=145, cmd_key="GetChannelstatus", translation_key="sleep", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 80e58c3d5c28b..337bf9cc29cea 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -71,6 +71,7 @@ class ReolinkHostSensorEntityDescription( ), ReolinkSensorEntityDescription( key="battery_percent", + cmd_id=252, cmd_key="GetBatteryInfo", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -81,6 +82,7 @@ class ReolinkHostSensorEntityDescription( ), ReolinkSensorEntityDescription( key="battery_temperature", + cmd_id=252, cmd_key="GetBatteryInfo", translation_key="battery_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -93,6 +95,7 @@ class ReolinkHostSensorEntityDescription( ), ReolinkSensorEntityDescription( key="battery_state", + cmd_id=252, cmd_key="GetBatteryInfo", translation_key="battery_state", device_class=SensorDeviceClass.ENUM, From 98ec0390bc2ee408b5d02d6d0f68db009b498b1f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 21 Nov 2024 04:33:38 +0100 Subject: [PATCH 0630/1070] Add current quality scale status to AVM FRITZ!Box Tools (#131102) * add current quality_scale .yaml * Update homeassistant/components/fritz/quality_scale.yaml * Update homeassistant/components/fritz/quality_scale.yaml * Update homeassistant/components/fritz/quality_scale.yaml --- .../components/fritz/quality_scale.yaml | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 homeassistant/components/fritz/quality_scale.yaml diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml new file mode 100644 index 0000000000000..7f1b49fe5d41f --- /dev/null +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: todo + comment: still in async_setup_entry, needs to be moved to async_setup + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: + status: todo + comment: one coverage miss in line 110 + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: + status: todo + comment: include the proper docs snippet + entity-event-setup: done + entity-unique-id: done + has-entity-name: + status: todo + comment: partially done + runtime-data: + status: todo + comment: still uses hass.data + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: + status: todo + comment: add the proper configuration_basic block + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: todo + comment: not set at the moment, we use a coordinator + reauthentication-flow: done + test-coverage: + status: todo + comment: we are close to the goal of 95% + + # Gold + devices: done + diagnostics: done + discovery-update-info: todo + discovery: done + docs-data-update: todo + docs-examples: done + docs-known-limitations: + status: exempt + comment: no known limitations, yet + docs-supported-devices: + status: todo + comment: add the known supported devices + docs-supported-functions: + status: todo + comment: need to be overhauled + docs-troubleshooting: done + docs-use-cases: + status: todo + comment: need to be overhauled + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: + status: todo + comment: automate the current cleanup process and deprecate the corresponding button + + # Platinum + async-dependency: + status: todo + comment: | + the fritzconnection lib is not async + changing this might need a bit more efforts to be spent + inject-websession: + status: todo + comment: | + the fritzconnection lib is not async and relies on requests + changing this might need a bit more efforts to be spent + strict-typing: done From 1aa95c746e104752bae35bb883483f1b416f1bec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 Nov 2024 08:07:04 +0100 Subject: [PATCH 0631/1070] Bump codecov/codecov-action from 5.0.5 to 5.0.7 (#131135) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4c0ebe2fda37c..b9e5b91aff268 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1248,7 +1248,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.0.5 + uses: codecov/codecov-action@v5.0.7 with: fail_ci_if_error: true flags: full-suite @@ -1386,7 +1386,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.0.5 + uses: codecov/codecov-action@v5.0.7 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From ac56a70948c8515eedb657fcaab5319c3c0f8e4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 Nov 2024 08:07:23 +0100 Subject: [PATCH 0632/1070] Bump github/codeql-action from 3.27.4 to 3.27.5 (#131134) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b9ccece34b9b3..4977139f5dccb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.27.4 + uses: github/codeql-action/init@v3.27.5 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.27.4 + uses: github/codeql-action/analyze@v3.27.5 with: category: "/language:python" From 5529cfda09a4c75ec74c3f56823af4a6e3cf7123 Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Thu, 21 Nov 2024 08:15:27 +0100 Subject: [PATCH 0633/1070] Nina: Add URL for more information to warning (#131070) --- homeassistant/components/nina/binary_sensor.py | 2 ++ homeassistant/components/nina/const.py | 1 + homeassistant/components/nina/coordinator.py | 2 ++ tests/components/nina/test_binary_sensor.py | 11 +++++++++++ 4 files changed, 16 insertions(+) diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 397ced0f5d34c..10d3008fd8236 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -25,6 +25,7 @@ ATTR_SENT, ATTR_SEVERITY, ATTR_START, + ATTR_WEB, CONF_MESSAGE_SLOTS, CONF_REGIONS, DOMAIN, @@ -103,6 +104,7 @@ def extra_state_attributes(self) -> dict[str, Any]: ATTR_SEVERITY: data.severity, ATTR_RECOMMENDED_ACTIONS: data.recommended_actions, ATTR_AFFECTED_AREAS: data.affected_areas, + ATTR_WEB: data.web, ATTR_ID: data.id, ATTR_SENT: data.sent, ATTR_START: data.start, diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py index 1e7550560796f..47194c4c2dedf 100644 --- a/homeassistant/components/nina/const.py +++ b/homeassistant/components/nina/const.py @@ -27,6 +27,7 @@ ATTR_SEVERITY: str = "severity" ATTR_RECOMMENDED_ACTIONS: str = "recommended_actions" ATTR_AFFECTED_AREAS: str = "affected_areas" +ATTR_WEB: str = "web" ATTR_ID: str = "id" ATTR_SENT: str = "sent" ATTR_START: str = "start" diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py index c731c7a62d7fd..2d9548f3d121c 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -27,6 +27,7 @@ class NinaWarningData: severity: str recommended_actions: str affected_areas: str + web: str sent: str start: str expires: str @@ -127,6 +128,7 @@ def _parse_data(self) -> dict[str, list[NinaWarningData]]: raw_warn.severity, " ".join([str(action) for action in raw_warn.recommended_actions]), affected_areas_string, + raw_warn.web or "", raw_warn.sent or "", raw_warn.start or "", raw_warn.expires or "", diff --git a/tests/components/nina/test_binary_sensor.py b/tests/components/nina/test_binary_sensor.py index a7f9a980960ce..6ed1aee7e9ddb 100644 --- a/tests/components/nina/test_binary_sensor.py +++ b/tests/components/nina/test_binary_sensor.py @@ -17,6 +17,7 @@ ATTR_SENT, ATTR_SEVERITY, ATTR_START, + ATTR_WEB, DOMAIN, ) from homeassistant.config_entries import ConfigEntryState @@ -77,6 +78,7 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) assert state_w1.attributes.get(ATTR_SENDER) == "Deutscher Wetterdienst" assert state_w1.attributes.get(ATTR_SEVERITY) == "Minor" assert state_w1.attributes.get(ATTR_RECOMMENDED_ACTIONS) == "" + assert state_w1.attributes.get(ATTR_WEB) == "https://www.wettergefahren.de" assert ( state_w1.attributes.get(ATTR_AFFECTED_AREAS) == "Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere." @@ -98,6 +100,7 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) assert state_w2.attributes.get(ATTR_SENDER) is None assert state_w2.attributes.get(ATTR_SEVERITY) is None assert state_w2.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w2.attributes.get(ATTR_WEB) is None assert state_w2.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w2.attributes.get(ATTR_ID) is None assert state_w2.attributes.get(ATTR_SENT) is None @@ -116,6 +119,7 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) assert state_w3.attributes.get(ATTR_SENDER) is None assert state_w3.attributes.get(ATTR_SEVERITY) is None assert state_w3.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w3.attributes.get(ATTR_WEB) is None assert state_w3.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w3.attributes.get(ATTR_ID) is None assert state_w3.attributes.get(ATTR_SENT) is None @@ -134,6 +138,7 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) assert state_w4.attributes.get(ATTR_SENDER) is None assert state_w4.attributes.get(ATTR_SEVERITY) is None assert state_w4.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w4.attributes.get(ATTR_WEB) is None assert state_w4.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w4.attributes.get(ATTR_ID) is None assert state_w4.attributes.get(ATTR_SENT) is None @@ -152,6 +157,7 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) assert state_w5.attributes.get(ATTR_SENDER) is None assert state_w5.attributes.get(ATTR_SEVERITY) is None assert state_w5.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w5.attributes.get(ATTR_WEB) is None assert state_w5.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w5.attributes.get(ATTR_ID) is None assert state_w5.attributes.get(ATTR_SENT) is None @@ -199,6 +205,7 @@ async def test_sensors_without_corona_filter( state_w1.attributes.get(ATTR_RECOMMENDED_ACTIONS) == "Waschen sich regelmäßig und gründlich die Hände." ) + assert state_w1.attributes.get(ATTR_WEB) == "" assert ( state_w1.attributes.get(ATTR_AFFECTED_AREAS) == "Bundesland: Freie Hansestadt Bremen, Land Berlin, Land Hessen, Land Nordrhein-Westfalen, Land Brandenburg, Freistaat Bayern, Land Mecklenburg-Vorpommern, Land Rheinland-Pfalz, Freistaat Sachsen, Land Schleswig-Holstein, Freie und Hansestadt Hamburg, Freistaat Thüringen, Land Niedersachsen, Land Saarland, Land Sachsen-Anhalt, Land Baden-Württemberg" @@ -227,6 +234,7 @@ async def test_sensors_without_corona_filter( assert state_w2.attributes.get(ATTR_SENDER) == "Deutscher Wetterdienst" assert state_w2.attributes.get(ATTR_SEVERITY) == "Minor" assert state_w2.attributes.get(ATTR_RECOMMENDED_ACTIONS) == "" + assert state_w2.attributes.get(ATTR_WEB) == "https://www.wettergefahren.de" assert state_w2.attributes.get(ATTR_ID) == "mow.DE-NW-BN-SE030-20201014-30-000" assert state_w2.attributes.get(ATTR_SENT) == "2021-10-11T05:20:00+01:00" assert state_w2.attributes.get(ATTR_START) == "2021-11-01T05:20:00+01:00" @@ -244,6 +252,7 @@ async def test_sensors_without_corona_filter( assert state_w3.attributes.get(ATTR_SENDER) is None assert state_w3.attributes.get(ATTR_SEVERITY) is None assert state_w3.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w3.attributes.get(ATTR_WEB) is None assert state_w3.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w3.attributes.get(ATTR_ID) is None assert state_w3.attributes.get(ATTR_SENT) is None @@ -262,6 +271,7 @@ async def test_sensors_without_corona_filter( assert state_w4.attributes.get(ATTR_SENDER) is None assert state_w4.attributes.get(ATTR_SEVERITY) is None assert state_w4.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w4.attributes.get(ATTR_WEB) is None assert state_w4.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w4.attributes.get(ATTR_ID) is None assert state_w4.attributes.get(ATTR_SENT) is None @@ -280,6 +290,7 @@ async def test_sensors_without_corona_filter( assert state_w5.attributes.get(ATTR_SENDER) is None assert state_w5.attributes.get(ATTR_SEVERITY) is None assert state_w5.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w5.attributes.get(ATTR_WEB) is None assert state_w5.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w5.attributes.get(ATTR_ID) is None assert state_w5.attributes.get(ATTR_SENT) is None From e7fedef651b1235f10e45a4180ffa56f0fd160c9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 21 Nov 2024 08:31:50 +0100 Subject: [PATCH 0634/1070] Add created sensor in filesize (#131108) --- .../components/filesize/coordinator.py | 2 + homeassistant/components/filesize/icons.json | 3 + homeassistant/components/filesize/sensor.py | 7 + .../components/filesize/strings.json | 3 + tests/components/filesize/conftest.py | 15 +- .../filesize/snapshots/test_sensor.ambr | 197 ++++++++++++++++++ tests/components/filesize/test_sensor.py | 45 +++- 7 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 tests/components/filesize/snapshots/test_sensor.ambr diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py index c0dbb14555ebd..8350cee91bfa6 100644 --- a/homeassistant/components/filesize/coordinator.py +++ b/homeassistant/components/filesize/coordinator.py @@ -60,12 +60,14 @@ async def _async_update_data(self) -> dict[str, float | int | datetime]: statinfo = await self.hass.async_add_executor_job(self._update) size = statinfo.st_size last_updated = dt_util.utc_from_timestamp(statinfo.st_mtime) + created = dt_util.utc_from_timestamp(statinfo.st_ctime) _LOGGER.debug("size %s, last updated %s", size, last_updated) data: dict[str, int | float | datetime] = { "file": round(size / 1e6, 2), "bytes": size, "last_updated": last_updated, + "created": created, } return data diff --git a/homeassistant/components/filesize/icons.json b/homeassistant/components/filesize/icons.json index 158295898532d..059a51a9e34f3 100644 --- a/homeassistant/components/filesize/icons.json +++ b/homeassistant/components/filesize/icons.json @@ -9,6 +9,9 @@ }, "last_updated": { "default": "mdi:file" + }, + "created": { + "default": "mdi:file" } } } diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 71a4e50edfed0..dfab815739bb2 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -47,6 +47,13 @@ device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, ), + SensorEntityDescription( + key="created", + translation_key="created", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + ), ) diff --git a/homeassistant/components/filesize/strings.json b/homeassistant/components/filesize/strings.json index 3323c3411b26b..27d83d9fb6262 100644 --- a/homeassistant/components/filesize/strings.json +++ b/homeassistant/components/filesize/strings.json @@ -26,6 +26,9 @@ }, "last_updated": { "name": "Last updated" + }, + "created": { + "name": "Created" } } } diff --git a/tests/components/filesize/conftest.py b/tests/components/filesize/conftest.py index ac66af0d22fa3..09acf7a58cc3a 100644 --- a/tests/components/filesize/conftest.py +++ b/tests/components/filesize/conftest.py @@ -8,21 +8,30 @@ import pytest -from homeassistant.components.filesize.const import DOMAIN -from homeassistant.const import CONF_FILE_PATH +from homeassistant.components.filesize.const import DOMAIN, PLATFORMS +from homeassistant.const import CONF_FILE_PATH, Platform from . import TEST_FILE_NAME from tests.common import MockConfigEntry +@pytest.fixture(name="load_platforms") +async def patch_platform_constant() -> list[Platform]: + """Return list of platforms to load.""" + return PLATFORMS + + @pytest.fixture -def mock_config_entry(tmp_path: Path) -> MockConfigEntry: +def mock_config_entry( + tmp_path: Path, load_platforms: list[Platform] +) -> MockConfigEntry: """Return the default mocked config entry.""" test_file = str(tmp_path.joinpath(TEST_FILE_NAME)) return MockConfigEntry( title=TEST_FILE_NAME, domain=DOMAIN, + entry_id="01JD5CTQMH9FKEFQKZJ8MMBQ3X", data={CONF_FILE_PATH: test_file}, unique_id=test_file, ) diff --git a/tests/components/filesize/snapshots/test_sensor.ambr b/tests/components/filesize/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..fb3702e02554a --- /dev/null +++ b/tests/components/filesize/snapshots/test_sensor.ambr @@ -0,0 +1,197 @@ +# serializer version: 1 +# name: test_sensors[load_platforms0][sensor.file_txt_created-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.file_txt_created', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Created', + 'platform': 'filesize', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'created', + 'unique_id': '01JD5CTQMH9FKEFQKZJ8MMBQ3X-created', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[load_platforms0][sensor.file_txt_created-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'file.txt Created', + }), + 'context': , + 'entity_id': 'sensor.file_txt_created', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-20T18:19:04+00:00', + }) +# --- +# name: test_sensors[load_platforms0][sensor.file_txt_last_updated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.file_txt_last_updated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last updated', + 'platform': 'filesize', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_updated', + 'unique_id': '01JD5CTQMH9FKEFQKZJ8MMBQ3X-last_updated', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[load_platforms0][sensor.file_txt_last_updated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'file.txt Last updated', + }), + 'context': , + 'entity_id': 'sensor.file_txt_last_updated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-20T18:19:24+00:00', + }) +# --- +# name: test_sensors[load_platforms0][sensor.file_txt_size-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.file_txt_size', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Size', + 'platform': 'filesize', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'size', + 'unique_id': '01JD5CTQMH9FKEFQKZJ8MMBQ3X', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[load_platforms0][sensor.file_txt_size-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'file.txt Size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.file_txt_size', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[load_platforms0][sensor.file_txt_size_in_bytes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.file_txt_size_in_bytes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Size in bytes', + 'platform': 'filesize', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'size_bytes', + 'unique_id': '01JD5CTQMH9FKEFQKZJ8MMBQ3X-bytes', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[load_platforms0][sensor.file_txt_size_in_bytes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'file.txt Size in bytes', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.file_txt_size_in_bytes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- diff --git a/tests/components/filesize/test_sensor.py b/tests/components/filesize/test_sensor.py index 880563f0ad86a..62554b15b8ed5 100644 --- a/tests/components/filesize/test_sensor.py +++ b/tests/components/filesize/test_sensor.py @@ -2,14 +2,55 @@ import os from pathlib import Path +from unittest.mock import patch -from homeassistant.const import CONF_FILE_PATH, STATE_UNAVAILABLE +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import CONF_FILE_PATH, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from . import TEST_FILE_NAME, async_create_file -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.SENSOR]], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + tmp_path: Path, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that an invalid path is caught.""" + testfile = str(tmp_path.joinpath("file.txt")) + await async_create_file(hass, testfile) + hass.config.allowlist_external_dirs = {tmp_path} + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, data={CONF_FILE_PATH: testfile} + ) + with ( + patch( + "os.stat_result.st_mtime", + 1732126764.780758, + ), + patch( + "os.stat_result.st_ctime", + 1732126744.780758, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_invalid_path( From 08f9d5bdfd533b02d8932575eb0549e8be8ad143 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 21 Nov 2024 08:53:27 +0100 Subject: [PATCH 0635/1070] Add codeowner for unifiprotect (#131136) --- CODEOWNERS | 2 ++ homeassistant/components/unifiprotect/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 7a098e4696161..ba233c0c1413e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1573,6 +1573,8 @@ build.json @home-assistant/supervisor /tests/components/unifi/ @Kane610 /homeassistant/components/unifi_direct/ @tofuSCHNITZEL /homeassistant/components/unifiled/ @florisvdk +/homeassistant/components/unifiprotect/ @RaHehl +/tests/components/unifiprotect/ @RaHehl /homeassistant/components/upb/ @gwww /tests/components/upb/ @gwww /homeassistant/components/upc_connect/ @pvizeli @fabaff diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 4b5714d0aeeab..96fc30b240421 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -1,7 +1,7 @@ { "domain": "unifiprotect", "name": "UniFi Protect", - "codeowners": [], + "codeowners": ["@RaHehl"], "config_flow": true, "dependencies": ["http", "repairs"], "dhcp": [ From 092d97a0b05904c7cbd415b0d62c6873a84c4606 Mon Sep 17 00:00:00 2001 From: bobpaul <90864+bobpaul@users.noreply.github.com> Date: Thu, 21 Nov 2024 02:53:47 -0500 Subject: [PATCH 0636/1070] Bump pylutron-caseta to 0.22.0 (#131129) --- homeassistant/components/lutron_caseta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index e96778f0a31a7..ec27861574331 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.21.1"], + "requirements": ["pylutron-caseta==0.22.0"], "zeroconf": [ { "type": "_lutron._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 6868e58e2e5c0..42d7dc1a0ca8b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2048,7 +2048,7 @@ pylitejet==0.6.3 pylitterbot==2023.5.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.21.1 +pylutron-caseta==0.22.0 # homeassistant.components.lutron pylutron==0.2.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4099ae34896b5..7917023545e1d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1653,7 +1653,7 @@ pylitejet==0.6.3 pylitterbot==2023.5.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.21.1 +pylutron-caseta==0.22.0 # homeassistant.components.lutron pylutron==0.2.16 From 27695095dd2deda7798251495cbad78d6ec4ca2b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 09:41:52 +0100 Subject: [PATCH 0637/1070] Fix wrong "(s)" that was leftover changing from "service(s)" to "actions" (#131141) * Fix wrong "(s)" that was leftover when changing from "service(s)" to "actions" * Update homeassistant/components/notify/strings.json --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/notify/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json index b7d4ec1ad256d..e832bfc248a23 100644 --- a/homeassistant/components/notify/strings.json +++ b/homeassistant/components/notify/strings.json @@ -67,7 +67,7 @@ "fix_flow": { "step": { "confirm": { - "description": "The {integration_title} `notify` actions(s) are migrated. A new `notify` entity is available now to replace each legacy `notify` action.\n\nUpdate any automations to use the new `notify.send_message` action exposed with this new entity. When this is done, fix this issue and restart Home Assistant.", + "description": "The {integration_title} `notify` action(s) are migrated. A new `notify` entity is available now to replace each legacy `notify` action.\n\nUpdate any automations to use the new `notify.send_message` action exposed with this new entity. When this is done, fix this issue and restart Home Assistant.", "title": "Migrate legacy {integration_title} notify action for domain `{domain}`" } } From 51e592f4507af17123b15d21ccbfcd05f05deece Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 21 Nov 2024 04:17:44 -0500 Subject: [PATCH 0638/1070] Add informative header to ZHA update entity release notes (#130099) --- homeassistant/components/zha/update.py | 28 +++++++- tests/components/zha/test_update.py | 94 +++++++++++++++++--------- 2 files changed, 89 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index 18b8ed1cca5ff..cb5c160e7b35c 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -36,6 +36,18 @@ _LOGGER = logging.getLogger(__name__) +OTA_MESSAGE_BATTERY_POWERED = ( + "Battery powered devices can sometimes take multiple hours to update and you may" + " need to wake the device for the update to begin." +) + +ZHA_DOCS_NETWORK_RELIABILITY = "https://www.home-assistant.io/integrations/zha/#zigbee-interference-avoidance-and-network-rangecoverage-optimization" +OTA_MESSAGE_RELIABILITY = ( + "If you are having issues updating a specific device, make sure that you've" + f" eliminated [common environmental issues]({ZHA_DOCS_NETWORK_RELIABILITY}) that" + " could be affecting network reliability. OTA updates require a reliable network." +) + async def async_setup_entry( hass: HomeAssistant, @@ -149,7 +161,21 @@ async def async_release_notes(self) -> str | None: This is suitable for a long changelog that does not fit in the release_summary property. The returned string can contain markdown. """ - return self.entity_data.entity.release_notes + + if self.entity_data.device_proxy.device.is_mains_powered: + header = ( + "" + f"{OTA_MESSAGE_RELIABILITY}" + "" + ) + else: + header = ( + "" + f"{OTA_MESSAGE_BATTERY_POWERED} {OTA_MESSAGE_RELIABILITY}" + "" + ) + + return f"{header}\n\n{self.entity_data.entity.release_notes or ''}" @property def release_url(self) -> str | None: diff --git a/tests/components/zha/test_update.py b/tests/components/zha/test_update.py index cd48ae62ff3bc..c8cbc40710628 100644 --- a/tests/components/zha/test_update.py +++ b/tests/components/zha/test_update.py @@ -1,6 +1,6 @@ """Test ZHA firmware updates.""" -from unittest.mock import AsyncMock, call, patch +from unittest.mock import AsyncMock, PropertyMock, call, patch import pytest from zha.application.platforms.update import ( @@ -14,6 +14,7 @@ import zigpy.types as t from zigpy.zcl import foundation from zigpy.zcl.clusters import general +import zigpy.zdo.types as zdo_t from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, @@ -33,6 +34,10 @@ get_zha_gateway, get_zha_gateway_proxy, ) +from homeassistant.components.zha.update import ( + OTA_MESSAGE_BATTERY_POWERED, + OTA_MESSAGE_RELIABILITY, +) from homeassistant.const import ( ATTR_ENTITY_ID, STATE_OFF, @@ -84,7 +89,26 @@ async def setup_test_data( SIG_EP_PROFILE: zha.PROFILE_ID, } }, - node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + node_descriptor=zdo_t.NodeDescriptor( + logical_type=zdo_t.LogicalType.Router, + complex_descriptor_available=0, + user_descriptor_available=0, + reserved=0, + aps_flags=0, + frequency_band=zdo_t.NodeDescriptor.FrequencyBand.Freq2400MHz, + mac_capability_flags=( + zdo_t.NodeDescriptor.MACCapabilityFlags.FullFunctionDevice + | zdo_t.NodeDescriptor.MACCapabilityFlags.MainsPowered + | zdo_t.NodeDescriptor.MACCapabilityFlags.RxOnWhenIdle + | zdo_t.NodeDescriptor.MACCapabilityFlags.AllocateAddress + ), + manufacturer_code=4107, + maximum_buffer_size=82, + maximum_incoming_transfer_size=128, + server_mask=11264, + maximum_outgoing_transfer_size=128, + descriptor_capability_field=zdo_t.NodeDescriptor.DescriptorCapability.NONE, + ).serialize(), ) gateway.get_or_create_device(zigpy_device) @@ -568,27 +592,8 @@ async def test_update_release_notes( ) -> None: """Test ZHA update platform release notes.""" await setup_zha() + zha_device, _, _, _ = await setup_test_data(hass, zigpy_device_mock) - gateway = get_zha_gateway(hass) - gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass) - - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [general.Basic.cluster_id, general.OnOff.cluster_id], - SIG_EP_OUTPUT: [general.Ota.cluster_id], - SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, - SIG_EP_PROFILE: zha.PROFILE_ID, - } - }, - node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", - ) - - gateway.get_or_create_device(zigpy_device) - await gateway.async_device_initialized(zigpy_device) - await hass.async_block_till_done(wait_background_tasks=True) - - zha_device: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee) zha_lib_entity = next( e for e in zha_device.device.platform_entities.values() @@ -602,14 +607,39 @@ async def test_update_release_notes( assert entity_id is not None ws_client = await hass_ws_client(hass) - await ws_client.send_json( - { - "id": 1, - "type": "update/release_notes", - "entity_id": entity_id, - } - ) - result = await ws_client.receive_json() - assert result["success"] is True - assert result["result"] == "Some lengthy release notes" + # Mains-powered devices + with patch( + "zha.zigbee.device.Device.is_mains_powered", PropertyMock(return_value=True) + ): + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": entity_id, + } + ) + + result = await ws_client.receive_json() + assert result["success"] is True + assert "Some lengthy release notes" in result["result"] + assert OTA_MESSAGE_RELIABILITY in result["result"] + assert OTA_MESSAGE_BATTERY_POWERED not in result["result"] + + # Battery-powered devices + with patch( + "zha.zigbee.device.Device.is_mains_powered", PropertyMock(return_value=False) + ): + await ws_client.send_json( + { + "id": 2, + "type": "update/release_notes", + "entity_id": entity_id, + } + ) + + result = await ws_client.receive_json() + assert result["success"] is True + assert "Some lengthy release notes" in result["result"] + assert OTA_MESSAGE_RELIABILITY in result["result"] + assert OTA_MESSAGE_BATTERY_POWERED in result["result"] From deb82cf072d324e87a35e6b9672faf1c89a88840 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 10:26:40 +0100 Subject: [PATCH 0639/1070] Fix typo in name of "Alarm arm home instant" action (#131151) --- homeassistant/components/elkm1/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index 6318231c281ad..bf02d7272800b 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -68,7 +68,7 @@ } }, "alarm_arm_home_instant": { - "name": "Alarm are home instant", + "name": "Alarm arm home instant", "description": "Arms the ElkM1 in home instant mode.", "fields": { "code": { From f1a4baa1b58473dde380281e00c468283775a5e2 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 21 Nov 2024 10:55:21 +0100 Subject: [PATCH 0640/1070] Add diagnostics to acaia (#131153) --- homeassistant/components/acaia/diagnostics.py | 31 +++++++++++++++++++ tests/components/acaia/conftest.py | 5 ++- .../acaia/snapshots/test_diagnostics.ambr | 16 ++++++++++ tests/components/acaia/test_diagnostics.py | 22 +++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/acaia/diagnostics.py create mode 100644 tests/components/acaia/snapshots/test_diagnostics.ambr create mode 100644 tests/components/acaia/test_diagnostics.py diff --git a/homeassistant/components/acaia/diagnostics.py b/homeassistant/components/acaia/diagnostics.py new file mode 100644 index 0000000000000..2d9f451180490 --- /dev/null +++ b/homeassistant/components/acaia/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics support for Acaia.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import AcaiaConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: AcaiaConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + scale = coordinator.scale + + # collect all data sources + return { + "model": scale.model, + "device_state": ( + asdict(scale.device_state) if scale.device_state is not None else "" + ), + "mac": scale.mac, + "last_disconnect_time": scale.last_disconnect_time, + "timer": scale.timer, + "weight": scale.weight, + } diff --git a/tests/components/acaia/conftest.py b/tests/components/acaia/conftest.py index 7e3c19c6c5ae4..f1757a7f102d2 100644 --- a/tests/components/acaia/conftest.py +++ b/tests/components/acaia/conftest.py @@ -52,9 +52,10 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_scale: MagicMock -) -> None: +) -> MockConfigEntry: """Set up the acaia integration for testing.""" await setup_integration(hass, mock_config_entry) + return mock_config_entry @pytest.fixture @@ -70,6 +71,7 @@ def mock_scale() -> Generator[MagicMock]: scale.connected = True scale.mac = "aa:bb:cc:dd:ee:ff" scale.model = "Lunar" + scale.last_disconnect_time = "1732181388.1895587" scale.timer_running = True scale.heartbeat_task = None scale.process_queue_task = None @@ -77,4 +79,5 @@ def mock_scale() -> Generator[MagicMock]: battery_level=42, units=AcaiaUnitOfMass.OUNCES ) scale.weight = 123.45 + scale.timer = 23 yield scale diff --git a/tests/components/acaia/snapshots/test_diagnostics.ambr b/tests/components/acaia/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..df5e4d3655572 --- /dev/null +++ b/tests/components/acaia/snapshots/test_diagnostics.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'device_state': dict({ + 'auto_off_time': 0, + 'battery_level': 42, + 'beeps': True, + 'units': 'ounces', + }), + 'last_disconnect_time': '1732181388.1895587', + 'mac': 'aa:bb:cc:dd:ee:ff', + 'model': 'Lunar', + 'timer': 23, + 'weight': 123.45, + }) +# --- diff --git a/tests/components/acaia/test_diagnostics.py b/tests/components/acaia/test_diagnostics.py new file mode 100644 index 0000000000000..77f6306b06882 --- /dev/null +++ b/tests/components/acaia/test_diagnostics.py @@ -0,0 +1,22 @@ +"""Tests for the diagnostics data provided by the Acaia integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From afe986b39c082f37e8a37b3b25f263c36e32fa0e Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Thu, 21 Nov 2024 19:12:36 +0900 Subject: [PATCH 0641/1070] Bump thinqconnect to 1.0.1 (#131132) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index 665a5a9e17953..5ffd9041676b8 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.0"] + "requirements": ["thinqconnect==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 42d7dc1a0ca8b..6db23700d9403 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2837,7 +2837,7 @@ thermopro-ble==0.10.0 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.0 +thinqconnect==1.0.1 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7917023545e1d..a897626d67012 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2259,7 +2259,7 @@ thermobeacon-ble==0.7.0 thermopro-ble==0.10.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.0 +thinqconnect==1.0.1 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From 1e1759c030c74feac9e93090aa3ded9734b00dd0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 13:38:34 +0100 Subject: [PATCH 0642/1070] Fix cast translation string (#131156) --- homeassistant/components/cast/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index 12f2edeee9a98..9c49813bd830d 100644 --- a/homeassistant/components/cast/strings.json +++ b/homeassistant/components/cast/strings.json @@ -53,7 +53,7 @@ }, "view_path": { "name": "View path", - "description": "The path of the dashboard view to show." + "description": "The URL path of the dashboard view to show." } } } From 3ed462f7a7b496c2c82f3aaa57161fa6b17b1ce8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 13:39:06 +0100 Subject: [PATCH 0643/1070] Improve explanation of 'device_tracker.see' action (#131095) --- homeassistant/components/device_tracker/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index d6e36d9230002..294333a5d8068 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -48,7 +48,7 @@ "services": { "see": { "name": "See", - "description": "Records a seen tracked device.", + "description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.", "fields": { "mac": { "name": "MAC address", From fc987ee7948a67df4513b3a681e8d8caac28ced1 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Thu, 21 Nov 2024 09:07:39 -0500 Subject: [PATCH 0644/1070] Use runtime_data for Fully Kiosk Browser integration (#131101) --- homeassistant/components/fully_kiosk/__init__.py | 14 ++++++-------- .../components/fully_kiosk/binary_sensor.py | 9 +++------ homeassistant/components/fully_kiosk/button.py | 9 +++------ homeassistant/components/fully_kiosk/camera.py | 9 +++++---- .../components/fully_kiosk/diagnostics.py | 7 +++---- homeassistant/components/fully_kiosk/image.py | 9 +++++---- .../components/fully_kiosk/media_player.py | 8 ++++---- homeassistant/components/fully_kiosk/notify.py | 9 +++++---- homeassistant/components/fully_kiosk/number.py | 7 +++---- homeassistant/components/fully_kiosk/sensor.py | 9 +++------ homeassistant/components/fully_kiosk/services.py | 2 +- homeassistant/components/fully_kiosk/switch.py | 9 +++------ 12 files changed, 44 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py index 99b477c298923..074ec3feaa02c 100644 --- a/homeassistant/components/fully_kiosk/__init__.py +++ b/homeassistant/components/fully_kiosk/__init__.py @@ -10,6 +10,8 @@ from .coordinator import FullyKioskDataUpdateCoordinator from .services import async_setup_services +type FullyKioskConfigEntry = ConfigEntry[FullyKioskDataUpdateCoordinator] + PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -33,13 +35,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FullyKioskConfigEntry) -> bool: """Set up Fully Kiosk Browser from a config entry.""" coordinator = FullyKioskDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) coordinator.async_update_listeners() @@ -47,10 +49,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FullyKioskConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/fully_kiosk/binary_sensor.py b/homeassistant/components/fully_kiosk/binary_sensor.py index 3cf9adea1d528..c039baa03974d 100644 --- a/homeassistant/components/fully_kiosk/binary_sensor.py +++ b/homeassistant/components/fully_kiosk/binary_sensor.py @@ -7,12 +7,11 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator from .entity import FullyKioskEntity @@ -38,13 +37,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FullyKioskConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser sensor.""" - coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data async_add_entities( FullyBinarySensor(coordinator, description) diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py index 94c34b50de111..4b172d45ae2c0 100644 --- a/homeassistant/components/fully_kiosk/button.py +++ b/homeassistant/components/fully_kiosk/button.py @@ -13,12 +13,11 @@ ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator from .entity import FullyKioskEntity @@ -68,13 +67,11 @@ class FullyButtonEntityDescription(ButtonEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FullyKioskConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser button entities.""" - coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data async_add_entities( FullyButtonEntity(coordinator, description) for description in BUTTONS diff --git a/homeassistant/components/fully_kiosk/camera.py b/homeassistant/components/fully_kiosk/camera.py index d55875e094f4b..7dfbe9e9257ad 100644 --- a/homeassistant/components/fully_kiosk/camera.py +++ b/homeassistant/components/fully_kiosk/camera.py @@ -5,21 +5,22 @@ from fullykiosk import FullyKioskError from homeassistant.components.camera import Camera, CameraEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator from .entity import FullyKioskEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FullyKioskConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the cameras.""" - coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities([FullyCameraEntity(coordinator)]) diff --git a/homeassistant/components/fully_kiosk/diagnostics.py b/homeassistant/components/fully_kiosk/diagnostics.py index 0ff567b0b46f6..c8364c7775350 100644 --- a/homeassistant/components/fully_kiosk/diagnostics.py +++ b/homeassistant/components/fully_kiosk/diagnostics.py @@ -5,11 +5,10 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import DOMAIN +from . import FullyKioskConfigEntry DEVICE_INFO_TO_REDACT = { "serial", @@ -57,10 +56,10 @@ async def async_get_device_diagnostics( - hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, entry: FullyKioskConfigEntry, device: dr.DeviceEntry ) -> dict[str, Any]: """Return device diagnostics.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data data = coordinator.data data["settings"] = async_redact_data(data["settings"], SETTINGS_TO_REDACT) return async_redact_data(data, DEVICE_INFO_TO_REDACT) diff --git a/homeassistant/components/fully_kiosk/image.py b/homeassistant/components/fully_kiosk/image.py index fbf3481e38b6c..00318a77ab5b1 100644 --- a/homeassistant/components/fully_kiosk/image.py +++ b/homeassistant/components/fully_kiosk/image.py @@ -9,13 +9,12 @@ from fullykiosk import FullyKiosk, FullyKioskError from homeassistant.components.image import ImageEntity, ImageEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN +from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator from .entity import FullyKioskEntity @@ -37,10 +36,12 @@ class FullyImageEntityDescription(ImageEntityDescription): async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FullyKioskConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser image entities.""" - coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( FullyImageEntity(coordinator, description) for description in IMAGES ) diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index ae61a39bb81a4..24f002a754468 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -12,23 +12,23 @@ MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import AUDIOMANAGER_STREAM_MUSIC, DOMAIN, MEDIA_SUPPORT_FULLYKIOSK +from . import FullyKioskConfigEntry +from .const import AUDIOMANAGER_STREAM_MUSIC, MEDIA_SUPPORT_FULLYKIOSK from .coordinator import FullyKioskDataUpdateCoordinator from .entity import FullyKioskEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FullyKioskConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser media player entity.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities([FullyMediaPlayer(coordinator)]) diff --git a/homeassistant/components/fully_kiosk/notify.py b/homeassistant/components/fully_kiosk/notify.py index aa47c178f036c..bddc07439b380 100644 --- a/homeassistant/components/fully_kiosk/notify.py +++ b/homeassistant/components/fully_kiosk/notify.py @@ -7,12 +7,11 @@ from fullykiosk import FullyKioskError from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator from .entity import FullyKioskEntity @@ -39,10 +38,12 @@ class FullyNotifyEntityDescription(NotifyEntityDescription): async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FullyKioskConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser notify entities.""" - coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( FullyNotifyEntity(coordinator, description) for description in NOTIFIERS ) diff --git a/homeassistant/components/fully_kiosk/number.py b/homeassistant/components/fully_kiosk/number.py index 59c249fd1c2e8..ef25a69f1ee00 100644 --- a/homeassistant/components/fully_kiosk/number.py +++ b/homeassistant/components/fully_kiosk/number.py @@ -5,12 +5,11 @@ from contextlib import suppress from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator from .entity import FullyKioskEntity @@ -54,11 +53,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FullyKioskConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser number entities.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( FullyNumberEntity(coordinator, entity) diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index 48fc8e514257d..ed95323547fb9 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -12,13 +12,12 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator from .entity import FullyKioskEntity @@ -114,13 +113,11 @@ class FullySensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FullyKioskConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser sensor.""" - coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data async_add_entities( FullySensor(coordinator, description) for description in SENSORS diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py index b93691989402b..089ae1d4246f6 100644 --- a/homeassistant/components/fully_kiosk/services.py +++ b/homeassistant/components/fully_kiosk/services.py @@ -53,7 +53,7 @@ async def collect_coordinators( for config_entry in config_entries: if config_entry.state != ConfigEntryState.LOADED: raise HomeAssistantError(f"{config_entry.title} is not loaded") - coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) + coordinators.append(config_entry.runtime_data) return coordinators async def async_load_url(call: ServiceCall) -> None: diff --git a/homeassistant/components/fully_kiosk/switch.py b/homeassistant/components/fully_kiosk/switch.py index 9d5af87abe956..4adf8e8c924a3 100644 --- a/homeassistant/components/fully_kiosk/switch.py +++ b/homeassistant/components/fully_kiosk/switch.py @@ -9,12 +9,11 @@ from fullykiosk import FullyKiosk from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator from .entity import FullyKioskEntity @@ -84,13 +83,11 @@ class FullySwitchEntityDescription(SwitchEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FullyKioskConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser switch.""" - coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data async_add_entities( FullySwitchEntity(coordinator, description) for description in SWITCHES From 1018a77c912e68992591b53a1a362886ee5bb9bb Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 21 Nov 2024 14:26:24 +0000 Subject: [PATCH 0645/1070] Update websockets package constraint to 13.1 (#131039) --- homeassistant/package_constraints.txt | 10 ++++++---- script/gen_requirements_all.py | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 285c7debe9986..405383aec15d4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -152,10 +152,12 @@ protobuf==5.28.3 # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 -# websockets 11.0 is missing files in the source distribution -# which break wheel builds so we need at least 11.0.1 -# https://github.com/aaugustin/websockets/issues/1329 -websockets>=11.0.1 +# websockets 13.1 is the first version to fully support the new +# asyncio implementation. The legacy implementation is now +# deprecated as of websockets 14.0. +# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features +# https://websockets.readthedocs.io/en/stable/howto/upgrade.html +websockets>=13.1 # pysnmplib is no longer maintained and does not work with newer # python diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 7d53741c661eb..689db744f1732 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -185,10 +185,12 @@ # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 -# websockets 11.0 is missing files in the source distribution -# which break wheel builds so we need at least 11.0.1 -# https://github.com/aaugustin/websockets/issues/1329 -websockets>=11.0.1 +# websockets 13.1 is the first version to fully support the new +# asyncio implementation. The legacy implementation is now +# deprecated as of websockets 14.0. +# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features +# https://websockets.readthedocs.io/en/stable/howto/upgrade.html +websockets>=13.1 # pysnmplib is no longer maintained and does not work with newer # python From c267170616afc22cfd8ddb5a63d37f185f490008 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 21 Nov 2024 15:39:35 +0100 Subject: [PATCH 0646/1070] Use reauth helpers in renault (#131147) --- .../components/renault/config_flow.py | 71 +++++++------------ tests/components/renault/test_config_flow.py | 3 + 2 files changed, 30 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py index 82429dd146c83..68024a7149930 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any +from typing import Any from renault_api.const import AVAILABLE_LOCALES import voluptuous as vol @@ -14,6 +14,15 @@ from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, DOMAIN from .renault_hub import RenaultHub +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) +REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) + class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Renault config flow.""" @@ -22,7 +31,6 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the Renault config flow.""" - self._original_data: Mapping[str, Any] | None = None self.renault_config: dict[str, Any] = {} self.renault_hub: RenaultHub | None = None @@ -49,13 +57,7 @@ def _show_user_form(self, errors: dict[str, Any] | None = None) -> ConfigFlowRes """Show the API keys form.""" return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()), - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } - ), + data_schema=USER_SCHEMA, errors=errors or {}, ) @@ -97,48 +99,29 @@ async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Perform reauth upon an API authentication error.""" - self._original_data = entry_data return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" - if not user_input: - return self._show_reauth_confirm_form() - - if TYPE_CHECKING: - assert self._original_data - - # Check credentials - self.renault_hub = RenaultHub(self.hass, self._original_data[CONF_LOCALE]) - if not await self.renault_hub.attempt_login( - self._original_data[CONF_USERNAME], user_input[CONF_PASSWORD] - ): - return self._show_reauth_confirm_form({"base": "invalid_credentials"}) - - # Update existing entry - data = {**self._original_data, CONF_PASSWORD: user_input[CONF_PASSWORD]} - existing_entry = await self.async_set_unique_id( - self._original_data[CONF_KAMEREON_ACCOUNT_ID] - ) - if TYPE_CHECKING: - assert existing_entry - self.hass.config_entries.async_update_entry(existing_entry, data=data) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") - - def _show_reauth_confirm_form( - self, errors: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Show the API keys form.""" - if TYPE_CHECKING: - assert self._original_data + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + if user_input: + # Check credentials + self.renault_hub = RenaultHub(self.hass, reauth_entry.data[CONF_LOCALE]) + if await self.renault_hub.attempt_login( + reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] + ): + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + errors = {"base": "invalid_credentials"} + return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + data_schema=REAUTH_SCHEMA, errors=errors or {}, - description_placeholders={ - CONF_USERNAME: self._original_data[CONF_USERNAME] - }, + description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]}, ) diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py index 234d1dca06931..ce69cc4a5c06f 100644 --- a/tests/components/renault/test_config_flow.py +++ b/tests/components/renault/test_config_flow.py @@ -256,3 +256,6 @@ async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> Non assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reauth_successful" + + assert config_entry.data[CONF_USERNAME] == "email@test.com" + assert config_entry.data[CONF_PASSWORD] == "any" From 42dcfae6e7215648a97c6182c11216af24e59e3e Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Thu, 21 Nov 2024 09:45:32 -0500 Subject: [PATCH 0647/1070] Add quality_scale.yaml for Fully Kiosk Browser integration (#131071) * Add quality_scale.yaml for Fully Kiosk Browser integration * Forgot to fill out a couple rules * missed another... * Rebase and update a couple rules --- .../components/fully_kiosk/quality_scale.yaml | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 homeassistant/components/fully_kiosk/quality_scale.yaml diff --git a/homeassistant/components/fully_kiosk/quality_scale.yaml b/homeassistant/components/fully_kiosk/quality_scale.yaml new file mode 100644 index 0000000000000..b30b629ae3ad1 --- /dev/null +++ b/homeassistant/components/fully_kiosk/quality_scale.yaml @@ -0,0 +1,64 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: done + dependency-transparency: done + action-setup: done + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + docs-actions: done + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: todo + entity-unavailable: done + action-exceptions: todo + reauthentication-flow: todo + parallel-updates: todo + test-coverage: todo + integration-owner: done + docs-installation-parameters: todo + docs-configuration-parameters: todo + + # Gold + entity-translations: todo + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: + status: exempt + comment: Each config entry maps to a single device + diagnostics: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: + status: exempt + comment: Each config entry maps to a single device + discovery-update-info: done + repair-issues: todo + docs-use-cases: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-data-update: todo + docs-known-limitations: done + docs-troubleshooting: todo + docs-examples: done + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: todo From 9add3a6c9b248ab7aafcdffdf4903c15ddce86d2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 21 Nov 2024 16:01:36 +0100 Subject: [PATCH 0648/1070] Add ability to pass integration domain to report_usage (#130705) * Add ability to pass integration domain to report_usage * Adjust * Fix * Add tests * Update test_frame.py * Update test_frame.py * Update test_frame.py * Update test_frame.py * Update test_frame.py * Update test_frame.py * Finish tests * Docstring * Replace logger warning with report_usage * Improve * docstring * Improve tests * Adjust docstring for exclude_integrations * Fix behavior and improve tests --- homeassistant/config_entries.py | 18 +++---- homeassistant/helpers/frame.py | 82 +++++++++++++++++++++++++++++-- tests/helpers/test_frame.py | 87 +++++++++++++++++++++++++++++++++ tests/test_config_entries.py | 13 +++-- 4 files changed, 178 insertions(+), 22 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 56931fe289dac..58d9c9c728f32 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2887,18 +2887,12 @@ def async_create_entry( # type: ignore[override] ) -> ConfigFlowResult: """Finish config flow and create a config entry.""" if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: - report_issue = async_suggest_report_issue( - self.hass, integration_domain=self.handler - ) - _LOGGER.warning( - ( - "Detected %s config flow creating a new entry, " - "when it is expected to update an existing entry and abort. " - "This will stop working in %s, please %s" - ), - self.source, - "2025.11", - report_issue, + report_usage( + f"creates a new entry in a '{self.source}' flow, " + "when it is expected to update an existing entry and abort", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2025.11", + integration_domain=self.handler, ) result = super().async_create_entry( title=title, diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 3ebe6fdba295c..6d03ae4ffd275 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -15,9 +15,13 @@ from propcache import cached_property -from homeassistant.core import async_get_hass_or_none +from homeassistant.core import HomeAssistant, async_get_hass_or_none from homeassistant.exceptions import HomeAssistantError -from homeassistant.loader import async_suggest_report_issue +from homeassistant.loader import ( + Integration, + async_get_issue_integration, + async_suggest_report_issue, +) _LOGGER = logging.getLogger(__name__) @@ -186,6 +190,7 @@ def report_usage( core_integration_behavior: ReportBehavior = ReportBehavior.LOG, custom_integration_behavior: ReportBehavior = ReportBehavior.LOG, exclude_integrations: set[str] | None = None, + integration_domain: str | None = None, level: int = logging.WARNING, ) -> None: """Report incorrect code usage. @@ -194,12 +199,29 @@ def report_usage( Please create a bug report at https://..." :param breaks_in_ha_version: if set, the report will be adjusted to specify the breaking version + :param exclude_integrations: skip specified integration when reviewing the stack. + If no integration is found, the core behavior will be applied + :param integration_domain: fallback for identifying the integration if the + frame is not found """ try: integration_frame = get_integration_frame( exclude_integrations=exclude_integrations ) except MissingIntegrationFrame as err: + if integration := async_get_issue_integration( + hass := async_get_hass_or_none(), integration_domain + ): + _report_integration_domain( + hass, + what, + breaks_in_ha_version, + integration, + core_integration_behavior, + custom_integration_behavior, + level, + ) + return msg = f"Detected code that {what}. Please report this issue" if core_behavior is ReportBehavior.ERROR: raise RuntimeError(msg) from err @@ -217,7 +239,7 @@ def report_usage( integration_behavior = custom_integration_behavior if integration_behavior is not ReportBehavior.IGNORE: - _report_integration( + _report_integration_frame( what, breaks_in_ha_version, integration_frame, @@ -226,14 +248,64 @@ def report_usage( ) -def _report_integration( +def _report_integration_domain( + hass: HomeAssistant | None, + what: str, + breaks_in_ha_version: str | None, + integration: Integration, + core_integration_behavior: ReportBehavior, + custom_integration_behavior: ReportBehavior, + level: int, +) -> None: + """Report incorrect usage in an integration (identified via domain). + + Async friendly. + """ + integration_behavior = core_integration_behavior + if not integration.is_built_in: + integration_behavior = custom_integration_behavior + + if integration_behavior is ReportBehavior.IGNORE: + return + + # Keep track of integrations already reported to prevent flooding + key = f"{integration.domain}:{what}" + if ( + integration_behavior is not ReportBehavior.ERROR + and key in _REPORTED_INTEGRATIONS + ): + return + _REPORTED_INTEGRATIONS.add(key) + + report_issue = async_suggest_report_issue(hass, integration=integration) + integration_type = "" if integration.is_built_in else "custom " + _LOGGER.log( + level, + "Detected that %sintegration '%s' %s. %s %s", + integration_type, + integration.domain, + what, + f"This will stop working in Home Assistant {breaks_in_ha_version}, please" + if breaks_in_ha_version + else "Please", + report_issue, + ) + + if integration_behavior is ReportBehavior.ERROR: + raise RuntimeError( + f"Detected that {integration_type}integration " + f"'{integration.domain}' {what}. Please {report_issue}" + ) + + +def _report_integration_frame( what: str, breaks_in_ha_version: str | None, integration_frame: IntegrationFrame, level: int = logging.WARNING, error: bool = False, ) -> None: - """Report incorrect usage in an integration. + """Report incorrect usage in an integration (identified via frame). Async friendly. """ diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 1c1dc8eb71eec..fb98111fd4241 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -7,6 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import frame +from homeassistant.loader import async_get_integration from tests.common import extract_stack_to_frame @@ -445,3 +446,89 @@ async def test_report( assert errored == expected_error assert caplog.text.count(what) == expected_log + + +@pytest.mark.parametrize( + ("behavior", "integration_domain", "source", "logs_again"), + [ + pytest.param( + "core_behavior", + None, + "code that", + True, + id="core", + ), + pytest.param( + "core_behavior", + "unknown_integration", + "code that", + True, + id="unknown integration", + ), + pytest.param( + "core_integration_behavior", + "sensor", + "that integration 'sensor'", + False, + id="core integration", + ), + pytest.param( + "custom_integration_behavior", + "test_package", + "that custom integration 'test_package'", + False, + id="custom integration", + ), + ], +) +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_report_integration_domain( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + behavior: str, + integration_domain: str | None, + source: str, + logs_again: bool, +) -> None: + """Test report.""" + await async_get_integration(hass, "sensor") + await async_get_integration(hass, "test_package") + + what = "test_report_string" + lookup_text = f"Detected {source} {what}" + + caplog.clear() + frame.report_usage( + what, + **{behavior: frame.ReportBehavior.IGNORE}, + integration_domain=integration_domain, + ) + + assert lookup_text not in caplog.text + + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + frame.report_usage( + what, + **{behavior: frame.ReportBehavior.LOG}, + integration_domain=integration_domain, + ) + + assert lookup_text in caplog.text + + # Check that it does not log again + caplog.clear() + frame.report_usage( + what, + **{behavior: frame.ReportBehavior.LOG}, + integration_domain=integration_domain, + ) + + assert (lookup_text in caplog.text) == logs_again + + # Check that it raises + with pytest.raises(RuntimeError, match=lookup_text): + frame.report_usage( + what, + **{behavior: frame.ReportBehavior.ERROR}, + integration_domain=integration_domain, + ) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 44f741242aa83..4fad1a32b43d4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -7157,7 +7157,10 @@ async def _async_step_confirm(self): assert len(hass.config_entries.async_entries("test")) == 1 - with mock_config_flow("test", TestFlow): + with ( + mock_config_flow("test", TestFlow), + patch.object(frame, "_REPORTED_INTEGRATIONS", set()), + ): result = await getattr(entry, f"start_{source}_flow")(hass) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY @@ -7169,10 +7172,10 @@ async def _async_step_confirm(self): assert entries[0].entry_id != entry.entry_id assert ( - f"Detected {source} config flow creating a new entry, when it is expected " - "to update an existing entry and abort. This will stop working in " - "2025.11, please create a bug report at https://github.com/home" - "-assistant/core/issues?q=is%3Aopen+is%3Aissue+" + f"Detected that integration 'test' creates a new entry in a '{source}' flow, " + "when it is expected to update an existing entry and abort. This will stop " + "working in Home Assistant 2025.11, please create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+" "label%3A%22integration%3A+test%22" ) in caplog.text From d8549409f79aeebe6ae8650849deb219b4cf6461 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 22 Nov 2024 04:10:44 +1300 Subject: [PATCH 0649/1070] Area units and conversion between metric and US (#123563) * area conversions * start work on tests * add number device class * update unit conversions to utilise distance constants * add area unit * update test unit system * update device condition and trigger * update statistic unit converters * further tests work WIP * update test unit system * add missing string translations * fix websocket tests * add deprecated notice * add more missing strings and missing initialisation of unit system * adjust icon and remove strings from scrape and random * Fix acre to meters conversion Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Tidy up valid units Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * fix ordering of area * update order alphabetically * fix broken test * update test_init * Update homeassistant/const.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * remove deprecated unit and fix alphabetical order * change deprecation and add tests, change to millimeter conversion for inches * fix order * re-order defs alphabetically * add measurement as well * update icons * fix up Deprecation of area square meters * Update core integrations to UnitOfArea * update test recorder tests * unit system tests in alphabetical * update snapshot * rebuild * revert alphabetization of functions * other revert of alphabetical order --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/ecovacs/sensor.py | 6 +- homeassistant/components/number/const.py | 8 ++ homeassistant/components/number/icons.json | 3 + homeassistant/components/number/strings.json | 3 + .../components/recorder/statistics.py | 2 + .../components/recorder/websocket_api.py | 2 + .../rituals_perfume_genie/select.py | 4 +- homeassistant/components/roborock/sensor.py | 11 +-- homeassistant/components/romy/sensor.py | 4 +- homeassistant/components/roomba/sensor.py | 9 +- homeassistant/components/sensor/const.py | 11 +++ .../components/sensor/device_condition.py | 3 + .../components/sensor/device_trigger.py | 3 + homeassistant/components/sensor/icons.json | 3 + homeassistant/components/sensor/strings.json | 5 + .../components/smartthings/sensor.py | 4 +- .../components/xiaomi_miio/sensor.py | 8 +- homeassistant/const.py | 24 ++++- homeassistant/util/unit_conversion.py | 33 +++++++ homeassistant/util/unit_system.py | 36 +++++++- .../ecovacs/snapshots/test_sensor.ambr | 16 ++-- .../components/recorder/test_websocket_api.py | 23 +++++ .../rituals_perfume_genie/test_select.py | 4 +- tests/components/sensor/test_init.py | 30 ++++++ tests/components/sensor/test_recorder.py | 14 +++ tests/helpers/test_template.py | 2 + tests/test_const.py | 18 ++-- tests/util/test_unit_conversion.py | 60 ++++++++++++ tests/util/test_unit_system.py | 92 +++++++++++++++++++ 29 files changed, 394 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 28c4efbd0c634..7c190d27775c8 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -26,11 +26,11 @@ SensorStateClass, ) from homeassistant.const import ( - AREA_SQUARE_METERS, ATTR_BATTERY_LEVEL, CONF_DESCRIPTION, PERCENTAGE, EntityCategory, + UnitOfArea, UnitOfTime, ) from homeassistant.core import HomeAssistant @@ -67,7 +67,7 @@ class EcovacsSensorEntityDescription( capability_fn=lambda caps: caps.stats.clean, value_fn=lambda e: e.area, translation_key="stats_area", - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, ), EcovacsSensorEntityDescription[StatsEvent]( key="stats_time", @@ -84,7 +84,7 @@ class EcovacsSensorEntityDescription( value_fn=lambda e: e.area, key="total_stats_area", translation_key="total_stats_area", - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, state_class=SensorStateClass.TOTAL_INCREASING, ), EcovacsSensorEntityDescription[TotalStatsEvent]( diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 374a69dedc8e7..3f29dd0416601 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -17,6 +17,7 @@ SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfApparentPower, + UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, @@ -98,6 +99,12 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `None` """ + AREA = "area" + """Area + + Unit of measurement: `UnitOfArea` units + """ + ATMOSPHERIC_PRESSURE = "atmospheric_pressure" """Atmospheric pressure. @@ -434,6 +441,7 @@ class NumberDeviceClass(StrEnum): DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), NumberDeviceClass.AQI: {None}, + NumberDeviceClass.AREA: set(UnitOfArea), NumberDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure), NumberDeviceClass.BATTERY: {PERCENTAGE}, NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration), diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index 5e0fc6e44d261..636fa0a7751e8 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -9,6 +9,9 @@ "aqi": { "default": "mdi:air-filter" }, + "area": { + "default": "mdi:texture-box" + }, "atmospheric_pressure": { "default": "mdi:thermometer-lines" }, diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index b9aec880ecc22..cc77d224d72af 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -37,6 +37,9 @@ "aqi": { "name": "[%key:component::sensor::entity_component::aqi::name%]" }, + "area": { + "name": "[%key:component::sensor::entity_component::area::name%]" + }, "atmospheric_pressure": { "name": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]" }, diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 7243af9d4d548..9f01fd0399c69 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -27,6 +27,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( + AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, @@ -129,6 +130,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { + **{unit: AreaConverter for unit in AreaConverter.VALID_UNITS}, **{ unit: BloodGlucoseConcentrationConverter for unit in BloodGlucoseConcentrationConverter.VALID_UNITS diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index f4dce73fa4739..ee5c5dd6d75b1 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -16,6 +16,7 @@ from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( + AreaConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, @@ -55,6 +56,7 @@ UNIT_SCHEMA = vol.Schema( { + vol.Optional("area"): vol.In(AreaConverter.VALID_UNITS), vol.Optional("blood_glucose_concentration"): vol.In( BloodGlucoseConcentrationConverter.VALID_UNITS ), diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index e93d6ae03ef20..27aff70649bf6 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -9,7 +9,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import AREA_SQUARE_METERS, EntityCategory +from homeassistant.const import EntityCategory, UnitOfArea from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -30,7 +30,7 @@ class RitualsSelectEntityDescription(SelectEntityDescription): RitualsSelectEntityDescription( key="room_size_square_meter", translation_key="room_size_square_meter", - unit_of_measurement=AREA_SQUARE_METERS, + unit_of_measurement=UnitOfArea.SQUARE_METERS, entity_category=EntityCategory.CONFIG, options=["15", "30", "60", "100"], current_fn=lambda diffuser: str(diffuser.room_size_square_meter), diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 33ce6be5a6814..47849ed5cc53c 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -25,12 +25,7 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.const import ( - AREA_SQUARE_METERS, - PERCENTAGE, - EntityCategory, - UnitOfTime, -) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -131,14 +126,14 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: translation_key="cleaning_area", value_fn=lambda data: data.status.square_meter_clean_area, entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, ), RoborockSensorDescription( key="total_cleaning_area", translation_key="total_cleaning_area", value_fn=lambda data: data.clean_summary.square_meter_clean_area, entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, ), RoborockSensorDescription( key="vacuum_error", diff --git a/homeassistant/components/romy/sensor.py b/homeassistant/components/romy/sensor.py index bdd486c4f8f84..341125b86ba1d 100644 --- a/homeassistant/components/romy/sensor.py +++ b/homeassistant/components/romy/sensor.py @@ -8,10 +8,10 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - AREA_SQUARE_METERS, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfArea, UnitOfLength, UnitOfTime, ) @@ -61,7 +61,7 @@ key="total_area_cleaned", translation_key="total_area_cleaned", state_class=SensorStateClass.TOTAL, - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 87e97fdb76085..d358dcb428c24 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -12,12 +12,7 @@ SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - AREA_SQUARE_METERS, - PERCENTAGE, - EntityCategory, - UnitOfTime, -) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -108,7 +103,7 @@ class RoombaSensorEntityDescription(SensorEntityDescription): RoombaSensorEntityDescription( key="total_cleaned_area", translation_key="total_cleaned_area", - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda self: ( None if (sqft := self.run_stats.get("sqft")) is None else sqft * 9.29 diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index b06353272f87e..c290a627b0700 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -17,6 +17,7 @@ SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfApparentPower, + UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, @@ -47,6 +48,7 @@ dir_with_deprecated_constants, ) from homeassistant.util.unit_conversion import ( + AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, @@ -117,6 +119,12 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `None` """ + AREA = "area" + """Area + + Unit of measurement: `UnitOfArea` units + """ + ATMOSPHERIC_PRESSURE = "atmospheric_pressure" """Atmospheric pressure. @@ -500,6 +508,7 @@ class SensorStateClass(StrEnum): STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { + SensorDeviceClass.AREA: AreaConverter, SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter, SensorDeviceClass.CONDUCTIVITY: ConductivityConverter, @@ -531,6 +540,7 @@ class SensorStateClass(StrEnum): DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), SensorDeviceClass.AQI: {None}, + SensorDeviceClass.AREA: set(UnitOfArea), SensorDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure), SensorDeviceClass.BATTERY: {PERCENTAGE}, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration), @@ -607,6 +617,7 @@ class SensorStateClass(StrEnum): DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.APPARENT_POWER: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.AQI: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.AREA: set(SensorStateClass), SensorDeviceClass.ATMOSPHERIC_PRESSURE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.BATTERY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: {SensorStateClass.MEASUREMENT}, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 56ecb36adb3b1..fc25dce18fce9 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -35,6 +35,7 @@ CONF_IS_APPARENT_POWER = "is_apparent_power" CONF_IS_AQI = "is_aqi" +CONF_IS_AREA = "is_area" CONF_IS_ATMOSPHERIC_PRESSURE = "is_atmospheric_pressure" CONF_IS_BATTERY_LEVEL = "is_battery_level" CONF_IS_BLOOD_GLUCOSE_CONCENTRATION = "is_blood_glucose_concentration" @@ -86,6 +87,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_IS_APPARENT_POWER}], SensorDeviceClass.AQI: [{CONF_TYPE: CONF_IS_AQI}], + SensorDeviceClass.AREA: [{CONF_TYPE: CONF_IS_AREA}], SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_IS_ATMOSPHERIC_PRESSURE}], SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_IS_BATTERY_LEVEL}], SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: [ @@ -153,6 +155,7 @@ [ CONF_IS_APPARENT_POWER, CONF_IS_AQI, + CONF_IS_AREA, CONF_IS_ATMOSPHERIC_PRESSURE, CONF_IS_BATTERY_LEVEL, CONF_IS_BLOOD_GLUCOSE_CONCENTRATION, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index ffee10d9f401b..d75b3aa6e41a2 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -34,6 +34,7 @@ CONF_APPARENT_POWER = "apparent_power" CONF_AQI = "aqi" +CONF_AREA = "area" CONF_ATMOSPHERIC_PRESSURE = "atmospheric_pressure" CONF_BATTERY_LEVEL = "battery_level" CONF_BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration" @@ -85,6 +86,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_APPARENT_POWER}], SensorDeviceClass.AQI: [{CONF_TYPE: CONF_AQI}], + SensorDeviceClass.AREA: [{CONF_TYPE: CONF_AREA}], SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_ATMOSPHERIC_PRESSURE}], SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_BATTERY_LEVEL}], SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: [ @@ -153,6 +155,7 @@ [ CONF_APPARENT_POWER, CONF_AQI, + CONF_AREA, CONF_ATMOSPHERIC_PRESSURE, CONF_BATTERY_LEVEL, CONF_BLOOD_GLUCOSE_CONCENTRATION, diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index ea4c902e66573..5f770765ee3df 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -9,6 +9,9 @@ "aqi": { "default": "mdi:air-filter" }, + "area": { + "default": "mdi:texture-box" + }, "atmospheric_pressure": { "default": "mdi:thermometer-lines" }, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 6d529e72c3b2b..0bc370398b513 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -4,6 +4,7 @@ "condition_type": { "is_apparent_power": "Current {entity_name} apparent power", "is_aqi": "Current {entity_name} air quality index", + "is_area": "Current {entity_name} area", "is_atmospheric_pressure": "Current {entity_name} atmospheric pressure", "is_battery_level": "Current {entity_name} battery level", "is_blood_glucose_concentration": "Current {entity_name} blood glucose concentration", @@ -55,6 +56,7 @@ "trigger_type": { "apparent_power": "{entity_name} apparent power changes", "aqi": "{entity_name} air quality index changes", + "area": "{entity_name} area changes", "atmospheric_pressure": "{entity_name} atmospheric pressure changes", "battery_level": "{entity_name} battery level changes", "blood_glucose_concentration": "{entity_name} blood glucose concentration changes", @@ -145,6 +147,9 @@ "aqi": { "name": "Air quality index" }, + "area": { + "name": "Area" + }, "atmospheric_pressure": { "name": "Atmospheric pressure" }, diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index b73d3b43764eb..8bd0421d2bcf3 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -15,11 +15,11 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - AREA_SQUARE_METERS, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, EntityCategory, + UnitOfArea, UnitOfElectricPotential, UnitOfEnergy, UnitOfMass, @@ -95,7 +95,7 @@ class Map(NamedTuple): Map( Attribute.bmi_measurement, "Body Mass Index", - f"{UnitOfMass.KILOGRAMS}/{AREA_SQUARE_METERS}", + f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}", None, SensorStateClass.MEASUREMENT, None, diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 3f6f4e9b50bf6..aafcba974875e 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -24,7 +24,6 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - AREA_SQUARE_METERS, ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -37,6 +36,7 @@ PERCENTAGE, REVOLUTIONS_PER_MINUTE, EntityCategory, + UnitOfArea, UnitOfPower, UnitOfPressure, UnitOfTemperature, @@ -622,7 +622,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, ), f"last_clean_{ATTR_LAST_CLEAN_AREA}": XiaomiMiioSensorDescription( - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, icon="mdi:texture-box", key=ATTR_LAST_CLEAN_AREA, parent_key=VacuumCoordinatorDataAttributes.last_clean_details, @@ -639,7 +639,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, ), f"current_{ATTR_LAST_CLEAN_AREA}": XiaomiMiioSensorDescription( - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, icon="mdi:texture-box", key=ATTR_STATUS_CLEAN_AREA, parent_key=VacuumCoordinatorDataAttributes.status, @@ -657,7 +657,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, ), f"clean_history_{ATTR_CLEAN_HISTORY_TOTAL_AREA}": XiaomiMiioSensorDescription( - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, icon="mdi:texture-box", key=ATTR_CLEAN_HISTORY_TOTAL_AREA, parent_key=VacuumCoordinatorDataAttributes.clean_history_status, diff --git a/homeassistant/const.py b/homeassistant/const.py index 61b60fc3cf356..5a3a3f292ffe7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1179,8 +1179,27 @@ class UnitOfVolumeFlowRate(StrEnum): ) """Deprecated: please use UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE""" -# Area units -AREA_SQUARE_METERS: Final = "m²" + +class UnitOfArea(StrEnum): + """Area units.""" + + SQUARE_METERS = "m²" + SQUARE_CENTIMETERS = "cm²" + SQUARE_KILOMETERS = "km²" + SQUARE_MILLIMETERS = "mm²" + SQUARE_INCHES = "in²" + SQUARE_FEET = "ft²" + SQUARE_YARDS = "yd²" + SQUARE_MILES = "mi²" + ACRES = "ac" + HECTARES = "ha" + + +_DEPRECATED_AREA_SQUARE_METERS: Final = DeprecatedConstantEnum( + UnitOfArea.SQUARE_METERS, + "2025.12", +) +"""Deprecated: please use UnitOfArea.SQUARE_METERS""" # Mass units @@ -1704,6 +1723,7 @@ class UnitOfDataRate(StrEnum): UNIT_NOT_RECOGNIZED_TEMPLATE: Final = "{} is not a recognized {} unit." LENGTH: Final = "length" +AREA: Final = "area" MASS: Final = "mass" PRESSURE: Final = "pressure" VOLUME: Final = "volume" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index a4c35d67ab75d..b700c66f248d4 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -10,6 +10,7 @@ CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, UNIT_NOT_RECOGNIZED_TEMPLATE, + UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, @@ -42,6 +43,19 @@ _NAUTICAL_MILE_TO_M = 1852 # 1 nautical mile = 1852 m +# Area constants to square meters +_CM2_TO_M2 = _CM_TO_M**2 # 1 cm² = 0.0001 m² +_MM2_TO_M2 = _MM_TO_M**2 # 1 mm² = 0.000001 m² +_KM2_TO_M2 = _KM_TO_M**2 # 1 km² = 1,000,000 m² + +_IN2_TO_M2 = _IN_TO_M**2 # 1 in² = 0.00064516 m² +_FT2_TO_M2 = _FOOT_TO_M**2 # 1 ft² = 0.092903 m² +_YD2_TO_M2 = _YARD_TO_M**2 # 1 yd² = 0.836127 m² +_MI2_TO_M2 = _MILE_TO_M**2 # 1 mi² = 2,590,000 m² + +_ACRE_TO_M2 = 66 * 660 * _FT2_TO_M2 # 1 acre = 4,046.86 m² +_HECTARE_TO_M2 = 100 * 100 # 1 hectare = 10,000 m² + # Duration conversion constants _MIN_TO_SEC = 60 # 1 min = 60 seconds _HRS_TO_MINUTES = 60 # 1 hr = 60 minutes @@ -146,6 +160,25 @@ class DataRateConverter(BaseUnitConverter): VALID_UNITS = set(UnitOfDataRate) +class AreaConverter(BaseUnitConverter): + """Utility to convert area values.""" + + UNIT_CLASS = "area" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfArea.SQUARE_METERS: 1, + UnitOfArea.SQUARE_CENTIMETERS: 1 / _CM2_TO_M2, + UnitOfArea.SQUARE_MILLIMETERS: 1 / _MM2_TO_M2, + UnitOfArea.SQUARE_KILOMETERS: 1 / _KM2_TO_M2, + UnitOfArea.SQUARE_INCHES: 1 / _IN2_TO_M2, + UnitOfArea.SQUARE_FEET: 1 / _FT2_TO_M2, + UnitOfArea.SQUARE_YARDS: 1 / _YD2_TO_M2, + UnitOfArea.SQUARE_MILES: 1 / _MI2_TO_M2, + UnitOfArea.ACRES: 1 / _ACRE_TO_M2, + UnitOfArea.HECTARES: 1 / _HECTARE_TO_M2, + } + VALID_UNITS = set(UnitOfArea) + + class DistanceConverter(BaseUnitConverter): """Utility to convert distance values.""" diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 7f7c7f2b5fdb6..c812dd38230c2 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -9,6 +9,7 @@ from homeassistant.const import ( ACCUMULATED_PRECIPITATION, + AREA, LENGTH, MASS, PRESSURE, @@ -16,6 +17,7 @@ UNIT_NOT_RECOGNIZED_TEMPLATE, VOLUME, WIND_SPEED, + UnitOfArea, UnitOfLength, UnitOfMass, UnitOfPrecipitationDepth, @@ -27,6 +29,7 @@ ) from .unit_conversion import ( + AreaConverter, DistanceConverter, PressureConverter, SpeedConverter, @@ -41,6 +44,8 @@ _CONF_UNIT_SYSTEM_METRIC: Final = "metric" _CONF_UNIT_SYSTEM_US_CUSTOMARY: Final = "us_customary" +AREA_UNITS = AreaConverter.VALID_UNITS + LENGTH_UNITS = DistanceConverter.VALID_UNITS MASS_UNITS: set[str] = { @@ -66,6 +71,7 @@ MASS: MASS_UNITS, VOLUME: VOLUME_UNITS, PRESSURE: PRESSURE_UNITS, + AREA: AREA_UNITS, } @@ -84,6 +90,7 @@ def __init__( name: str, *, accumulated_precipitation: UnitOfPrecipitationDepth, + area: UnitOfArea, conversions: dict[tuple[SensorDeviceClass | str | None, str | None], str], length: UnitOfLength, mass: UnitOfMass, @@ -97,6 +104,7 @@ def __init__( UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit, unit_type) for unit, unit_type in ( (accumulated_precipitation, ACCUMULATED_PRECIPITATION), + (area, AREA), (temperature, TEMPERATURE), (length, LENGTH), (wind_speed, WIND_SPEED), @@ -112,10 +120,11 @@ def __init__( self._name = name self.accumulated_precipitation_unit = accumulated_precipitation - self.temperature_unit = temperature + self.area_unit = area self.length_unit = length self.mass_unit = mass self.pressure_unit = pressure + self.temperature_unit = temperature self.volume_unit = volume self.wind_speed_unit = wind_speed self._conversions = conversions @@ -149,6 +158,16 @@ def accumulated_precipitation(self, precip: float | None, from_unit: str) -> flo precip, from_unit, self.accumulated_precipitation_unit ) + def area(self, area: float | None, from_unit: str) -> float: + """Convert the given area to this unit system.""" + if not isinstance(area, Number): + raise TypeError(f"{area!s} is not a numeric value.") + + # type ignore: https://github.com/python/mypy/issues/7207 + return AreaConverter.convert( # type: ignore[unreachable] + area, from_unit, self.area_unit + ) + def pressure(self, pressure: float | None, from_unit: str) -> float: """Convert the given pressure to this unit system.""" if not isinstance(pressure, Number): @@ -184,6 +203,7 @@ def as_dict(self) -> dict[str, str]: return { LENGTH: self.length_unit, ACCUMULATED_PRECIPITATION: self.accumulated_precipitation_unit, + AREA: self.area_unit, MASS: self.mass_unit, PRESSURE: self.pressure_unit, TEMPERATURE: self.temperature_unit, @@ -234,6 +254,12 @@ def _deprecated_unit_system(value: str) -> str: for unit in UnitOfPressure if unit != UnitOfPressure.HPA }, + # Convert non-metric area + ("area", UnitOfArea.SQUARE_INCHES): UnitOfArea.SQUARE_CENTIMETERS, + ("area", UnitOfArea.SQUARE_FEET): UnitOfArea.SQUARE_METERS, + ("area", UnitOfArea.SQUARE_MILES): UnitOfArea.SQUARE_KILOMETERS, + ("area", UnitOfArea.SQUARE_YARDS): UnitOfArea.SQUARE_METERS, + ("area", UnitOfArea.ACRES): UnitOfArea.HECTARES, # Convert non-metric distances ("distance", UnitOfLength.FEET): UnitOfLength.METERS, ("distance", UnitOfLength.INCHES): UnitOfLength.MILLIMETERS, @@ -285,6 +311,7 @@ def _deprecated_unit_system(value: str) -> str: if unit not in (UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS) }, }, + area=UnitOfArea.SQUARE_METERS, length=UnitOfLength.KILOMETERS, mass=UnitOfMass.GRAMS, pressure=UnitOfPressure.PA, @@ -303,6 +330,12 @@ def _deprecated_unit_system(value: str) -> str: for unit in UnitOfPressure if unit != UnitOfPressure.INHG }, + # Convert non-USCS areas + ("area", UnitOfArea.SQUARE_METERS): UnitOfArea.SQUARE_FEET, + ("area", UnitOfArea.SQUARE_CENTIMETERS): UnitOfArea.SQUARE_INCHES, + ("area", UnitOfArea.SQUARE_MILLIMETERS): UnitOfArea.SQUARE_INCHES, + ("area", UnitOfArea.SQUARE_KILOMETERS): UnitOfArea.SQUARE_MILES, + ("area", UnitOfArea.HECTARES): UnitOfArea.ACRES, # Convert non-USCS distances ("distance", UnitOfLength.CENTIMETERS): UnitOfLength.INCHES, ("distance", UnitOfLength.KILOMETERS): UnitOfLength.MILES, @@ -356,6 +389,7 @@ def _deprecated_unit_system(value: str) -> str: if unit not in (UnitOfSpeed.KNOTS, UnitOfSpeed.MILES_PER_HOUR) }, }, + area=UnitOfArea.SQUARE_FEET, length=UnitOfLength.MILES, mass=UnitOfMass.POUNDS, pressure=UnitOfPressure.PSI, diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index 659edfde2cf93..9c76c00b5b7db 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -177,14 +177,14 @@ 'supported_features': 0, 'translation_key': 'stats_area', 'unique_id': '8516fbb1-17f1-4194-0000000_stats_area', - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }) # --- # name: test_sensors[5xu9h3][sensor.goat_g1_area_cleaned:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Goat G1 Area cleaned', - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.goat_g1_area_cleaned', @@ -512,7 +512,7 @@ 'supported_features': 0, 'translation_key': 'total_stats_area', 'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_area', - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }) # --- # name: test_sensors[5xu9h3][sensor.goat_g1_total_area_cleaned:state] @@ -520,7 +520,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Goat G1 Total area cleaned', 'state_class': , - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.goat_g1_total_area_cleaned', @@ -755,14 +755,14 @@ 'supported_features': 0, 'translation_key': 'stats_area', 'unique_id': 'E1234567890000000001_stats_area', - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }) # --- # name: test_sensors[yna5x1][sensor.ozmo_950_area_cleaned:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Ozmo 950 Area cleaned', - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.ozmo_950_area_cleaned', @@ -1137,7 +1137,7 @@ 'supported_features': 0, 'translation_key': 'total_stats_area', 'unique_id': 'E1234567890000000001_total_stats_area', - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }) # --- # name: test_sensors[yna5x1][sensor.ozmo_950_total_area_cleaned:state] @@ -1145,7 +1145,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Ozmo 950 Total area cleaned', 'state_class': , - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.ozmo_950_total_area_cleaned', diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 547288d1cc3f3..403384aee9f3b 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -51,6 +51,16 @@ async def mock_recorder_before_hass( """Set up recorder.""" +AREA_SENSOR_FT_ATTRIBUTES = { + "device_class": "area", + "state_class": "measurement", + "unit_of_measurement": "ft²", +} +AREA_SENSOR_M_ATTRIBUTES = { + "device_class": "area", + "state_class": "measurement", + "unit_of_measurement": "m²", +} DISTANCE_SENSOR_FT_ATTRIBUTES = { "device_class": "distance", "state_class": "measurement", @@ -1247,6 +1257,9 @@ async def test_statistic_during_period_calendar( @pytest.mark.parametrize( ("attributes", "state", "value", "custom_units", "converted_value"), [ + (AREA_SENSOR_M_ATTRIBUTES, 10, 10, {"area": "cm²"}, 100000), + (AREA_SENSOR_M_ATTRIBUTES, 10, 10, {"area": "m²"}, 10), + (AREA_SENSOR_M_ATTRIBUTES, 10, 10, {"area": "ft²"}, 107.639), (DISTANCE_SENSOR_M_ATTRIBUTES, 10, 10, {"distance": "cm"}, 1000), (DISTANCE_SENSOR_M_ATTRIBUTES, 10, 10, {"distance": "m"}, 10), (DISTANCE_SENSOR_M_ATTRIBUTES, 10, 10, {"distance": "in"}, 10 / 0.0254), @@ -1434,6 +1447,7 @@ async def test_sum_statistics_during_period_unit_conversion( "custom_units", [ {"distance": "L"}, + {"area": "L"}, {"energy": "W"}, {"power": "Pa"}, {"pressure": "K"}, @@ -1678,6 +1692,8 @@ async def test_statistics_during_period_empty_statistic_ids( @pytest.mark.parametrize( ("units", "attributes", "display_unit", "statistics_unit", "unit_class"), [ + (US_CUSTOMARY_SYSTEM, AREA_SENSOR_M_ATTRIBUTES, "m²", "m²", "area"), + (METRIC_SYSTEM, AREA_SENSOR_M_ATTRIBUTES, "m²", "m²", "area"), (US_CUSTOMARY_SYSTEM, DISTANCE_SENSOR_M_ATTRIBUTES, "m", "m", "distance"), (METRIC_SYSTEM, DISTANCE_SENSOR_M_ATTRIBUTES, "m", "m", "distance"), ( @@ -1852,6 +1868,13 @@ async def test_list_statistic_ids( @pytest.mark.parametrize( ("attributes", "attributes2", "display_unit", "statistics_unit", "unit_class"), [ + ( + AREA_SENSOR_M_ATTRIBUTES, + AREA_SENSOR_FT_ATTRIBUTES, + "ft²", + "m²", + "area", + ), ( DISTANCE_SENSOR_M_ATTRIBUTES, DISTANCE_SENSOR_FT_ATTRIBUTES, diff --git a/tests/components/rituals_perfume_genie/test_select.py b/tests/components/rituals_perfume_genie/test_select.py index 17612edfd9795..a4d97ab83fd31 100644 --- a/tests/components/rituals_perfume_genie/test_select.py +++ b/tests/components/rituals_perfume_genie/test_select.py @@ -9,10 +9,10 @@ DOMAIN as SELECT_DOMAIN, ) from homeassistant.const import ( - AREA_SQUARE_METERS, ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, EntityCategory, + UnitOfArea, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -38,7 +38,7 @@ async def test_select_entity( entry = entity_registry.async_get("select.genie_room_size") assert entry assert entry.unique_id == f"{diffuser.hublot}-room_size_square_meter" - assert entry.unit_of_measurement == AREA_SQUARE_METERS + assert entry.unit_of_measurement == UnitOfArea.SQUARE_METERS assert entry.entity_category == EntityCategory.CONFIG diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 2504ea80d8443..3893a089b8127 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -30,6 +30,7 @@ PERCENTAGE, STATE_UNKNOWN, EntityCategory, + UnitOfArea, UnitOfDataRate, UnitOfEnergy, UnitOfLength, @@ -651,6 +652,34 @@ async def test_custom_unit( "device_class", ), [ + # Area + ( + UnitOfArea.SQUARE_KILOMETERS, + UnitOfArea.SQUARE_MILES, + UnitOfArea.SQUARE_MILES, + 1000, + "1000", + "386", + SensorDeviceClass.AREA, + ), + ( + UnitOfArea.SQUARE_CENTIMETERS, + UnitOfArea.SQUARE_INCHES, + UnitOfArea.SQUARE_INCHES, + 7.24, + "7.24", + "1.12", + SensorDeviceClass.AREA, + ), + ( + UnitOfArea.SQUARE_KILOMETERS, + "peer_distance", + UnitOfArea.SQUARE_KILOMETERS, + 1000, + "1000", + "1000", + SensorDeviceClass.AREA, + ), # Distance ( UnitOfLength.KILOMETERS, @@ -1834,6 +1863,7 @@ async def test_non_numeric_device_class_with_unit_of_measurement( [ SensorDeviceClass.APPARENT_POWER, SensorDeviceClass.AQI, + SensorDeviceClass.AREA, SensorDeviceClass.ATMOSPHERIC_PRESSURE, SensorDeviceClass.BATTERY, SensorDeviceClass.CO, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index aec6ec84f1b03..44eaa9fde0d21 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -227,6 +227,8 @@ async def assert_validation_result( ), [ (None, "%", "%", "%", "unitless", 13.050847, -10, 30), + ("area", "m²", "m²", "m²", "area", 13.050847, -10, 30), + ("area", "mi²", "mi²", "mi²", "area", 13.050847, -10, 30), ("battery", "%", "%", "%", "unitless", 13.050847, -10, 30), ("battery", None, None, None, "unitless", 13.050847, -10, 30), ("distance", "m", "m", "m", "distance", 13.050847, -10, 30), @@ -914,6 +916,8 @@ async def test_compile_hourly_statistics_wrong_unit( "factor", ), [ + (US_CUSTOMARY_SYSTEM, "area", "m²", "m²", "m²", "area", 1), + (US_CUSTOMARY_SYSTEM, "area", "mi²", "mi²", "mi²", "area", 1), (US_CUSTOMARY_SYSTEM, "distance", "m", "m", "m", "distance", 1), (US_CUSTOMARY_SYSTEM, "distance", "mi", "mi", "mi", "distance", 1), (US_CUSTOMARY_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), @@ -926,6 +930,8 @@ async def test_compile_hourly_statistics_wrong_unit( (US_CUSTOMARY_SYSTEM, "volume", "ft³", "ft³", "ft³", "volume", 1), (US_CUSTOMARY_SYSTEM, "weight", "g", "g", "g", "mass", 1), (US_CUSTOMARY_SYSTEM, "weight", "oz", "oz", "oz", "mass", 1), + (METRIC_SYSTEM, "area", "m²", "m²", "m²", "area", 1), + (METRIC_SYSTEM, "area", "mi²", "mi²", "mi²", "area", 1), (METRIC_SYSTEM, "distance", "m", "m", "m", "distance", 1), (METRIC_SYSTEM, "distance", "mi", "mi", "mi", "distance", 1), (METRIC_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), @@ -2228,6 +2234,8 @@ async def test_compile_hourly_energy_statistics_multiple( [ ("battery", "%", 30), ("battery", None, 30), + ("area", "m²", 30), + ("area", "mi²", 30), ("distance", "m", 30), ("distance", "mi", 30), ("humidity", "%", 30), @@ -2336,6 +2344,8 @@ async def test_compile_hourly_statistics_partially_unavailable( [ ("battery", "%", 30), ("battery", None, 30), + ("area", "m²", 30), + ("area", "mi²", 30), ("distance", "m", 30), ("distance", "mi", 30), ("humidity", "%", 30), @@ -2438,6 +2448,10 @@ async def test_compile_hourly_statistics_fails( "statistic_type", ), [ + ("measurement", "area", "m²", "m²", "m²", "area", "mean"), + ("measurement", "area", "mi²", "mi²", "mi²", "area", "mean"), + ("total", "area", "m²", "m²", "m²", "area", "sum"), + ("total", "area", "mi²", "mi²", "mi²", "area", "sum"), ("measurement", "battery", "%", "%", "%", "unitless", "mean"), ("measurement", "battery", None, None, None, "unitless", "mean"), ("measurement", "distance", "m", "m", "m", "distance", "mean"), diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b8c6b5a25af37..628aea20900ba 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -24,6 +24,7 @@ ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_UNAVAILABLE, + UnitOfArea, UnitOfLength, UnitOfMass, UnitOfPrecipitationDepth, @@ -61,6 +62,7 @@ def _set_up_units(hass: HomeAssistant) -> None: hass.config.units = UnitSystem( "custom", accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=UnitOfLength.METERS, mass=UnitOfMass.GRAMS, diff --git a/tests/test_const.py b/tests/test_const.py index 73636b9910798..ca598de39e14f 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -177,18 +177,24 @@ def test_deprecated_constants( @pytest.mark.parametrize( - ("replacement", "constant_name"), + ("replacement", "constant_name", "breaks_in_version"), [ - (const.UnitOfLength.YARDS, "LENGTH_YARD"), - (const.UnitOfSoundPressure.DECIBEL, "SOUND_PRESSURE_DB"), - (const.UnitOfSoundPressure.WEIGHTED_DECIBEL_A, "SOUND_PRESSURE_WEIGHTED_DBA"), - (const.UnitOfVolume.FLUID_OUNCES, "VOLUME_FLUID_OUNCE"), + (const.UnitOfLength.YARDS, "LENGTH_YARD", "2025.1"), + (const.UnitOfSoundPressure.DECIBEL, "SOUND_PRESSURE_DB", "2025.1"), + ( + const.UnitOfSoundPressure.WEIGHTED_DECIBEL_A, + "SOUND_PRESSURE_WEIGHTED_DBA", + "2025.1", + ), + (const.UnitOfVolume.FLUID_OUNCES, "VOLUME_FLUID_OUNCE", "2025.1"), + (const.UnitOfArea.SQUARE_METERS, "AREA_SQUARE_METERS", "2025.12"), ], ) def test_deprecated_constant_name_changes( caplog: pytest.LogCaptureFixture, replacement: Enum, constant_name: str, + breaks_in_version: str, ) -> None: """Test deprecated constants, where the name is not the same as the enum value.""" import_and_test_deprecated_constant( @@ -197,7 +203,7 @@ def test_deprecated_constant_name_changes( constant_name, f"{replacement.__class__.__name__}.{replacement.name}", replacement, - "2025.1", + breaks_in_version, ) diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index c2c05e76ab5a4..60144f817149d 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -11,6 +11,7 @@ CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, @@ -32,6 +33,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import unit_conversion from homeassistant.util.unit_conversion import ( + AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, @@ -61,6 +63,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { converter: sorted(converter.VALID_UNITS, key=lambda x: (x is None, x)) for converter in ( + AreaConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, @@ -83,6 +86,7 @@ # Dict containing all converters with a corresponding unit ratio. _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, float]] = { + AreaConverter: (UnitOfArea.SQUARE_KILOMETERS, UnitOfArea.SQUARE_METERS, 0.000001), BloodGlucoseConcentrationConverter: ( UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, @@ -138,6 +142,62 @@ _CONVERTED_VALUE: dict[ type[BaseUnitConverter], list[tuple[float, str | None, float, str | None]] ] = { + AreaConverter: [ + # Square Meters to other units + (5, UnitOfArea.SQUARE_METERS, 50000, UnitOfArea.SQUARE_CENTIMETERS), + (5, UnitOfArea.SQUARE_METERS, 5000000, UnitOfArea.SQUARE_MILLIMETERS), + (5, UnitOfArea.SQUARE_METERS, 0.000005, UnitOfArea.SQUARE_KILOMETERS), + (5, UnitOfArea.SQUARE_METERS, 7750.015500031001, UnitOfArea.SQUARE_INCHES), + (5, UnitOfArea.SQUARE_METERS, 53.81955, UnitOfArea.SQUARE_FEET), + (5, UnitOfArea.SQUARE_METERS, 5.979950231505403, UnitOfArea.SQUARE_YARDS), + (5, UnitOfArea.SQUARE_METERS, 1.9305107927122295e-06, UnitOfArea.SQUARE_MILES), + (5, UnitOfArea.SQUARE_METERS, 0.0012355269073358272, UnitOfArea.ACRES), + (5, UnitOfArea.SQUARE_METERS, 0.0005, UnitOfArea.HECTARES), + # Square Kilometers to other units + (1, UnitOfArea.SQUARE_KILOMETERS, 1000000, UnitOfArea.SQUARE_METERS), + (1, UnitOfArea.SQUARE_KILOMETERS, 1e10, UnitOfArea.SQUARE_CENTIMETERS), + (1, UnitOfArea.SQUARE_KILOMETERS, 1e12, UnitOfArea.SQUARE_MILLIMETERS), + (5, UnitOfArea.SQUARE_KILOMETERS, 1.9305107927122296, UnitOfArea.SQUARE_MILES), + (5, UnitOfArea.SQUARE_KILOMETERS, 1235.5269073358272, UnitOfArea.ACRES), + (5, UnitOfArea.SQUARE_KILOMETERS, 500, UnitOfArea.HECTARES), + # Acres to other units + (5, UnitOfArea.ACRES, 20234.3, UnitOfArea.SQUARE_METERS), + (5, UnitOfArea.ACRES, 202342821.11999995, UnitOfArea.SQUARE_CENTIMETERS), + (5, UnitOfArea.ACRES, 20234282111.999992, UnitOfArea.SQUARE_MILLIMETERS), + (5, UnitOfArea.ACRES, 0.0202343, UnitOfArea.SQUARE_KILOMETERS), + (5, UnitOfArea.ACRES, 217800, UnitOfArea.SQUARE_FEET), + (5, UnitOfArea.ACRES, 24200.0, UnitOfArea.SQUARE_YARDS), + (5, UnitOfArea.ACRES, 0.0078125, UnitOfArea.SQUARE_MILES), + (5, UnitOfArea.ACRES, 2.02343, UnitOfArea.HECTARES), + # Hectares to other units + (5, UnitOfArea.HECTARES, 50000, UnitOfArea.SQUARE_METERS), + (5, UnitOfArea.HECTARES, 500000000, UnitOfArea.SQUARE_CENTIMETERS), + (5, UnitOfArea.HECTARES, 50000000000.0, UnitOfArea.SQUARE_MILLIMETERS), + (5, UnitOfArea.HECTARES, 0.019305107927122298, UnitOfArea.SQUARE_MILES), + (5, UnitOfArea.HECTARES, 538195.5, UnitOfArea.SQUARE_FEET), + (5, UnitOfArea.HECTARES, 59799.50231505403, UnitOfArea.SQUARE_YARDS), + (5, UnitOfArea.HECTARES, 12.355269073358272, UnitOfArea.ACRES), + # Square Miles to other units + (5, UnitOfArea.SQUARE_MILES, 12949940.551679997, UnitOfArea.SQUARE_METERS), + (5, UnitOfArea.SQUARE_MILES, 129499405516.79997, UnitOfArea.SQUARE_CENTIMETERS), + (5, UnitOfArea.SQUARE_MILES, 12949940551679.996, UnitOfArea.SQUARE_MILLIMETERS), + (5, UnitOfArea.SQUARE_MILES, 1294.9940551679997, UnitOfArea.HECTARES), + (5, UnitOfArea.SQUARE_MILES, 3200, UnitOfArea.ACRES), + # Square Yards to other units + (5, UnitOfArea.SQUARE_YARDS, 4.1806367999999985, UnitOfArea.SQUARE_METERS), + (5, UnitOfArea.SQUARE_YARDS, 41806.4, UnitOfArea.SQUARE_CENTIMETERS), + (5, UnitOfArea.SQUARE_YARDS, 4180636.7999999984, UnitOfArea.SQUARE_MILLIMETERS), + ( + 5, + UnitOfArea.SQUARE_YARDS, + 4.180636799999998e-06, + UnitOfArea.SQUARE_KILOMETERS, + ), + (5, UnitOfArea.SQUARE_YARDS, 45.0, UnitOfArea.SQUARE_FEET), + (5, UnitOfArea.SQUARE_YARDS, 6479.999999999998, UnitOfArea.SQUARE_INCHES), + (5, UnitOfArea.SQUARE_YARDS, 1.6141528925619832e-06, UnitOfArea.SQUARE_MILES), + (5, UnitOfArea.SQUARE_YARDS, 0.0010330578512396695, UnitOfArea.ACRES), + ], BloodGlucoseConcentrationConverter: [ ( 90, diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index c08555840bb07..b2c604acbcf6a 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -7,12 +7,14 @@ from homeassistant.components.sensor import DEVICE_CLASS_UNITS, SensorDeviceClass from homeassistant.const import ( ACCUMULATED_PRECIPITATION, + AREA, LENGTH, MASS, PRESSURE, TEMPERATURE, VOLUME, WIND_SPEED, + UnitOfArea, UnitOfLength, UnitOfMass, UnitOfPrecipitationDepth, @@ -44,6 +46,7 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=UnitOfLength.METERS, mass=UnitOfMass.GRAMS, @@ -57,6 +60,7 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=INVALID_UNIT, mass=UnitOfMass.GRAMS, @@ -70,6 +74,7 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=UnitOfLength.METERS, mass=UnitOfMass.GRAMS, @@ -83,6 +88,7 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=UnitOfLength.METERS, mass=UnitOfMass.GRAMS, @@ -96,6 +102,7 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=UnitOfLength.METERS, mass=INVALID_UNIT, @@ -109,6 +116,7 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=UnitOfLength.METERS, mass=UnitOfMass.GRAMS, @@ -122,6 +130,21 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=INVALID_UNIT, + area=UnitOfArea.SQUARE_METERS, + conversions={}, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, + ) + + with pytest.raises(ValueError): + UnitSystem( + SYSTEM_NAME, + accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=INVALID_UNIT, conversions={}, length=UnitOfLength.METERS, mass=UnitOfMass.GRAMS, @@ -146,6 +169,8 @@ def test_invalid_value() -> None: METRIC_SYSTEM.pressure("50Pa", UnitOfPressure.PA) with pytest.raises(TypeError): METRIC_SYSTEM.accumulated_precipitation("50mm", UnitOfLength.MILLIMETERS) + with pytest.raises(TypeError): + METRIC_SYSTEM.area("2m²", UnitOfArea.SQUARE_METERS) def test_as_dict() -> None: @@ -158,6 +183,7 @@ def test_as_dict() -> None: MASS: UnitOfMass.GRAMS, PRESSURE: UnitOfPressure.PA, ACCUMULATED_PRECIPITATION: UnitOfLength.MILLIMETERS, + AREA: UnitOfArea.SQUARE_METERS, } assert expected == METRIC_SYSTEM.as_dict() @@ -303,6 +329,29 @@ def test_accumulated_precipitation_to_imperial() -> None: ) == pytest.approx(10, abs=1e-4) +def test_area_same_unit() -> None: + """Test no conversion happens if to unit is same as from unit.""" + assert METRIC_SYSTEM.area(5, METRIC_SYSTEM.area_unit) == 5 + + +def test_area_unknown_unit() -> None: + """Test no conversion happens if unknown unit.""" + with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): + METRIC_SYSTEM.area(5, "abc") + + +def test_area_to_metric() -> None: + """Test area conversion to metric system.""" + assert METRIC_SYSTEM.area(25, METRIC_SYSTEM.area_unit) == 25 + assert round(METRIC_SYSTEM.area(10, IMPERIAL_SYSTEM.area_unit), 1) == 0.9 + + +def test_area_to_imperial() -> None: + """Test area conversion to imperial system.""" + assert IMPERIAL_SYSTEM.area(77, IMPERIAL_SYSTEM.area_unit) == 77 + assert IMPERIAL_SYSTEM.area(25, METRIC_SYSTEM.area_unit) == 269.09776041774313 + + def test_properties() -> None: """Test the unit properties are returned as expected.""" assert METRIC_SYSTEM.length_unit == UnitOfLength.KILOMETERS @@ -312,6 +361,7 @@ def test_properties() -> None: assert METRIC_SYSTEM.volume_unit == UnitOfVolume.LITERS assert METRIC_SYSTEM.pressure_unit == UnitOfPressure.PA assert METRIC_SYSTEM.accumulated_precipitation_unit == UnitOfLength.MILLIMETERS + assert METRIC_SYSTEM.area_unit == UnitOfArea.SQUARE_METERS @pytest.mark.parametrize( @@ -338,6 +388,18 @@ def test_get_unit_system_invalid(key: str) -> None: @pytest.mark.parametrize( ("device_class", "original_unit", "state_unit"), [ + # Test area conversion + (SensorDeviceClass.AREA, UnitOfArea.SQUARE_FEET, UnitOfArea.SQUARE_METERS), + ( + SensorDeviceClass.AREA, + UnitOfArea.SQUARE_INCHES, + UnitOfArea.SQUARE_CENTIMETERS, + ), + (SensorDeviceClass.AREA, UnitOfArea.SQUARE_MILES, UnitOfArea.SQUARE_KILOMETERS), + (SensorDeviceClass.AREA, UnitOfArea.SQUARE_YARDS, UnitOfArea.SQUARE_METERS), + (SensorDeviceClass.AREA, UnitOfArea.ACRES, UnitOfArea.HECTARES), + (SensorDeviceClass.AREA, UnitOfArea.SQUARE_KILOMETERS, None), + (SensorDeviceClass.AREA, "very_long", None), # Test atmospheric pressure ( SensorDeviceClass.ATMOSPHERIC_PRESSURE, @@ -495,6 +557,13 @@ def test_get_metric_converted_unit_( UNCONVERTED_UNITS_METRIC_SYSTEM = { + SensorDeviceClass.AREA: ( + UnitOfArea.SQUARE_MILLIMETERS, + UnitOfArea.SQUARE_CENTIMETERS, + UnitOfArea.SQUARE_METERS, + UnitOfArea.SQUARE_KILOMETERS, + UnitOfArea.HECTARES, + ), SensorDeviceClass.ATMOSPHERIC_PRESSURE: (UnitOfPressure.HPA,), SensorDeviceClass.DISTANCE: ( UnitOfLength.CENTIMETERS, @@ -544,6 +613,7 @@ def test_get_metric_converted_unit_( @pytest.mark.parametrize( "device_class", [ + SensorDeviceClass.AREA, SensorDeviceClass.ATMOSPHERIC_PRESSURE, SensorDeviceClass.DISTANCE, SensorDeviceClass.GAS, @@ -572,6 +642,21 @@ def test_metric_converted_units(device_class: SensorDeviceClass) -> None: @pytest.mark.parametrize( ("device_class", "original_unit", "state_unit"), [ + # Test area conversion + ( + SensorDeviceClass.AREA, + UnitOfArea.SQUARE_MILLIMETERS, + UnitOfArea.SQUARE_INCHES, + ), + ( + SensorDeviceClass.AREA, + UnitOfArea.SQUARE_CENTIMETERS, + UnitOfArea.SQUARE_INCHES, + ), + (SensorDeviceClass.AREA, UnitOfArea.SQUARE_METERS, UnitOfArea.SQUARE_FEET), + (SensorDeviceClass.AREA, UnitOfArea.SQUARE_KILOMETERS, UnitOfArea.SQUARE_MILES), + (SensorDeviceClass.AREA, UnitOfArea.HECTARES, UnitOfArea.ACRES), + (SensorDeviceClass.AREA, "very_area", None), # Test atmospheric pressure ( SensorDeviceClass.ATMOSPHERIC_PRESSURE, @@ -721,6 +806,13 @@ def test_get_us_converted_unit( UNCONVERTED_UNITS_US_SYSTEM = { + SensorDeviceClass.AREA: ( + UnitOfArea.SQUARE_FEET, + UnitOfArea.SQUARE_INCHES, + UnitOfArea.SQUARE_MILES, + UnitOfArea.SQUARE_YARDS, + UnitOfArea.ACRES, + ), SensorDeviceClass.ATMOSPHERIC_PRESSURE: (UnitOfPressure.INHG,), SensorDeviceClass.DISTANCE: ( UnitOfLength.FEET, From 23acc3161635e25753d0281cf6a4457d9998fc2a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 21 Nov 2024 16:33:43 +0100 Subject: [PATCH 0650/1070] Improve comments in ConfigEntriesFlowManager.async_finish_flow (#131175) --- homeassistant/config_entries.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 58d9c9c728f32..a33f21fa22f7b 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1482,8 +1482,6 @@ async def async_finish_flow( ) # Unload the entry before setting up the new one. - # We will remove it only after the other one is set up, - # so that device customizations are not getting lost. if existing_entry is not None and existing_entry.state.recoverable: await self.config_entries.async_unload(existing_entry.entry_id) @@ -1506,12 +1504,14 @@ async def async_finish_flow( ) if existing_entry is not None: - # Unload and remove the existing entry + # Unload and remove the existing entry, but don't clean up devices and + # entities until the new entry is added await self.config_entries._async_remove(existing_entry.entry_id) # noqa: SLF001 await self.config_entries.async_add(entry) if existing_entry is not None: # Clean up devices and entities belonging to the existing entry + # which are not present in the new entry self.config_entries._async_clean_up(existing_entry) # noqa: SLF001 result["result"] = entry From 3d499ab849234c88186ecea773b8506c22bffda6 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 21 Nov 2024 08:24:06 -0800 Subject: [PATCH 0651/1070] Dont count unrecorded time for history_stats (#126271) --- .../components/history_stats/data.py | 15 ++-- tests/components/history_stats/test_sensor.py | 79 ++++++++++--------- 2 files changed, 49 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 544e1772b0175..40cf351fd9eb3 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -176,26 +176,27 @@ def _async_compute_seconds_and_changes( # state_changes_during_period is called with include_start_time_state=True # which is the default and always provides the state at the start # of the period - previous_state_matches = ( - self._history_current_period - and self._history_current_period[0].state in self._entity_states - ) - last_state_change_timestamp = start_timestamp + previous_state_matches = False + last_state_change_timestamp = 0.0 elapsed = 0.0 - match_count = 1 if previous_state_matches else 0 + match_count = 0 # Make calculations for history_state in self._history_current_period: current_state_matches = history_state.state in self._entity_states state_change_timestamp = history_state.last_changed + if state_change_timestamp > now_timestamp: + # Shouldn't count states that are in the future + continue + if previous_state_matches: elapsed += state_change_timestamp - last_state_change_timestamp elif current_state_matches: match_count += 1 previous_state_matches = current_state_matches - last_state_change_timestamp = state_change_timestamp + last_state_change_timestamp = max(start_timestamp, state_change_timestamp) # Count time elapsed between last history state and end of measure if previous_state_matches: diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index f86c04b3e5b04..694c5c2070733 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -437,10 +437,10 @@ def _fake_states(*args, **kwargs): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() - assert hass.states.get("sensor.sensor1").state == "0.83" - assert hass.states.get("sensor.sensor2").state == "0.833333333333333" + assert hass.states.get("sensor.sensor1").state == "0.5" + assert 0.499 < float(hass.states.get("sensor.sensor2").state) < 0.501 assert hass.states.get("sensor.sensor3").state == "2" - assert hass.states.get("sensor.sensor4").state == "83.3" + assert hass.states.get("sensor.sensor4").state == "50.0" async def test_async_on_entire_period( @@ -1254,10 +1254,10 @@ def _fake_states(*args, **kwargs): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() - assert hass.states.get("sensor.sensor1").state == "0.83" - assert hass.states.get("sensor.sensor2").state == "0.833333333333333" - assert hass.states.get("sensor.sensor3").state == "2" - assert hass.states.get("sensor.sensor4").state == "41.7" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert float(hass.states.get("sensor.sensor2").state) == 0 + assert hass.states.get("sensor.sensor3").state == "0" + assert hass.states.get("sensor.sensor4").state == "0.0" past_next_update = start_time + timedelta(minutes=30) with ( @@ -1268,12 +1268,12 @@ def _fake_states(*args, **kwargs): freeze_time(past_next_update), ): async_fire_time_changed(hass, past_next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get("sensor.sensor1").state == "0.83" - assert hass.states.get("sensor.sensor2").state == "0.833333333333333" - assert hass.states.get("sensor.sensor3").state == "2" - assert hass.states.get("sensor.sensor4").state == "41.7" + assert hass.states.get("sensor.sensor1").state == "0.17" + assert 0.166 < float(hass.states.get("sensor.sensor2").state) < 0.167 + assert hass.states.get("sensor.sensor3").state == "1" + assert hass.states.get("sensor.sensor4").state == "8.3" async def test_measure_from_end_going_backwards( @@ -1355,10 +1355,10 @@ def _fake_states(*args, **kwargs): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() - assert hass.states.get("sensor.sensor1").state == "0.83" - assert hass.states.get("sensor.sensor2").state == "0.833333333333333" - assert hass.states.get("sensor.sensor3").state == "2" - assert hass.states.get("sensor.sensor4").state == "83.3" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert float(hass.states.get("sensor.sensor2").state) == 0 + assert hass.states.get("sensor.sensor3").state == "0" + assert hass.states.get("sensor.sensor4").state == "0.0" past_next_update = start_time + timedelta(minutes=30) with ( @@ -1369,12 +1369,12 @@ def _fake_states(*args, **kwargs): freeze_time(past_next_update), ): async_fire_time_changed(hass, past_next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get("sensor.sensor1").state == "0.83" - assert hass.states.get("sensor.sensor2").state == "0.833333333333333" - assert hass.states.get("sensor.sensor3").state == "2" - assert hass.states.get("sensor.sensor4").state == "83.3" + assert hass.states.get("sensor.sensor1").state == "0.17" + assert 0.166 < float(hass.states.get("sensor.sensor2").state) < 0.167 + assert hass.states.get("sensor.sensor3").state == "1" + assert 16.6 <= float(hass.states.get("sensor.sensor4").state) <= 16.7 async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None: @@ -1403,7 +1403,7 @@ def _fake_states(*args, **kwargs): "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ), - freeze_time(start_time), + freeze_time(start_time + timedelta(minutes=60)), ): await async_setup_component( hass, @@ -1455,10 +1455,10 @@ def _fake_states(*args, **kwargs): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() - assert hass.states.get("sensor.sensor1").state == "0.83" - assert hass.states.get("sensor.sensor2").state == "0.833333333333333" + assert hass.states.get("sensor.sensor1").state == "0.5" + assert 0.499 < float(hass.states.get("sensor.sensor2").state) < 0.501 assert hass.states.get("sensor.sensor3").state == "2" - assert hass.states.get("sensor.sensor4").state == "83.3" + assert hass.states.get("sensor.sensor4").state == "50.0" @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii"]) @@ -1537,18 +1537,19 @@ def _fake_states(*args, **kwargs): await hass.async_block_till_done() await async_update_entity(hass, "sensor.heatpump_compressor_today") await hass.async_block_till_done() - assert hass.states.get("sensor.heatpump_compressor_today").state == "1.83" + assert hass.states.get("sensor.heatpump_compressor_today").state == "0.5" assert ( - hass.states.get("sensor.heatpump_compressor_today2").state - == "1.83333333333333" + 0.499 + < float(hass.states.get("sensor.heatpump_compressor_today2").state) + < 0.501 ) async_fire_time_changed(hass, time_200) - await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get("sensor.heatpump_compressor_today").state == "1.83" + assert hass.states.get("sensor.heatpump_compressor_today").state == "0.5" assert ( - hass.states.get("sensor.heatpump_compressor_today2").state - == "1.83333333333333" + 0.499 + < float(hass.states.get("sensor.heatpump_compressor_today2").state) + < 0.501 ) hass.states.async_set("binary_sensor.heatpump_compressor_state", "off") await hass.async_block_till_done() @@ -1557,10 +1558,11 @@ def _fake_states(*args, **kwargs): with freeze_time(time_400): async_fire_time_changed(hass, time_400) await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get("sensor.heatpump_compressor_today").state == "1.83" + assert hass.states.get("sensor.heatpump_compressor_today").state == "0.5" assert ( - hass.states.get("sensor.heatpump_compressor_today2").state - == "1.83333333333333" + 0.499 + < float(hass.states.get("sensor.heatpump_compressor_today2").state) + < 0.501 ) hass.states.async_set("binary_sensor.heatpump_compressor_state", "on") await async_wait_recording_done(hass) @@ -1568,10 +1570,11 @@ def _fake_states(*args, **kwargs): with freeze_time(time_600): async_fire_time_changed(hass, time_600) await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get("sensor.heatpump_compressor_today").state == "3.83" + assert hass.states.get("sensor.heatpump_compressor_today").state == "2.5" assert ( - hass.states.get("sensor.heatpump_compressor_today2").state - == "3.83333333333333" + 2.499 + < float(hass.states.get("sensor.heatpump_compressor_today2").state) + < 2.501 ) rolled_to_next_day = start_of_today + timedelta(days=1) From 3474642afede2152e28cf6d48fe0c50fcb940b9b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 21 Nov 2024 17:29:59 +0100 Subject: [PATCH 0652/1070] Set up MQTT websocket_api and dump, publish actions from `async_setup` (#131170) * Set up MQTT websocket_api and dump, publish actions from `async_setup` * Follow up comments --- homeassistant/components/mqtt/__init__.py | 149 ++++++++++++---------- tests/components/mqtt/test_init.py | 37 +++++- 2 files changed, 113 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 907b1a1dd1181..bcad8747c3946 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -225,77 +225,27 @@ async def async_check_config_schema( ) from exc -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Load a config entry.""" - conf: dict[str, Any] - mqtt_data: MqttData +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the actions and websocket API for the MQTT component.""" - async def _setup_client( - client_available: asyncio.Future[bool], - ) -> tuple[MqttData, dict[str, Any]]: - """Set up the MQTT client.""" - # Fetch configuration - conf = dict(entry.data) - hass_config = await conf_util.async_hass_config_yaml(hass) - mqtt_yaml = CONFIG_SCHEMA(hass_config).get(DOMAIN, []) - await async_create_certificate_temp_files(hass, conf) - client = MQTT(hass, entry, conf) - if DOMAIN in hass.data: - mqtt_data = hass.data[DATA_MQTT] - mqtt_data.config = mqtt_yaml - mqtt_data.client = client - else: - # Initial setup - websocket_api.async_register_command(hass, websocket_subscribe) - websocket_api.async_register_command(hass, websocket_mqtt_info) - hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client) - await client.async_start(mqtt_data) - - # Restore saved subscriptions - if mqtt_data.subscriptions_to_restore: - mqtt_data.client.async_restore_tracked_subscriptions( - mqtt_data.subscriptions_to_restore - ) - mqtt_data.subscriptions_to_restore = set() - mqtt_data.reload_dispatchers.append( - entry.add_update_listener(_async_config_entry_updated) - ) - - return (mqtt_data, conf) - - client_available: asyncio.Future[bool] - if DATA_MQTT_AVAILABLE not in hass.data: - client_available = hass.data[DATA_MQTT_AVAILABLE] = hass.loop.create_future() - else: - client_available = hass.data[DATA_MQTT_AVAILABLE] - - mqtt_data, conf = await _setup_client(client_available) - platforms_used = platforms_from_config(mqtt_data.config) - platforms_used.update( - entry.domain - for entry in er.async_entries_for_config_entry( - er.async_get(hass), entry.entry_id - ) - ) - integration = async_get_loaded_integration(hass, DOMAIN) - # Preload platforms we know we are going to use so - # discovery can setup each platform synchronously - # and avoid creating a flood of tasks at startup - # while waiting for the the imports to complete - if not integration.platforms_are_loaded(platforms_used): - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS): - await integration.async_get_platforms(platforms_used) - - # Wait to connect until the platforms are loaded so - # we can be sure discovery does not have to wait for - # each platform to load when we get the flood of retained - # messages on connect - await mqtt_data.client.async_connect(client_available) + websocket_api.async_register_command(hass, websocket_subscribe) + websocket_api.async_register_command(hass, websocket_mqtt_info) async def async_publish_service(call: ServiceCall) -> None: """Handle MQTT publish service calls.""" msg_topic: str | None = call.data.get(ATTR_TOPIC) msg_topic_template: str | None = call.data.get(ATTR_TOPIC_TEMPLATE) + + if not mqtt_config_entry_enabled(hass): + raise ServiceValidationError( + translation_key="mqtt_not_setup_cannot_publish", + translation_domain=DOMAIN, + translation_placeholders={ + "topic": str(msg_topic or msg_topic_template) + }, + ) + + mqtt_data = hass.data[DATA_MQTT] payload: PublishPayloadType = call.data.get(ATTR_PAYLOAD) evaluate_payload: bool = call.data.get(ATTR_EVALUATE_PAYLOAD, False) payload_template: str | None = call.data.get(ATTR_PAYLOAD_TEMPLATE) @@ -402,6 +352,71 @@ async def finish_dump(_: datetime) -> None: } ), ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Load a config entry.""" + conf: dict[str, Any] + mqtt_data: MqttData + + async def _setup_client() -> tuple[MqttData, dict[str, Any]]: + """Set up the MQTT client.""" + # Fetch configuration + conf = dict(entry.data) + hass_config = await conf_util.async_hass_config_yaml(hass) + mqtt_yaml = CONFIG_SCHEMA(hass_config).get(DOMAIN, []) + await async_create_certificate_temp_files(hass, conf) + client = MQTT(hass, entry, conf) + if DOMAIN in hass.data: + mqtt_data = hass.data[DATA_MQTT] + mqtt_data.config = mqtt_yaml + mqtt_data.client = client + else: + # Initial setup + hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client) + await client.async_start(mqtt_data) + + # Restore saved subscriptions + if mqtt_data.subscriptions_to_restore: + mqtt_data.client.async_restore_tracked_subscriptions( + mqtt_data.subscriptions_to_restore + ) + mqtt_data.subscriptions_to_restore = set() + mqtt_data.reload_dispatchers.append( + entry.add_update_listener(_async_config_entry_updated) + ) + + return (mqtt_data, conf) + + client_available: asyncio.Future[bool] + if DATA_MQTT_AVAILABLE not in hass.data: + client_available = hass.data[DATA_MQTT_AVAILABLE] = hass.loop.create_future() + else: + client_available = hass.data[DATA_MQTT_AVAILABLE] + + mqtt_data, conf = await _setup_client() + platforms_used = platforms_from_config(mqtt_data.config) + platforms_used.update( + entry.domain + for entry in er.async_entries_for_config_entry( + er.async_get(hass), entry.entry_id + ) + ) + integration = async_get_loaded_integration(hass, DOMAIN) + # Preload platforms we know we are going to use so + # discovery can setup each platform synchronously + # and avoid creating a flood of tasks at startup + # while waiting for the the imports to complete + if not integration.platforms_are_loaded(platforms_used): + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS): + await integration.async_get_platforms(platforms_used) + + # Wait to connect until the platforms are loaded so + # we can be sure discovery does not have to wait for + # each platform to load when we get the flood of retained + # messages on connect + await mqtt_data.client.async_connect(client_available) # setup platforms and discovery async def _reload_config(call: ServiceCall) -> None: @@ -557,10 +572,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mqtt_data = hass.data[DATA_MQTT] mqtt_client = mqtt_data.client - # Unload publish and dump services. - hass.services.async_remove(DOMAIN, SERVICE_PUBLISH) - hass.services.async_remove(DOMAIN, SERVICE_DUMP) - # Stop the discovery await discovery.async_stop(hass) # Unload the platforms diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 145016751e723..2ab664f504116 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -255,6 +255,26 @@ async def test_service_call_without_topic_does_not_publish( assert not mqtt_mock.async_publish.called +async def test_service_call_mqtt_entry_does_not_publish( + hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient +) -> None: + """Test the service call if topic is missing.""" + assert await async_setup_component(hass, mqtt.DOMAIN, {}) + with pytest.raises( + ServiceValidationError, + match='Cannot publish to topic "test_topic", make sure MQTT is set up correctly', + ): + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC: "test_topic", + mqtt.ATTR_PAYLOAD: "payload", + }, + blocking=True, + ) + + # The use of a topic_template in an mqtt publish action call # has been deprecated with HA Core 2024.8.0 and will be removed with HA Core 2025.2.0 async def test_mqtt_publish_action_call_with_topic_and_topic_template_does_not_publish( @@ -1822,11 +1842,17 @@ async def async_mqtt_connected_async(status: bool) -> None: async def test_unload_config_entry( hass: HomeAssistant, - setup_with_birth_msg_client_mock: MqttMockPahoClient, + mqtt_client_mock: MqttMockPahoClient, caplog: pytest.LogCaptureFixture, ) -> None: """Test unloading the MQTT entry.""" - mqtt_client_mock = setup_with_birth_msg_client_mock + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + ) + entry.add_to_hass(hass) + + assert await async_setup_component(hass, mqtt.DOMAIN, {}) assert hass.services.has_service(mqtt.DOMAIN, "dump") assert hass.services.has_service(mqtt.DOMAIN, "publish") @@ -1843,8 +1869,8 @@ async def test_unload_config_entry( mqtt_client_mock.publish.assert_any_call("just_in_time", "published", 0, False) assert new_mqtt_config_entry.state is ConfigEntryState.NOT_LOADED await hass.async_block_till_done(wait_background_tasks=True) - assert not hass.services.has_service(mqtt.DOMAIN, "dump") - assert not hass.services.has_service(mqtt.DOMAIN, "publish") + assert hass.services.has_service(mqtt.DOMAIN, "dump") + assert hass.services.has_service(mqtt.DOMAIN, "publish") assert "No ACK from MQTT server" not in caplog.text @@ -1852,6 +1878,9 @@ async def test_publish_or_subscribe_without_valid_config_entry( hass: HomeAssistant, record_calls: MessageCallbackType ) -> None: """Test internal publish function with bad use cases.""" + assert await async_setup_component(hass, mqtt.DOMAIN, {}) + assert hass.services.has_service(mqtt.DOMAIN, "dump") + assert hass.services.has_service(mqtt.DOMAIN, "publish") with pytest.raises(HomeAssistantError): await mqtt.async_publish( hass, "some-topic", "test-payload", qos=0, retain=False, encoding=None From 9444f7aea20af82d4faba7266cc8a2a0db895f06 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 21 Nov 2024 17:30:25 +0100 Subject: [PATCH 0653/1070] Set PARALLEL_UPDATES = 0 for MQTT components as no semaphore is needed (#131174) --- homeassistant/components/mqtt/alarm_control_panel.py | 2 ++ homeassistant/components/mqtt/binary_sensor.py | 2 ++ homeassistant/components/mqtt/button.py | 2 ++ homeassistant/components/mqtt/camera.py | 2 ++ homeassistant/components/mqtt/climate.py | 2 ++ homeassistant/components/mqtt/cover.py | 2 ++ homeassistant/components/mqtt/device_tracker.py | 2 ++ homeassistant/components/mqtt/event.py | 2 ++ homeassistant/components/mqtt/fan.py | 2 ++ homeassistant/components/mqtt/humidifier.py | 2 ++ homeassistant/components/mqtt/image.py | 2 ++ homeassistant/components/mqtt/lawn_mower.py | 2 ++ homeassistant/components/mqtt/light/__init__.py | 2 ++ homeassistant/components/mqtt/lock.py | 2 ++ homeassistant/components/mqtt/notify.py | 2 ++ homeassistant/components/mqtt/number.py | 2 ++ homeassistant/components/mqtt/scene.py | 2 ++ homeassistant/components/mqtt/select.py | 2 ++ homeassistant/components/mqtt/sensor.py | 2 ++ homeassistant/components/mqtt/siren.py | 2 ++ homeassistant/components/mqtt/switch.py | 2 ++ homeassistant/components/mqtt/text.py | 2 ++ homeassistant/components/mqtt/update.py | 2 ++ homeassistant/components/mqtt/vacuum.py | 2 ++ homeassistant/components/mqtt/valve.py | 2 ++ homeassistant/components/mqtt/water_heater.py | 2 ++ 26 files changed, 52 insertions(+) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 76bac8540a495..613f665c302cb 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -35,6 +35,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + _SUPPORTED_FEATURES = { "arm_home": AlarmControlPanelEntityFeature.ARM_HOME, "arm_away": AlarmControlPanelEntityFeature.ARM_AWAY, diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 7f89a78991ac9..b49dc7aa24c05 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -43,6 +43,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + DEFAULT_NAME = "MQTT Binary sensor" CONF_OFF_DELAY = "off_delay" DEFAULT_PAYLOAD_OFF = "OFF" diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 2aac51890c1c3..8e5446b532e91 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -20,6 +20,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic +PARALLEL_UPDATES = 0 + CONF_PAYLOAD_PRESS = "payload_press" DEFAULT_NAME = "MQTT Button" DEFAULT_PAYLOAD_PRESS = "PRESS" diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index ca622defb25d3..88fabad044601 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -27,6 +27,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + CONF_IMAGE_ENCODING = "image_encoding" DEFAULT_NAME = "MQTT Camera" diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index dd3efa4054b7e..2419e3f32ac57 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -91,6 +91,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + DEFAULT_NAME = "MQTT HVAC" CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template" diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 0b495663803d7..c7d041848f047 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -69,6 +69,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + CONF_GET_POSITION_TOPIC = "position_topic" CONF_GET_POSITION_TEMPLATE = "position_template" CONF_SET_POSITION_TOPIC = "set_position_topic" diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index b87db40ccf73d..bdf543e046a3d 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -36,6 +36,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + CONF_PAYLOAD_HOME = "payload_home" CONF_PAYLOAD_NOT_HOME = "payload_not_home" CONF_SOURCE_TYPE = "source_type" diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index 3f67891ca5eb1..d9812aaaf4852 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -38,6 +38,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + CONF_EVENT_TYPES = "event_types" MQTT_EVENT_ATTRIBUTES_BLOCKED = frozenset( diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 70187ee9eb1e0..b3c0f22789c46 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -57,6 +57,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic +PARALLEL_UPDATES = 0 + CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" CONF_DIRECTION_VALUE_TEMPLATE = "direction_value_template" diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 304d293de7982..5d1af03ad249a 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -59,6 +59,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic +PARALLEL_UPDATES = 0 + CONF_AVAILABLE_MODES_LIST = "modes" CONF_DEVICE_CLASS = "device_class" CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 6ecdee0648926..4b7b2d783d26f 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -37,6 +37,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + CONF_CONTENT_TYPE = "content_type" CONF_IMAGE_ENCODING = "image_encoding" CONF_IMAGE_TOPIC = "image_topic" diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 11afe4220c4b3..87577c4b4d904 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -38,6 +38,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + CONF_ACTIVITY_STATE_TOPIC = "activity_state_topic" CONF_ACTIVITY_VALUE_TEMPLATE = "activity_value_template" CONF_DOCK_COMMAND_TOPIC = "dock_command_topic" diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index a1ba955181dc2..328f80cb5ea71 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -30,6 +30,8 @@ MqttLightTemplate, ) +PARALLEL_UPDATES = 0 + def validate_mqtt_light_discovery(config_value: dict[str, Any]) -> ConfigType: """Validate MQTT light schema for discovery.""" diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index e58d15b659dcb..2113dbbd5ba9a 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -45,6 +45,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + CONF_CODE_FORMAT = "code_format" CONF_PAYLOAD_LOCK = "payload_lock" diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py index 4a5ccc0277471..84442e75e7318 100644 --- a/homeassistant/components/mqtt/notify.py +++ b/homeassistant/components/mqtt/notify.py @@ -20,6 +20,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic +PARALLEL_UPDATES = 0 + DEFAULT_NAME = "MQTT notify" PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 895334f2e1e35..a9bf1829b6392 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -50,6 +50,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + CONF_MIN = "min" CONF_MAX = "max" CONF_STEP = "step" diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index dad596d9c4f62..314bd716ee0b0 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -21,6 +21,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic +PARALLEL_UPDATES = 0 + DEFAULT_NAME = "MQTT Scene" DEFAULT_RETAIN = False diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 37d3287988fe9..55d56ecd77407 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -37,6 +37,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + DEFAULT_NAME = "MQTT Select" MQTT_SELECT_ATTRIBUTES_BLOCKED = frozenset( diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 17ea0ab1f5be3..bacbf4d323ef7 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -47,6 +47,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + CONF_EXPIRE_AFTER = "expire_after" CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 1937b60fde065..22f64053d23a0 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -55,6 +55,8 @@ ) from .schemas import MQTT_ENTITY_COMMON_SCHEMA +PARALLEL_UPDATES = 0 + DEFAULT_NAME = "MQTT Siren" DEFAULT_PAYLOAD_ON = "ON" DEFAULT_PAYLOAD_OFF = "OFF" diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index a73c4fe53f8c5..c90174e8a0122 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -43,6 +43,8 @@ ) from .schemas import MQTT_ENTITY_COMMON_SCHEMA +PARALLEL_UPDATES = 0 + DEFAULT_NAME = "MQTT Switch" DEFAULT_PAYLOAD_ON = "ON" DEFAULT_PAYLOAD_OFF = "OFF" diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index edfecfbc03861..b4ed33a77300a 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -40,6 +40,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + CONF_MAX = "max" CONF_MIN = "min" CONF_PATTERN = "pattern" diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 8878ff6312753..99b4e5cb821df 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -32,6 +32,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + DEFAULT_NAME = "MQTT Update" CONF_DISPLAY_PRECISION = "display_precision" diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 86b32aa281bbc..ac6dca3cbbc46 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -39,6 +39,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic +PARALLEL_UPDATES = 0 + BATTERY = "battery_level" FAN_SPEED = "fan_speed" STATE = "state" diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 00d3d7d79bdf6..50c5960f801c3 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -63,6 +63,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + CONF_REPORTS_POSITION = "reports_position" DEFAULT_NAME = "MQTT Valve" diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index b98d73e0bfeb6..4c1d3fa8a53fb 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -72,6 +72,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + DEFAULT_NAME = "MQTT Water Heater" MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED = frozenset( From 3550d5838d388a6caee9b66e9b5c2aded04f292e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Nov 2024 10:35:42 -0600 Subject: [PATCH 0654/1070] Bump yarl to 1.18.0 (#131183) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 405383aec15d4..57e0e277c662f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,7 +70,7 @@ voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 -yarl==1.17.2 +yarl==1.18.0 zeroconf==0.136.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 2bcc058f0a53e..34296b2bb6c2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.17.2", + "yarl==1.18.0", "webrtc-models==0.3.0", ] diff --git a/requirements.txt b/requirements.txt index 30ce1d0b6f3dd..c8a49a622320a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,5 +47,5 @@ uv==0.5.0 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.17.2 +yarl==1.18.0 webrtc-models==0.3.0 From fd392eea317a695d721fbd0f78e0d9423bf29a98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Nov 2024 10:36:36 -0600 Subject: [PATCH 0655/1070] Bump aiohttp to 3.11.7 (#131188) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 57e0e277c662f..36272507dc8c9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.2.0 -aiohttp==3.11.6 +aiohttp==3.11.7 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 34296b2bb6c2a..6883c3a213972 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.6", + "aiohttp==3.11.7", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index c8a49a622320a..039d85dca6c13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.6 +aiohttp==3.11.7 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 aiozoneinfo==0.2.1 From bd0a2b6f68699aba07ea18823d81e25b4dcf5e60 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 21 Nov 2024 17:42:12 +0100 Subject: [PATCH 0656/1070] Add unit translations for KNX integration (#131176) * Add unit translations for KNX integration * re-use values --- homeassistant/components/knx/strings.json | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 8d8692f6b7aa0..08b921f316ba3 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -294,19 +294,24 @@ "name": "Connection type" }, "telegrams_incoming": { - "name": "Incoming telegrams" + "name": "Incoming telegrams", + "unit_of_measurement": "[%key:component::knx::entity::sensor::telegram_count::unit_of_measurement%]" }, "telegrams_incoming_error": { - "name": "Incoming telegram errors" + "name": "Incoming telegram errors", + "unit_of_measurement": "errors" }, "telegrams_outgoing": { - "name": "Outgoing telegrams" + "name": "Outgoing telegrams", + "unit_of_measurement": "[%key:component::knx::entity::sensor::telegram_count::unit_of_measurement%]" }, "telegrams_outgoing_error": { - "name": "Outgoing telegram errors" + "name": "Outgoing telegram errors", + "unit_of_measurement": "[%key:component::knx::entity::sensor::telegrams_incoming_error::unit_of_measurement%]" }, "telegram_count": { - "name": "Telegrams" + "name": "Telegrams", + "unit_of_measurement": "telegrams" } } }, From 9bbf9be95f29b982476d4458bc36c460a58f66a2 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 21 Nov 2024 17:47:22 +0100 Subject: [PATCH 0657/1070] Add optional flag to bypass the media proxy in esphome media players (#131191) * Add optional flag to play_media to bypass media proxy * use constants * add test --- .../components/esphome/media_player.py | 7 ++++++- tests/components/esphome/test_media_player.py | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 3930b71d10602..8a30814aa2c1e 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -20,6 +20,7 @@ from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_EXTRA, BrowseMedia, MediaPlayerDeviceClass, MediaPlayerEntity, @@ -50,6 +51,8 @@ } ) +ATTR_BYPASS_PROXY = "bypass_proxy" + class EsphomeMediaPlayer( EsphomeEntity[MediaPlayerInfo, MediaPlayerEntityState], MediaPlayerEntity @@ -108,13 +111,15 @@ async def async_play_media( media_id = async_process_play_media_url(self.hass, media_id) announcement = kwargs.get(ATTR_MEDIA_ANNOUNCE) + bypass_proxy = kwargs.get(ATTR_MEDIA_EXTRA, {}).get(ATTR_BYPASS_PROXY) supported_formats: list[MediaPlayerSupportedFormat] | None = ( self._entry_data.media_player_formats.get(self._static_info.unique_id) ) if ( - supported_formats + not bypass_proxy + and supported_formats and _is_url(media_id) and ( proxy_url := self._get_proxy_url( diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 799666fc66e27..42b7e72a06e00 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -22,6 +22,7 @@ ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_EXTRA, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MEDIA_PLAYER_DOMAIN, @@ -414,3 +415,22 @@ async def test_media_player_proxy( media_args = mock_client.media_player_command.call_args.kwargs assert media_args["announcement"] + + # test with bypass_proxy flag + mock_async_create_proxy_url.reset_mock() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: media_url, + ATTR_MEDIA_EXTRA: { + "bypass_proxy": True, + }, + }, + blocking=True, + ) + mock_async_create_proxy_url.assert_not_called() + media_args = mock_client.media_player_command.call_args.kwargs + assert media_args["media_url"] == media_url From bd3352c1f07715560a5c19c3fb16584847634ccc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 18:53:23 +0100 Subject: [PATCH 0658/1070] Fix two strings for the Generic hygrostat UI (#131185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix two strings for the Generic hygrostat UI The 'min_cycle_duration' key shown when setting up a Generic hygrostat contains the words "in the humidifier option" which makes no sense anymore in the UI context. So these words can be dropped, making this explanation exactly identical to the same key in the Generic thermostat. In addition there is an "s" missing in the main description here. * Replace "be" with "remain" and use "toggle" Co-authored-by: Jan Bouwhuis * Fix the title string to use "Create" instead of "Add" Anything the user creates should use "Create …", not "Add …". The description we're addressing in the fix already has this correct. This commit adds the fix for the title. Makes it consistent with the majority of Helpers, rest needs to be addressed in separate PRs. --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/generic_hygrostat/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/generic_hygrostat/strings.json b/homeassistant/components/generic_hygrostat/strings.json index 2be3955eff1ee..7b8d56dbaa5e6 100644 --- a/homeassistant/components/generic_hygrostat/strings.json +++ b/homeassistant/components/generic_hygrostat/strings.json @@ -3,8 +3,8 @@ "config": { "step": { "user": { - "title": "Add generic hygrostat", - "description": "Create a humidifier entity that control the humidity via a switch and sensor.", + "title": "Create generic hygrostat", + "description": "Create a humidifier entity that controls the humidity via a switch and sensor.", "data": { "device_class": "Device class", "dry_tolerance": "Dry tolerance", @@ -17,7 +17,7 @@ "data_description": { "dry_tolerance": "The minimum amount of difference between the humidity read by the sensor specified in the target sensor option and the target humidity that must change prior to being switched on.", "humidifier": "Humidifier or dehumidifier switch; must be a toggle device.", - "min_cycle_duration": "Set a minimum amount of time that the switch specified in the humidifier option must be in its current state prior to being switched either off or on.", + "min_cycle_duration": "Set a minimum duration for which the specified switch must remain in its current state before it can be toggled off or on.", "target_sensor": "Sensor with current humidity.", "wet_tolerance": "The minimum amount of difference between the humidity read by the sensor specified in the target sensor option and the target humidity that must change prior to being switched off." } From 5fa4739e2dfa5a030a919f7f691405720386d8cc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 21 Nov 2024 19:15:37 +0100 Subject: [PATCH 0659/1070] Bump securetar to 2024.11.0 (#131172) --- homeassistant/components/backup/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 1ec9b748cdadf..0a906bb6dfaba 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["securetar==2024.2.1"] + "requirements": ["securetar==2024.11.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 36272507dc8c9..6847dc612e515 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -58,7 +58,7 @@ PyTurboJPEG==1.7.5 pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 -securetar==2024.2.1 +securetar==2024.11.0 SQLAlchemy==2.0.31 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' diff --git a/pyproject.toml b/pyproject.toml index 6883c3a213972..235c18c621393 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.2", "requests==2.32.3", - "securetar==2024.2.1", + "securetar==2024.11.0", "SQLAlchemy==2.0.31", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", diff --git a/requirements.txt b/requirements.txt index 039d85dca6c13..edfb611f8aa5f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,7 +36,7 @@ psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 -securetar==2024.2.1 +securetar==2024.11.0 SQLAlchemy==2.0.31 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' diff --git a/requirements_all.txt b/requirements_all.txt index 6db23700d9403..d46775023ed59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2625,7 +2625,7 @@ screenlogicpy==0.10.0 scsgate==0.1.0 # homeassistant.components.backup -securetar==2024.2.1 +securetar==2024.11.0 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a897626d67012..afd363a674abb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2095,7 +2095,7 @@ sanix==1.0.6 screenlogicpy==0.10.0 # homeassistant.components.backup -securetar==2024.2.1 +securetar==2024.11.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense From 1195a794797546160e91858cc5fcc0973afe004a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 21 Nov 2024 19:21:57 +0100 Subject: [PATCH 0660/1070] Add PARALLEL_UPDATES = 0 to Reolink platforms (#131165) --- homeassistant/components/reolink/binary_sensor.py | 2 ++ homeassistant/components/reolink/button.py | 1 + homeassistant/components/reolink/camera.py | 1 + homeassistant/components/reolink/light.py | 2 ++ homeassistant/components/reolink/number.py | 2 ++ homeassistant/components/reolink/select.py | 1 + homeassistant/components/reolink/sensor.py | 2 ++ homeassistant/components/reolink/siren.py | 2 ++ homeassistant/components/reolink/switch.py | 2 ++ homeassistant/components/reolink/update.py | 1 + 10 files changed, 16 insertions(+) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 38132d299417e..c59c1e7785f53 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -28,6 +28,8 @@ from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription from .util import ReolinkConfigEntry, ReolinkData +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class ReolinkBinarySensorEntityDescription( diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 986ac9d872cea..8863ef9f9a9e7 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -33,6 +33,7 @@ ) from .util import ReolinkConfigEntry, ReolinkData +PARALLEL_UPDATES = 0 ATTR_SPEED = "speed" SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM SERVICE_PTZ_MOVE = "ptz_move" diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 600286be9a230..26ef0b0f4fcd5 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -21,6 +21,7 @@ from .util import ReolinkConfigEntry, ReolinkData _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index 0f239a3081385..3bd9a120798d4 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -28,6 +28,8 @@ ) from .util import ReolinkConfigEntry, ReolinkData +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class ReolinkLightEntityDescription( diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 8ce568d4bd0fb..692b43bca9e84 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -29,6 +29,8 @@ ) from .util import ReolinkConfigEntry, ReolinkData +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class ReolinkNumberEntityDescription( diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index a444997a907de..6f8a072264da0 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -35,6 +35,7 @@ from .util import ReolinkConfigEntry, ReolinkData _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 337bf9cc29cea..36900da99ca2d 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -29,6 +29,8 @@ ) from .util import ReolinkConfigEntry, ReolinkData +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class ReolinkSensorEntityDescription( diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index 45f435c1f2c89..cb12eb5d38c92 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -21,6 +21,8 @@ from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription from .util import ReolinkConfigEntry, ReolinkData +PARALLEL_UPDATES = 0 + @dataclass(frozen=True) class ReolinkSirenEntityDescription( diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 482cdab18a7cc..c274609599df8 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -27,6 +27,8 @@ ) from .util import ReolinkConfigEntry, ReolinkData +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class ReolinkSwitchEntityDescription( diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 33e446e8b2520..73d2e53673d6d 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -32,6 +32,7 @@ ) from .util import ReolinkConfigEntry, ReolinkData +PARALLEL_UPDATES = 0 RESUME_AFTER_INSTALL = 15 POLL_AFTER_INSTALL = 120 POLL_PROGRESS = 2 From ba042e2325418a4009b80c54b59024489cf0a1ea Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 22 Nov 2024 07:42:08 +1300 Subject: [PATCH 0661/1070] Fix typo in ESPHome repair text (#131200) --- homeassistant/components/esphome/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 18a54772e3038..971a489a9e24a 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -119,7 +119,7 @@ }, "service_calls_not_allowed": { "title": "{name} is not permitted to perform Home Assistant actions", - "description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perfom Home Assistant action, you can enable this functionality in the options flow." + "description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perform Home Assistant action, you can enable this functionality in the options flow." } } } From 1020d75b94a4b628f63fe1eb3e4f1457989c9ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 21 Nov 2024 20:21:00 +0100 Subject: [PATCH 0662/1070] Bump AEMET-OpenData to v0.6.2 (#131178) --- homeassistant/components/aemet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index 3696e16b437ca..d4b0db6193351 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aemet", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], - "requirements": ["AEMET-OpenData==0.5.4"] + "requirements": ["AEMET-OpenData==0.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index d46775023ed59..be70978ce45e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -4,7 +4,7 @@ -r requirements.txt # homeassistant.components.aemet -AEMET-OpenData==0.5.4 +AEMET-OpenData==0.6.2 # homeassistant.components.honeywell AIOSomecomfort==0.0.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index afd363a674abb..bf9824b701242 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,7 +4,7 @@ -r requirements_test.txt # homeassistant.components.aemet -AEMET-OpenData==0.5.4 +AEMET-OpenData==0.6.2 # homeassistant.components.honeywell AIOSomecomfort==0.0.25 From 3c96c559dc52304d74d8b3b9ee7f6e3813a5b034 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 21 Nov 2024 20:42:36 +0100 Subject: [PATCH 0663/1070] Remove config entry unique id from trafikverket_train (#130989) --- .../components/trafikverket_train/__init__.py | 25 ++++++ .../trafikverket_train/config_flow.py | 16 ++-- .../components/trafikverket_train/util.py | 13 +--- .../components/trafikverket_train/conftest.py | 6 +- .../snapshots/test_init.ambr | 2 +- .../snapshots/test_sensor.ambr | 2 +- .../trafikverket_train/test_config_flow.py | 18 +++-- .../trafikverket_train/test_init.py | 78 ++++++++++++++++++- 8 files changed, 129 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 3e807df930140..23aee50d81616 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -11,6 +13,8 @@ TVTrainConfigEntry = ConfigEntry[TVDataUpdateCoordinator] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool: """Set up Trafikverket Train from a config entry.""" @@ -42,3 +46,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool: + """Migrate config entry.""" + _LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if entry.version == 1 and entry.minor_version == 1: + # Remove unique id + hass.config_entries.async_update_entry(entry, unique_id=None, minor_version=2) + + _LOGGER.debug( + "Migration to version %s.%s successful", + entry.version, + entry.minor_version, + ) + + return True diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index f498a7b0d0e46..298e6a44f2d2e 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -37,7 +37,7 @@ import homeassistant.util.dt as dt_util from .const import CONF_FILTER_PRODUCT, CONF_FROM, CONF_TIME, CONF_TO, DOMAIN -from .util import create_unique_id, next_departuredate +from .util import next_departuredate _LOGGER = logging.getLogger(__name__) @@ -125,6 +125,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Train integration.""" VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback @@ -202,11 +203,16 @@ async def async_step_user( filter_product, ) if not errors: - unique_id = create_unique_id( - train_from, train_to, train_time, train_days + self._async_abort_entries_match( + { + CONF_API_KEY: api_key, + CONF_FROM: train_from, + CONF_TO: train_to, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + CONF_FILTER_PRODUCT: filter_product, + } ) - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() return self.async_create_entry( title=name, data={ diff --git a/homeassistant/components/trafikverket_train/util.py b/homeassistant/components/trafikverket_train/util.py index 9648436f1e5ca..9a8dd9ea237cc 100644 --- a/homeassistant/components/trafikverket_train/util.py +++ b/homeassistant/components/trafikverket_train/util.py @@ -2,22 +2,11 @@ from __future__ import annotations -from datetime import date, time, timedelta +from datetime import date, timedelta from homeassistant.const import WEEKDAYS -def create_unique_id( - from_station: str, to_station: str, depart_time: time | str | None, weekdays: list -) -> str: - """Create unique id.""" - timestr = str(depart_time) if depart_time else "" - return ( - f"{from_station.casefold().replace(' ', '')}-{to_station.casefold().replace(' ', '')}" - f"-{timestr.casefold().replace(' ', '')}-{weekdays!s}" - ) - - def next_weekday(fromdate: date, weekday: int) -> date: """Return the date of the next time a specific weekday happen.""" days_ahead = weekday - fromdate.weekday() diff --git a/tests/components/trafikverket_train/conftest.py b/tests/components/trafikverket_train/conftest.py index 14671d2725224..2fa5d099ee9d2 100644 --- a/tests/components/trafikverket_train/conftest.py +++ b/tests/components/trafikverket_train/conftest.py @@ -50,7 +50,8 @@ async def setup_config_entry_with_mocked_data(config_entry_id: str) -> None: data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - unique_id="stockholmc-uppsalac--['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", + version=1, + minor_version=2, ) config_entry.add_to_hass(hass) await setup_config_entry_with_mocked_data(config_entry.entry_id) @@ -60,7 +61,8 @@ async def setup_config_entry_with_mocked_data(config_entry_id: str) -> None: source=SOURCE_USER, data=ENTRY_CONFIG2, entry_id="2", - unique_id="stockholmc-uppsalac-1100-['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", + version=1, + minor_version=2, ) config_entry2.add_to_hass(hass) await setup_config_entry_with_mocked_data(config_entry2.entry_id) diff --git a/tests/components/trafikverket_train/snapshots/test_init.ambr b/tests/components/trafikverket_train/snapshots/test_init.ambr index c32995fdb7689..2b3693eddc126 100644 --- a/tests/components/trafikverket_train/snapshots/test_init.ambr +++ b/tests/components/trafikverket_train/snapshots/test_init.ambr @@ -7,7 +7,7 @@ 'title_placeholders': dict({ 'name': 'Mock Title', }), - 'unique_id': '321', + 'unique_id': None, }), 'flow_id': , 'handler': 'trafikverket_train', diff --git a/tests/components/trafikverket_train/snapshots/test_sensor.ambr b/tests/components/trafikverket_train/snapshots/test_sensor.ambr index cae0457bbffbc..6caf1f86b51ee 100644 --- a/tests/components/trafikverket_train/snapshots/test_sensor.ambr +++ b/tests/components/trafikverket_train/snapshots/test_sensor.ambr @@ -222,7 +222,7 @@ 'title_placeholders': dict({ 'name': 'Mock Title', }), - 'unique_id': "stockholmc-uppsalac--['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", + 'unique_id': None, }), 'flow_id': , 'handler': 'trafikverket_train', diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index 9fe02994f0509..d75ad5d1b468e 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -16,6 +16,7 @@ from homeassistant import config_entries from homeassistant.components.trafikverket_train.const import ( + CONF_FILTER_PRODUCT, CONF_FROM, CONF_TIME, CONF_TO, @@ -73,7 +74,6 @@ async def test_form(hass: HomeAssistant) -> None: } assert result["options"] == {"filter_product": None} assert len(mock_setup_entry.mock_calls) == 1 - assert result["result"].unique_id == "stockholmc-uppsalac-10:00-['mon', 'fri']" async def test_form_entry_already_exist(hass: HomeAssistant) -> None: @@ -88,8 +88,10 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: CONF_TO: "Uppsala C", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, + CONF_FILTER_PRODUCT: None, }, - unique_id=f"stockholmc-uppsalac-10:00-{WEEKDAYS}", + version=1, + minor_version=2, ) entry.add_to_hass(hass) @@ -240,7 +242,8 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - unique_id=f"stockholmc-uppsalac-10:00-{WEEKDAYS}", + version=1, + minor_version=2, ) entry.add_to_hass(hass) @@ -314,7 +317,8 @@ async def test_reauth_flow_error( CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - unique_id=f"stockholmc-uppsalac-10:00-{WEEKDAYS}", + version=1, + minor_version=2, ) entry.add_to_hass(hass) @@ -396,7 +400,8 @@ async def test_reauth_flow_error_departures( CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - unique_id=f"stockholmc-uppsalac-10:00-{WEEKDAYS}", + version=1, + minor_version=2, ) entry.add_to_hass(hass) @@ -467,7 +472,8 @@ async def test_options_flow( CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - unique_id=f"stockholmc-uppsalac-10:00-{WEEKDAYS}", + version=1, + minor_version=2, ) entry.add_to_hass(hass) diff --git a/tests/components/trafikverket_train/test_init.py b/tests/components/trafikverket_train/test_init.py index c8fea174e830b..972e6bffbb4d7 100644 --- a/tests/components/trafikverket_train/test_init.py +++ b/tests/components/trafikverket_train/test_init.py @@ -28,7 +28,8 @@ async def test_unload_entry( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - unique_id="321", + version=1, + minor_version=2, ) entry.add_to_hass(hass) @@ -64,7 +65,8 @@ async def test_auth_failed( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - unique_id="321", + version=1, + minor_version=2, ) entry.add_to_hass(hass) @@ -94,7 +96,8 @@ async def test_no_stations( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - unique_id="321", + version=1, + minor_version=2, ) entry.add_to_hass(hass) @@ -121,7 +124,8 @@ async def test_migrate_entity_unique_id( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - unique_id="321", + version=1, + minor_version=2, ) entry.add_to_hass(hass) @@ -149,3 +153,69 @@ async def test_migrate_entity_unique_id( entity = entity_registry.async_get(entity.entity_id) assert entity.unique_id == f"{entry.entry_id}-departure_time" + + +async def test_migrate_entry( + hass: HomeAssistant, + get_trains: list[TrainStopModel], +) -> None: + """Test migrate entry unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + version=1, + minor_version=1, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station", + ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id is None + + +async def test_migrate_entry_from_future_version_fails( + hass: HomeAssistant, + get_trains: list[TrainStopModel], +) -> None: + """Test migrate entry from future version fails.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + version=2, + entry_id="1", + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station", + ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR From 1ab2bbe3b03e0434c30d0c1fbdb9710509a979c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 21 Nov 2024 20:45:43 +0100 Subject: [PATCH 0664/1070] Don't save Home Assistant device ID at Home Connect device (#131013) --- .../components/home_connect/__init__.py | 33 ++++++++++--------- .../home_connect/test_diagnostics.py | 5 +-- tests/components/home_connect/test_init.py | 2 +- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 175545c9665c1..c05b04a2c24f5 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -6,7 +6,6 @@ import logging from typing import Any -from homeconnect.api import HomeConnectAppliance from requests import HTTPError import voluptuous as vol @@ -92,12 +91,27 @@ def _get_appliance_by_device_id( hass: HomeAssistant, device_id: str -) -> HomeConnectAppliance: +) -> api.HomeConnectAppliance: """Return a Home Connect appliance instance given an device_id.""" + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + assert device_entry + + ha_id = next( + ( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + None, + ) + assert ha_id + for hc_api in hass.data[DOMAIN].values(): for device in hc_api.devices: - if device.device_id == device_id: - return device.appliance + appliance = device.appliance + if appliance.haId == ha_id: + return appliance raise ValueError(f"Appliance for device id {device_id} not found") @@ -259,20 +273,9 @@ async def update_all_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: data = hass.data[DOMAIN] hc_api = data[entry.entry_id] - device_registry = dr.async_get(hass) try: await hass.async_add_executor_job(hc_api.get_devices) for device in hc_api.devices: - device_entry = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, device.appliance.haId)}, - name=device.appliance.name, - manufacturer=device.appliance.brand, - model=device.appliance.vib, - ) - - device.device_id = device_entry.id - await hass.async_add_executor_job(device.initialize) except HTTPError as err: _LOGGER.warning("Cannot update devices: %s", err.response.status_code) diff --git a/tests/components/home_connect/test_diagnostics.py b/tests/components/home_connect/test_diagnostics.py index a56ccef360fd0..e391580459979 100644 --- a/tests/components/home_connect/test_diagnostics.py +++ b/tests/components/home_connect/test_diagnostics.py @@ -54,8 +54,9 @@ async def test_async_get_device_diagnostics( assert await integration_setup() assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device( - identifiers={(DOMAIN, "SIEMENS-HCS02DWH1-6BE58C26DCC1")} + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "SIEMENS-HCS02DWH1-6BE58C26DCC1")}, ) assert await async_get_device_diagnostics(hass, config_entry, device) == snapshot diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 52550d705a9ee..849e93e33d234 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -305,7 +305,7 @@ async def test_services_exception( service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" - with pytest.raises(ValueError): + with pytest.raises(AssertionError): await hass.services.async_call(**service_call) From 797eb606fe0cb40cccaaa06c3cbed9d622ce1934 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 21 Nov 2024 20:46:03 +0100 Subject: [PATCH 0665/1070] Fix correct handling in ManualTriggerEntity (#130135) --- homeassistant/components/command_line/sensor.py | 6 ++---- homeassistant/helpers/trigger_template_entity.py | 12 +++++------- tests/components/command_line/test_binary_sensor.py | 12 ++++++++++-- tests/components/command_line/test_cover.py | 12 +++++++++--- tests/components/command_line/test_switch.py | 6 +++--- 5 files changed, 29 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 7c31af165f995..e4c1370d5f7bf 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -187,13 +187,11 @@ async def _async_update(self) -> None: SensorDeviceClass.TIMESTAMP, }: self._attr_native_value = value - self._process_manual_data(value) - return - - if value is not None: + elif value is not None: self._attr_native_value = async_parse_date_datetime( value, self.entity_id, self.device_class ) + self._process_manual_data(value) self.async_write_ha_state() diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index 7f8ad41d7bb5e..1486e33d6fa69 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -30,7 +30,7 @@ from . import config_validation as cv from .entity import Entity -from .template import render_complex +from .template import TemplateStateFromEntityId, render_complex from .typing import ConfigType CONF_AVAILABILITY = "availability" @@ -231,16 +231,14 @@ def _process_manual_data(self, value: Any | None = None) -> None: Ex: self._process_manual_data(payload) """ - self.async_write_ha_state() - this = None - if state := self.hass.states.get(self.entity_id): - this = state.as_dict() - run_variables: dict[str, Any] = {"value": value} # Silently try if variable is a json and store result in `value_json` if it is. with contextlib.suppress(*JSON_DECODE_EXCEPTIONS): run_variables["value_json"] = json_loads(run_variables["value"]) - variables = {"this": this, **(run_variables or {})} + variables = { + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **(run_variables or {}), + } self._render_templates(variables) diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 5d1cd845e271f..aa49410aacbb7 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -87,7 +87,7 @@ async def test_setup_platform_yaml(hass: HomeAssistant) -> None: "payload_off": "0", "value_template": "{{ value | multiply(0.1) }}", "icon": ( - '{% if this.state=="on" %} mdi:on {% else %} mdi:off {% endif %}' + '{% if this.attributes.icon=="mdi:icon2" %} mdi:icon1 {% else %} mdi:icon2 {% endif %}' ), } } @@ -101,7 +101,15 @@ async def test_template(hass: HomeAssistant, load_yaml_integration: None) -> Non entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_ON - assert entity_state.attributes.get("icon") == "mdi:on" + assert entity_state.attributes.get("icon") == "mdi:icon2" + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("binary_sensor.test") + assert entity_state + assert entity_state.state == STATE_ON + assert entity_state.attributes.get("icon") == "mdi:icon1" @pytest.mark.parametrize( diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index da9d86ba8a518..426968eccc51a 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -422,13 +422,19 @@ async def test_icon_template(hass: HomeAssistant) -> None: "command_close": f"echo 0 > {path}", "command_stop": f"echo 0 > {path}", "name": "Test", - "icon": "{% if this.state=='open' %} mdi:open {% else %} mdi:closed {% endif %}", + "icon": '{% if this.attributes.icon=="mdi:icon2" %} mdi:icon1 {% else %} mdi:icon2 {% endif %}', } } ] }, ) await hass.async_block_till_done() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) await hass.services.async_call( COVER_DOMAIN, @@ -438,7 +444,7 @@ async def test_icon_template(hass: HomeAssistant) -> None: ) entity_state = hass.states.get("cover.test") assert entity_state - assert entity_state.attributes.get("icon") == "mdi:closed" + assert entity_state.attributes.get("icon") == "mdi:icon1" await hass.services.async_call( COVER_DOMAIN, @@ -448,4 +454,4 @@ async def test_icon_template(hass: HomeAssistant) -> None: ) entity_state = hass.states.get("cover.test") assert entity_state - assert entity_state.attributes.get("icon") == "mdi:open" + assert entity_state.attributes.get("icon") == "mdi:icon2" diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 549e729892c9d..d62410fa7927d 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -552,7 +552,7 @@ async def test_templating(hass: HomeAssistant) -> None: "command_off": f"echo 0 > {path}", "value_template": '{{ value=="1" }}', "icon": ( - '{% if this.state=="on" %} mdi:on {% else %} mdi:off {% endif %}' + '{% if this.attributes.icon=="mdi:icon2" %} mdi:icon1 {% else %} mdi:icon2 {% endif %}' ), "name": "Test", } @@ -564,7 +564,7 @@ async def test_templating(hass: HomeAssistant) -> None: "command_off": f"echo 0 > {path}", "value_template": '{{ value=="1" }}', "icon": ( - '{% if states("switch.test2")=="on" %} mdi:on {% else %} mdi:off {% endif %}' + '{% if states("switch.test")=="off" %} mdi:off {% else %} mdi:on {% endif %}' ), "name": "Test2", }, @@ -595,7 +595,7 @@ async def test_templating(hass: HomeAssistant) -> None: entity_state = hass.states.get("switch.test") entity_state2 = hass.states.get("switch.test2") assert entity_state.state == STATE_ON - assert entity_state.attributes.get("icon") == "mdi:on" + assert entity_state.attributes.get("icon") == "mdi:icon2" assert entity_state2.state == STATE_ON assert entity_state2.attributes.get("icon") == "mdi:on" From 3cfd958dc24db69b3f0b91eca709bb9e6bb23581 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 21 Nov 2024 20:47:24 +0100 Subject: [PATCH 0666/1070] Allow mL/s as UnitOfVolumeFlowRate (#130771) --- homeassistant/components/number/const.py | 2 +- homeassistant/components/sensor/const.py | 2 +- homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 3 +++ tests/util/test_unit_conversion.py | 12 ++++++++++++ 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 3f29dd0416601..7330b781e755d 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -404,7 +404,7 @@ class NumberDeviceClass(StrEnum): """Generic flow rate Unit of measurement: UnitOfVolumeFlowRate - - SI / metric: `m³/h`, `L/min` + - SI / metric: `m³/h`, `L/min`, `mL/s` - USCS / imperial: `ft³/min`, `gal/min` """ diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index c290a627b0700..87012c3631a25 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -427,7 +427,7 @@ class SensorDeviceClass(StrEnum): """Generic flow rate Unit of measurement: UnitOfVolumeFlowRate - - SI / metric: `m³/h`, `L/min` + - SI / metric: `m³/h`, `L/min`, `mL/s` - USCS / imperial: `ft³/min`, `gal/min` """ diff --git a/homeassistant/const.py b/homeassistant/const.py index 5a3a3f292ffe7..514c215461185 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1166,6 +1166,7 @@ class UnitOfVolumeFlowRate(StrEnum): CUBIC_FEET_PER_MINUTE = "ft³/min" LITERS_PER_MINUTE = "L/min" GALLONS_PER_MINUTE = "gal/min" + MILLILITERS_PER_SECOND = "mL/s" _DEPRECATED_VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = DeprecatedConstantEnum( diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index b700c66f248d4..3cffcb5768e68 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -666,12 +666,15 @@ class VolumeFlowRateConverter(BaseUnitConverter): / (_HRS_TO_MINUTES * _L_TO_CUBIC_METER), UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 1 / (_HRS_TO_MINUTES * _GALLON_TO_CUBIC_METER), + UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND: 1 + / (_HRS_TO_SECS * _ML_TO_CUBIC_METER), } VALID_UNITS = { UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, UnitOfVolumeFlowRate.LITERS_PER_MINUTE, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND, } diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 60144f817149d..4d1eda3d8de8e 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -743,6 +743,18 @@ 7.48051948, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, ), + ( + 9, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + 2500, + UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND, + ), + ( + 3, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + 50, + UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND, + ), ], } From e9286971aa57c62c7818f89195ce004a02e4d1e9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 21 Nov 2024 20:49:59 +0100 Subject: [PATCH 0667/1070] Add remaining snapshot testing to Sensibo (#131105) --- .../sensibo/snapshots/test_binary_sensor.ambr | 705 ++++++++++++++++++ .../sensibo/snapshots/test_button.ambr | 139 ++++ .../sensibo/snapshots/test_climate.ambr | 255 ++++++- .../sensibo/snapshots/test_number.ambr | 343 +++++++++ .../sensibo/snapshots/test_select.ambr | 170 +++++ .../components/sensibo/test_binary_sensor.py | 28 +- tests/components/sensibo/test_button.py | 30 +- tests/components/sensibo/test_climate.py | 20 +- tests/components/sensibo/test_number.py | 17 +- tests/components/sensibo/test_select.py | 15 +- 10 files changed, 1653 insertions(+), 69 deletions(-) create mode 100644 tests/components/sensibo/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/sensibo/snapshots/test_button.ambr create mode 100644 tests/components/sensibo/snapshots/test_number.ambr create mode 100644 tests/components/sensibo/snapshots/test_select.ambr diff --git a/tests/components/sensibo/snapshots/test_binary_sensor.ambr b/tests/components/sensibo/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..110a6ae8174ec --- /dev/null +++ b/tests/components/sensibo/snapshots/test_binary_sensor.ambr @@ -0,0 +1,705 @@ +# serializer version: 1 +# name: test_binary_sensor[load_platforms0][binary_sensor.bedroom_filter_clean_required-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.bedroom_filter_clean_required', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter clean required', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_clean', + 'unique_id': 'BBZZBBZZ-filter_clean', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.bedroom_filter_clean_required-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bedroom Filter clean required', + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom_filter_clean_required', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.bedroom_pure_boost_linked_with_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bedroom_pure_boost_linked_with_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pure Boost linked with AC', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pure_ac_integration', + 'unique_id': 'BBZZBBZZ-pure_ac_integration', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.bedroom_pure_boost_linked_with_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Bedroom Pure Boost linked with AC', + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom_pure_boost_linked_with_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.bedroom_pure_boost_linked_with_indoor_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bedroom_pure_boost_linked_with_indoor_air_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pure Boost linked with indoor air quality', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pure_measure_integration', + 'unique_id': 'BBZZBBZZ-pure_measure_integration', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.bedroom_pure_boost_linked_with_indoor_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Bedroom Pure Boost linked with indoor air quality', + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom_pure_boost_linked_with_indoor_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.bedroom_pure_boost_linked_with_outdoor_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bedroom_pure_boost_linked_with_outdoor_air_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pure Boost linked with outdoor air quality', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pure_prime_integration', + 'unique_id': 'BBZZBBZZ-pure_prime_integration', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.bedroom_pure_boost_linked_with_outdoor_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Bedroom Pure Boost linked with outdoor air quality', + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom_pure_boost_linked_with_outdoor_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.bedroom_pure_boost_linked_with_presence-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bedroom_pure_boost_linked_with_presence', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pure Boost linked with presence', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pure_geo_integration', + 'unique_id': 'BBZZBBZZ-pure_geo_integration', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.bedroom_pure_boost_linked_with_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Bedroom Pure Boost linked with presence', + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom_pure_boost_linked_with_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.hallway_filter_clean_required-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.hallway_filter_clean_required', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter clean required', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_clean', + 'unique_id': 'ABC999111-filter_clean', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.hallway_filter_clean_required-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Hallway Filter clean required', + }), + 'context': , + 'entity_id': 'binary_sensor.hallway_filter_clean_required', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.hallway_motion_sensor_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.hallway_motion_sensor_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AABBCC-alive', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.hallway_motion_sensor_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Hallway Motion Sensor Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.hallway_motion_sensor_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.hallway_motion_sensor_main_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.hallway_motion_sensor_main_sensor', + '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': 'Main sensor', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_main_sensor', + 'unique_id': 'AABBCC-is_main_sensor', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.hallway_motion_sensor_main_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hallway Motion Sensor Main sensor', + }), + 'context': , + 'entity_id': 'binary_sensor.hallway_motion_sensor_main_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.hallway_motion_sensor_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.hallway_motion_sensor_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AABBCC-motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.hallway_motion_sensor_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Hallway Motion Sensor Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.hallway_motion_sensor_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.hallway_room_occupied-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.hallway_room_occupied', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Room occupied', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'room_occupied', + 'unique_id': 'ABC999111-room_occupied', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.hallway_room_occupied-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Hallway Room occupied', + }), + 'context': , + 'entity_id': 'binary_sensor.hallway_room_occupied', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.kitchen_filter_clean_required-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kitchen_filter_clean_required', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter clean required', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_clean', + 'unique_id': 'AAZZAAZZ-filter_clean', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.kitchen_filter_clean_required-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Kitchen Filter clean required', + }), + 'context': , + 'entity_id': 'binary_sensor.kitchen_filter_clean_required', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.kitchen_pure_boost_linked_with_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kitchen_pure_boost_linked_with_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pure Boost linked with AC', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pure_ac_integration', + 'unique_id': 'AAZZAAZZ-pure_ac_integration', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.kitchen_pure_boost_linked_with_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Kitchen Pure Boost linked with AC', + }), + 'context': , + 'entity_id': 'binary_sensor.kitchen_pure_boost_linked_with_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.kitchen_pure_boost_linked_with_indoor_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kitchen_pure_boost_linked_with_indoor_air_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pure Boost linked with indoor air quality', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pure_measure_integration', + 'unique_id': 'AAZZAAZZ-pure_measure_integration', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.kitchen_pure_boost_linked_with_indoor_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Kitchen Pure Boost linked with indoor air quality', + }), + 'context': , + 'entity_id': 'binary_sensor.kitchen_pure_boost_linked_with_indoor_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.kitchen_pure_boost_linked_with_outdoor_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kitchen_pure_boost_linked_with_outdoor_air_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pure Boost linked with outdoor air quality', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pure_prime_integration', + 'unique_id': 'AAZZAAZZ-pure_prime_integration', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.kitchen_pure_boost_linked_with_outdoor_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Kitchen Pure Boost linked with outdoor air quality', + }), + 'context': , + 'entity_id': 'binary_sensor.kitchen_pure_boost_linked_with_outdoor_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.kitchen_pure_boost_linked_with_presence-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kitchen_pure_boost_linked_with_presence', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pure Boost linked with presence', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pure_geo_integration', + 'unique_id': 'AAZZAAZZ-pure_geo_integration', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.kitchen_pure_boost_linked_with_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Kitchen Pure Boost linked with presence', + }), + 'context': , + 'entity_id': 'binary_sensor.kitchen_pure_boost_linked_with_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/sensibo/snapshots/test_button.ambr b/tests/components/sensibo/snapshots/test_button.ambr new file mode 100644 index 0000000000000..7ef6d56c71479 --- /dev/null +++ b/tests/components/sensibo/snapshots/test_button.ambr @@ -0,0 +1,139 @@ +# serializer version: 1 +# name: test_button[load_platforms0][button.bedroom_reset_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.bedroom_reset_filter', + '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': 'Reset filter', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_filter', + 'unique_id': 'BBZZBBZZ-reset_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[load_platforms0][button.bedroom_reset_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom Reset filter', + }), + 'context': , + 'entity_id': 'button.bedroom_reset_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[load_platforms0][button.hallway_reset_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hallway_reset_filter', + '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': 'Reset filter', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_filter', + 'unique_id': 'ABC999111-reset_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[load_platforms0][button.hallway_reset_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hallway Reset filter', + }), + 'context': , + 'entity_id': 'button.hallway_reset_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[load_platforms0][button.kitchen_reset_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.kitchen_reset_filter', + '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': 'Reset filter', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_filter', + 'unique_id': 'AAZZAAZZ-reset_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[load_platforms0][button.kitchen_reset_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Reset filter', + }), + 'context': , + 'entity_id': 'button.kitchen_reset_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sensibo/snapshots/test_climate.ambr b/tests/components/sensibo/snapshots/test_climate.ambr index 1e02ee63a9a59..e3b273329323c 100644 --- a/tests/components/sensibo/snapshots/test_climate.ambr +++ b/tests/components/sensibo/snapshots/test_climate.ambr @@ -1,33 +1,230 @@ # serializer version: 1 -# name: test_climate - ReadOnlyDict({ - 'current_humidity': 32.9, - 'current_temperature': 21.2, - 'fan_mode': 'high', - 'fan_modes': list([ - 'quiet', - 'low', - 'medium', - ]), - 'friendly_name': 'Hallway', - 'hvac_modes': list([ - , - , - , - , - , - , - ]), - 'max_temp': 20, - 'min_temp': 10, +# name: test_climate[load_platforms0][climate.bedroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 1, + 'min_temp': 0, + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bedroom', + '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': None, + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'climate_device', + 'unique_id': 'BBZZBBZZ', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[load_platforms0][climate.bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Bedroom', + 'hvac_modes': list([ + , + ]), + 'max_temp': 1, + 'min_temp': 0, + 'supported_features': , + 'target_temp_step': 1, + }), + 'context': , + 'entity_id': 'climate.bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[load_platforms0][climate.hallway-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'quiet', + 'low', + 'medium', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 20, + 'min_temp': 10, + 'swing_modes': list([ + 'stopped', + 'fixedtop', + 'fixedmiddletop', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.hallway', + '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': None, + 'platform': 'sensibo', + 'previous_unique_id': None, 'supported_features': , - 'swing_mode': 'stopped', - 'swing_modes': list([ - 'stopped', - 'fixedtop', - 'fixedmiddletop', - ]), - 'target_temp_step': 1, - 'temperature': 25, + 'translation_key': 'climate_device', + 'unique_id': 'ABC999111', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[load_platforms0][climate.hallway-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 32.9, + 'current_temperature': 21.2, + 'fan_mode': 'high', + 'fan_modes': list([ + 'quiet', + 'low', + 'medium', + ]), + 'friendly_name': 'Hallway', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 20, + 'min_temp': 10, + 'supported_features': , + 'swing_mode': 'stopped', + 'swing_modes': list([ + 'stopped', + 'fixedtop', + 'fixedmiddletop', + ]), + 'target_temp_step': 1, + 'temperature': 25, + }), + 'context': , + 'entity_id': 'climate.hallway', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate[load_platforms0][climate.kitchen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'low', + 'high', + ]), + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 1, + 'min_temp': 0, + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.kitchen', + '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': None, + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'climate_device', + 'unique_id': 'AAZZAAZZ', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[load_platforms0][climate.kitchen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'fan_mode': 'low', + 'fan_modes': list([ + 'low', + 'high', + ]), + 'friendly_name': 'Kitchen', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 1, + 'min_temp': 0, + 'supported_features': , + 'target_temp_step': 1, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.kitchen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', }) # --- diff --git a/tests/components/sensibo/snapshots/test_number.ambr b/tests/components/sensibo/snapshots/test_number.ambr new file mode 100644 index 0000000000000..b632b95f1be47 --- /dev/null +++ b/tests/components/sensibo/snapshots/test_number.ambr @@ -0,0 +1,343 @@ +# serializer version: 1 +# name: test_number[load_platforms0][number.bedroom_humidity_calibration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.bedroom_humidity_calibration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity calibration', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'calibration_humidity', + 'unique_id': 'BBZZBBZZ-calibration_hum', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[load_platforms0][number.bedroom_humidity_calibration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Bedroom Humidity calibration', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.bedroom_humidity_calibration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_number[load_platforms0][number.bedroom_temperature_calibration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.bedroom_temperature_calibration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature calibration', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'calibration_temperature', + 'unique_id': 'BBZZBBZZ-calibration_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_number[load_platforms0][number.bedroom_temperature_calibration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bedroom Temperature calibration', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.bedroom_temperature_calibration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_number[load_platforms0][number.hallway_humidity_calibration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.hallway_humidity_calibration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity calibration', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'calibration_humidity', + 'unique_id': 'ABC999111-calibration_hum', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[load_platforms0][number.hallway_humidity_calibration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Hallway Humidity calibration', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.hallway_humidity_calibration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_number[load_platforms0][number.hallway_temperature_calibration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.hallway_temperature_calibration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature calibration', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'calibration_temperature', + 'unique_id': 'ABC999111-calibration_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_number[load_platforms0][number.hallway_temperature_calibration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Hallway Temperature calibration', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.hallway_temperature_calibration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_number[load_platforms0][number.kitchen_humidity_calibration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.kitchen_humidity_calibration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity calibration', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'calibration_humidity', + 'unique_id': 'AAZZAAZZ-calibration_hum', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[load_platforms0][number.kitchen_humidity_calibration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Kitchen Humidity calibration', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.kitchen_humidity_calibration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_number[load_platforms0][number.kitchen_temperature_calibration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.kitchen_temperature_calibration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature calibration', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'calibration_temperature', + 'unique_id': 'AAZZAAZZ-calibration_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_number[load_platforms0][number.kitchen_temperature_calibration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Kitchen Temperature calibration', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.kitchen_temperature_calibration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/sensibo/snapshots/test_select.ambr b/tests/components/sensibo/snapshots/test_select.ambr new file mode 100644 index 0000000000000..bdafc8654ffc4 --- /dev/null +++ b/tests/components/sensibo/snapshots/test_select.ambr @@ -0,0 +1,170 @@ +# serializer version: 1 +# name: test_select[load_platforms0][select.hallway_horizontal_swing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stopped', + 'fixedleft', + 'fixedcenterleft', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.hallway_horizontal_swing', + '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': 'Horizontal swing', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'horizontalswing', + 'unique_id': 'ABC999111-horizontalSwing', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[load_platforms0][select.hallway_horizontal_swing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hallway Horizontal swing', + 'options': list([ + 'stopped', + 'fixedleft', + 'fixedcenterleft', + ]), + }), + 'context': , + 'entity_id': 'select.hallway_horizontal_swing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stopped', + }) +# --- +# name: test_select[load_platforms0][select.hallway_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.hallway_light', + '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': 'Light', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'ABC999111-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[load_platforms0][select.hallway_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hallway Light', + 'options': list([ + 'on', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.hallway_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_select[load_platforms0][select.kitchen_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'dim', + 'off', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.kitchen_light', + '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': 'Light', + 'platform': 'sensibo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'AAZZAAZZ-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[load_platforms0][select.kitchen_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Light', + 'options': list([ + 'on', + 'dim', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.kitchen_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/sensibo/test_binary_sensor.py b/tests/components/sensibo/test_binary_sensor.py index 61b62226679bd..dbc3e87a2367f 100644 --- a/tests/components/sensibo/test_binary_sensor.py +++ b/tests/components/sensibo/test_binary_sensor.py @@ -7,39 +7,33 @@ from pysensibo.model import SensiboData import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.BINARY_SENSOR]], +) async def test_binary_sensor( hass: HomeAssistant, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test the Sensibo binary sensor.""" - state1 = hass.states.get("binary_sensor.hallway_motion_sensor_connectivity") - state2 = hass.states.get("binary_sensor.hallway_motion_sensor_main_sensor") - state3 = hass.states.get("binary_sensor.hallway_motion_sensor_motion") - state4 = hass.states.get("binary_sensor.hallway_room_occupied") - state5 = hass.states.get( - "binary_sensor.kitchen_pure_boost_linked_with_indoor_air_quality" - ) - state6 = hass.states.get( - "binary_sensor.kitchen_pure_boost_linked_with_outdoor_air_quality" - ) - assert state1.state == "on" - assert state2.state == "on" - assert state3.state == "on" - assert state4.state == "on" - assert state5.state == "on" - assert state6.state == "off" + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) monkeypatch.setattr( get_data.parsed["ABC999111"].motion_sensors["AABBCC"], "alive", False diff --git a/tests/components/sensibo/test_button.py b/tests/components/sensibo/test_button.py index 6d7ce442562aa..5c36fe9e94df0 100644 --- a/tests/components/sensibo/test_button.py +++ b/tests/components/sensibo/test_button.py @@ -5,21 +5,47 @@ from datetime import datetime, timedelta from unittest.mock import patch +from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory from pysensibo.model import SensiboData import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform +@freeze_time("2022-03-12T15:24:26+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.BUTTON]], +) async def test_button( + hass: HomeAssistant, + load_int: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Sensibo button.""" + + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) + + +async def test_button_update( hass: HomeAssistant, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index b5a7be7bde03e..8be9f4a60e4bd 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -54,12 +54,14 @@ SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform async def test_climate_find_valid_targets() -> None: @@ -77,26 +79,22 @@ async def test_climate_find_valid_targets() -> None: assert _find_valid_target_temp(25, valid_targets) == 20 +@pytest.mark.parametrize( + "load_platforms", + [[Platform.CLIMATE]], +) async def test_climate( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, get_data: SensiboData, load_int: ConfigEntry, + entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test the Sensibo climate.""" - state1 = hass.states.get("climate.hallway") - state2 = hass.states.get("climate.kitchen") - state3 = hass.states.get("climate.bedroom") - - assert state1.state == "heat" - assert state1.attributes == snapshot - - assert state2.state == "off" + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) - assert state3 - assert state3.state == "off" found_log = False logs = caplog.get_records("setup") for log in logs: diff --git a/tests/components/sensibo/test_number.py b/tests/components/sensibo/test_number.py index de369698f5092..95836ba023cca 100644 --- a/tests/components/sensibo/test_number.py +++ b/tests/components/sensibo/test_number.py @@ -7,6 +7,7 @@ from pysensibo.model import SensiboData import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, @@ -14,27 +15,31 @@ SERVICE_SET_VALUE, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform +@pytest.mark.parametrize( + "load_platforms", + [[Platform.NUMBER]], +) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number( hass: HomeAssistant, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test the Sensibo number.""" - state1 = hass.states.get("number.hallway_temperature_calibration") - state2 = hass.states.get("number.hallway_humidity_calibration") - assert state1.state == "0.1" - assert state2.state == "0.0" + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) monkeypatch.setattr(get_data.parsed["ABC999111"], "calibration_temp", 0.2) diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index 7a9c89ef612e4..2e4a1cb507cae 100644 --- a/tests/components/sensibo/test_select.py +++ b/tests/components/sensibo/test_select.py @@ -7,6 +7,7 @@ from pysensibo.model import SensiboData import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, @@ -14,24 +15,30 @@ SERVICE_SELECT_OPTION, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform +@pytest.mark.parametrize( + "load_platforms", + [[Platform.SELECT]], +) async def test_select( hass: HomeAssistant, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test the Sensibo select.""" - state1 = hass.states.get("select.hallway_horizontal_swing") - assert state1.state == "stopped" + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) monkeypatch.setattr( get_data.parsed["ABC999111"], "horizontal_swing_mode", "fixedleft" From 50fdbe9b3b8f031aa086828e342a7a06c31f4de5 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 21 Nov 2024 14:50:21 -0500 Subject: [PATCH 0668/1070] Generic ZHA Zeroconf discovery (#126294) --- homeassistant/components/zha/config_flow.py | 84 +++++-- homeassistant/components/zha/manifest.json | 4 + homeassistant/components/zha/strings.json | 3 +- homeassistant/generated/zeroconf.py | 6 + tests/components/zha/test_config_flow.py | 265 ++++++++++++-------- 5 files changed, 233 insertions(+), 129 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index f3f7f38772d2d..9c515c315b71c 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -70,8 +70,17 @@ REPAIR_MY_URL = "https://my.home-assistant.io/redirect/repairs/" -DEFAULT_ZHA_ZEROCONF_PORT = 6638 -ESPHOME_API_PORT = 6053 +LEGACY_ZEROCONF_PORT = 6638 +LEGACY_ZEROCONF_ESPHOME_API_PORT = 6053 + +ZEROCONF_SERVICE_TYPE = "_zigbee-coordinator._tcp.local." +ZEROCONF_PROPERTIES_SCHEMA = vol.Schema( + { + vol.Required("radio_type"): vol.All(str, vol.In([t.name for t in RadioType])), + vol.Required("serial_number"): str, + }, + extra=vol.ALLOW_EXTRA, +) def _format_backup_choice( @@ -617,34 +626,65 @@ async def async_step_zeroconf( ) -> ConfigFlowResult: """Handle zeroconf discovery.""" - # Hostname is format: livingroom.local. - local_name = discovery_info.hostname[:-1] - port = discovery_info.port or DEFAULT_ZHA_ZEROCONF_PORT - - # Fix incorrect port for older TubesZB devices - if "tube" in local_name and port == ESPHOME_API_PORT: - port = DEFAULT_ZHA_ZEROCONF_PORT - - if "radio_type" in discovery_info.properties: - self._radio_mgr.radio_type = self._radio_mgr.parse_radio_type( - discovery_info.properties["radio_type"] + # Transform legacy zeroconf discovery into the new format + if discovery_info.type != ZEROCONF_SERVICE_TYPE: + port = discovery_info.port or LEGACY_ZEROCONF_PORT + name = discovery_info.name + + # Fix incorrect port for older TubesZB devices + if "tube" in name and port == LEGACY_ZEROCONF_ESPHOME_API_PORT: + port = LEGACY_ZEROCONF_PORT + + # Determine the radio type + if "radio_type" in discovery_info.properties: + radio_type = discovery_info.properties["radio_type"] + elif "efr32" in name: + radio_type = RadioType.ezsp.name + elif "zigate" in name: + radio_type = RadioType.zigate.name + else: + radio_type = RadioType.znp.name + + fallback_title = name.split("._", 1)[0] + title = discovery_info.properties.get("name", fallback_title) + + discovery_info = zeroconf.ZeroconfServiceInfo( + ip_address=discovery_info.ip_address, + ip_addresses=discovery_info.ip_addresses, + port=port, + hostname=discovery_info.hostname, + type=ZEROCONF_SERVICE_TYPE, + name=f"{title}.{ZEROCONF_SERVICE_TYPE}", + properties={ + "radio_type": radio_type, + # To maintain backwards compatibility + "serial_number": discovery_info.hostname.removesuffix(".local."), + }, ) - elif "efr32" in local_name: - self._radio_mgr.radio_type = RadioType.ezsp - else: - self._radio_mgr.radio_type = RadioType.znp - node_name = local_name.removesuffix(".local") - device_path = f"socket://{discovery_info.host}:{port}" + try: + discovery_props = ZEROCONF_PROPERTIES_SCHEMA(discovery_info.properties) + except vol.Invalid: + return self.async_abort(reason="invalid_zeroconf_data") + + radio_type = self._radio_mgr.parse_radio_type(discovery_props["radio_type"]) + device_path = f"socket://{discovery_info.host}:{discovery_info.port}" + title = discovery_info.name.removesuffix(f".{ZEROCONF_SERVICE_TYPE}") await self._set_unique_id_and_update_ignored_flow( - unique_id=node_name, + unique_id=discovery_props["serial_number"], device_path=device_path, ) - self.context["title_placeholders"] = {CONF_NAME: node_name} - self._title = device_path + self.context["title_placeholders"] = {CONF_NAME: title} + self._title = title self._radio_mgr.device_path = device_path + self._radio_mgr.radio_type = radio_type + self._radio_mgr.device_settings = { + CONF_DEVICE_PATH: device_path, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + } return await self.async_step_confirm() diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 8736dc8954946..a2a285e61094f 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -130,6 +130,10 @@ { "type": "_czc._tcp.local.", "name": "czc*" + }, + { + "type": "_zigbee-coordinator._tcp.local.", + "name": "*" } ] } diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 40b6e1c947467..c462bef8fb070 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -76,7 +76,8 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "not_zha_device": "This device is not a zha device", "usb_probe_failed": "Failed to probe the usb device", - "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this." + "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this.", + "invalid_zeroconf_data": "The coordinator has invalid zeroconf service info and cannot be identified by ZHA" } }, "options": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 1fbd6337fdb0f..5f7161a8245f1 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -872,6 +872,12 @@ "name": "*zigate*", }, ], + "_zigbee-coordinator._tcp.local.": [ + { + "domain": "zha", + "name": "*", + }, + ], "_zigstar_gw._tcp.local.": [ { "domain": "zha", diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 87ba46a4ced11..e0229ebe049d6 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -154,104 +154,180 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: return port +@pytest.mark.parametrize( + ("entry_name", "unique_id", "radio_type", "service_info"), + [ + ( + # TubesZB, old ESPHome devices (ZNP) + "tubeszb-cc2652-poe", + "tubeszb-cc2652-poe", + RadioType.znp, + zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + hostname="tubeszb-cc2652-poe.local.", + name="tubeszb-cc2652-poe._esphomelib._tcp.local.", + port=6053, # the ESPHome API port is remapped to 6638 + type="_esphomelib._tcp.local.", + properties={ + "project_version": "3.0", + "project_name": "tubezb.cc2652-poe", + "network": "ethernet", + "board": "esp32-poe", + "platform": "ESP32", + "maс": "8c4b14c33c24", + "version": "2023.12.8", + }, + ), + ), + ( + # TubesZB, old ESPHome device (EFR32) + "tubeszb-efr32-poe", + "tubeszb-efr32-poe", + RadioType.ezsp, + zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + hostname="tubeszb-efr32-poe.local.", + name="tubeszb-efr32-poe._esphomelib._tcp.local.", + port=6053, # the ESPHome API port is remapped to 6638 + type="_esphomelib._tcp.local.", + properties={ + "project_version": "3.0", + "project_name": "tubezb.efr32-poe", + "network": "ethernet", + "board": "esp32-poe", + "platform": "ESP32", + "maс": "8c4b14c33c24", + "version": "2023.12.8", + }, + ), + ), + ( + # TubesZB, newer devices + "TubeZB", + "tubeszb-cc2652-poe", + RadioType.znp, + zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + hostname="tubeszb-cc2652-poe.local.", + name="tubeszb-cc2652-poe._tubeszb._tcp.local.", + port=6638, + properties={ + "name": "TubeZB", + "radio_type": "znp", + "version": "1.0", + "baud_rate": "115200", + "data_flow_control": "software", + }, + type="_tubeszb._tcp.local.", + ), + ), + ( + # Expected format for all new devices + "Some Zigbee Gateway (12345)", + "aabbccddeeff", + RadioType.znp, + zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + hostname="some-zigbee-gateway-12345.local.", + name="Some Zigbee Gateway (12345)._zigbee-coordinator._tcp.local.", + port=6638, + properties={"radio_type": "znp", "serial_number": "aabbccddeeff"}, + type="_zigbee-coordinator._tcp.local.", + ), + ), + ], +) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) -async def test_zeroconf_discovery_znp(hass: HomeAssistant) -> None: +@patch(f"bellows.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +async def test_zeroconf_discovery( + entry_name: str, + unique_id: str, + radio_type: RadioType, + service_info: zeroconf.ZeroconfServiceInfo, + hass: HomeAssistant, +) -> None: """Test zeroconf flow -- radio detected.""" - service_info = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.1.200"), - ip_addresses=[ip_address("192.168.1.200")], - hostname="tube._tube_zb_gw._tcp.local.", - name="tube", - port=6053, - properties={"name": "tube_123456"}, - type="mock_type", - ) - flow = await hass.config_entries.flow.async_init( + result_init = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info ) - assert flow["step_id"] == "confirm" - - # Confirm discovery - result1 = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={} - ) - assert result1["step_id"] == "manual_port_config" + assert result_init["step_id"] == "confirm" # Confirm port settings - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], user_input={} + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} ) - assert result2["type"] is FlowResultType.MENU - assert result2["step_id"] == "choose_formation_strategy" + assert result_confirm["type"] is FlowResultType.MENU + assert result_confirm["step_id"] == "choose_formation_strategy" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result_form = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "socket://192.168.1.200:6638" - assert result3["data"] == { + assert result_form["type"] is FlowResultType.CREATE_ENTRY + assert result_form["title"] == entry_name + assert result_form["context"]["unique_id"] == unique_id + assert result_form["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, CONF_FLOW_CONTROL: None, CONF_DEVICE_PATH: "socket://192.168.1.200:6638", }, - CONF_RADIO_TYPE: "znp", + CONF_RADIO_TYPE: radio_type.name, } @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch(f"zigpy_zigate.{PROBE_FUNCTION_PATH}") -async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> None: +async def test_legacy_zeroconf_discovery_zigate( + setup_entry_mock, hass: HomeAssistant +) -> None: """Test zeroconf flow -- zigate radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], - hostname="_zigate-zigbee-gateway._tcp.local.", - name="any", + hostname="_zigate-zigbee-gateway.local.", + name="some name._zigate-zigbee-gateway._tcp.local.", port=1234, - properties={"radio_type": "zigate"}, + properties={}, type="mock_type", ) - flow = await hass.config_entries.flow.async_init( + result_init = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info ) - assert flow["step_id"] == "confirm" - - # Confirm discovery - result1 = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={} - ) - assert result1["step_id"] == "manual_port_config" + assert result_init["step_id"] == "confirm" # Confirm the radio is deprecated - result2 = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={} + result_confirm_deprecated = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} ) - assert result2["step_id"] == "verify_radio" - assert "ZiGate" in result2["description_placeholders"]["name"] + assert result_confirm_deprecated["step_id"] == "verify_radio" + assert "ZiGate" in result_confirm_deprecated["description_placeholders"]["name"] # Confirm port settings - result3 = await hass.config_entries.flow.async_configure( - result1["flow_id"], user_input={} + result_confirm = await hass.config_entries.flow.async_configure( + result_confirm_deprecated["flow_id"], user_input={} ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "choose_formation_strategy" + assert result_confirm["type"] is FlowResultType.MENU + assert result_confirm["step_id"] == "choose_formation_strategy" - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result_form = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == "socket://192.168.1.200:1234" - assert result4["data"] == { + assert result_form["type"] is FlowResultType.CREATE_ENTRY + assert result_form["title"] == "some name" + assert result_form["data"] == { CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.200:1234", CONF_BAUDRATE: 115200, @@ -261,75 +337,50 @@ async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> Non } -@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) -@patch(f"bellows.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) -async def test_efr32_via_zeroconf(hass: HomeAssistant) -> None: - """Test zeroconf flow -- efr32 radio detected.""" +async def test_zeroconf_discovery_bad_payload(hass: HomeAssistant) -> None: + """Test zeroconf flow with a bad payload.""" service_info = zeroconf.ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], - hostname="efr32._esphomelib._tcp.local.", - name="efr32", + hostname="some.hostname", + name="any", port=1234, - properties={}, - type="mock_type", + properties={"radio_type": "some bogus radio"}, + type="_zigbee-coordinator._tcp.local.", ) - flow = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info ) - assert flow["step_id"] == "confirm" - - # Confirm discovery - result1 = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={} - ) - assert result1["step_id"] == "manual_port_config" - - # Confirm port settings - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], user_input={} - ) - - assert result2["type"] is FlowResultType.MENU - assert result2["step_id"] == "choose_formation_strategy" - - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "socket://192.168.1.200:1234" - assert result3["data"] == { - CONF_DEVICE: { - CONF_DEVICE_PATH: "socket://192.168.1.200:1234", - CONF_BAUDRATE: 115200, - CONF_FLOW_CONTROL: None, - }, - CONF_RADIO_TYPE: "ezsp", - } + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_zeroconf_data" @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) -async def test_discovery_via_zeroconf_ip_change_ignored(hass: HomeAssistant) -> None: +async def test_legacy_zeroconf_discovery_ip_change_ignored(hass: HomeAssistant) -> None: """Test zeroconf flow that was ignored gets updated.""" + entry = MockConfigEntry( domain=DOMAIN, - unique_id="tube_zb_gw_cc2652p2_poe", + unique_id="tubeszb-cc2652-poe", source=config_entries.SOURCE_IGNORE, ) entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.1.22"), - ip_addresses=[ip_address("192.168.1.22")], - hostname="tube_zb_gw_cc2652p2_poe.local.", - name="mock_name", - port=6053, - properties={"address": "tube_zb_gw_cc2652p2_poe.local"}, - type="mock_type", + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + hostname="tubeszb-cc2652-poe.local.", + name="tubeszb-cc2652-poe._tubeszb._tcp.local.", + port=6638, + properties={ + "name": "TubeZB", + "radio_type": "znp", + "version": "1.0", + "baud_rate": "115200", + "data_flow_control": "software", + }, + type="_tubeszb._tcp.local.", ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info @@ -338,11 +389,13 @@ async def test_discovery_via_zeroconf_ip_change_ignored(hass: HomeAssistant) -> assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert entry.data[CONF_DEVICE] == { - CONF_DEVICE_PATH: "socket://192.168.1.22:6638", + CONF_DEVICE_PATH: "socket://192.168.1.200:6638", } -async def test_discovery_confirm_final_abort_if_entries(hass: HomeAssistant) -> None: +async def test_legacy_zeroconf_discovery_confirm_final_abort_if_entries( + hass: HomeAssistant, +) -> None: """Test discovery aborts if ZHA was set up after the confirmation dialog is shown.""" service_info = zeroconf.ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), @@ -677,7 +730,7 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) -async def test_discovery_already_setup(hass: HomeAssistant) -> None: +async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> None: """Test zeroconf flow -- radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), From f42386de44f2fdfb2d71564c9789c65111a1ab58 Mon Sep 17 00:00:00 2001 From: Davin Kevin Date: Thu, 21 Nov 2024 20:50:49 +0100 Subject: [PATCH 0669/1070] Prevent endless loop in recorder when using a filter and there are no more states to purge (#126149) Co-authored-by: J. Nick Koston --- homeassistant/components/recorder/purge.py | 16 +- tests/components/recorder/test_purge.py | 165 +++++++++++++++++++++ 2 files changed, 174 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index d28e7e2a5475f..329f48e5455c4 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -110,7 +110,7 @@ def purge_old_data( _LOGGER.debug("Purging hasn't fully completed yet") return False - if apply_filter and _purge_filtered_data(instance, session) is False: + if apply_filter and not _purge_filtered_data(instance, session): _LOGGER.debug("Cleanup filtered data hasn't fully completed yet") return False @@ -631,7 +631,10 @@ def _purge_old_entity_ids(instance: Recorder, session: Session) -> None: def _purge_filtered_data(instance: Recorder, session: Session) -> bool: - """Remove filtered states and events that shouldn't be in the database.""" + """Remove filtered states and events that shouldn't be in the database. + + Returns true if all states and events are purged. + """ _LOGGER.debug("Cleanup filtered data") database_engine = instance.database_engine assert database_engine is not None @@ -639,7 +642,7 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: # Check if excluded entity_ids are in database entity_filter = instance.entity_filter - has_more_states_to_purge = False + has_more_to_purge = False excluded_metadata_ids: list[str] = [ metadata_id for (metadata_id, entity_id) in session.query( @@ -648,12 +651,11 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: if entity_filter and not entity_filter(entity_id) ] if excluded_metadata_ids: - has_more_states_to_purge = _purge_filtered_states( + has_more_to_purge |= not _purge_filtered_states( instance, session, excluded_metadata_ids, database_engine, now_timestamp ) # Check if excluded event_types are in database - has_more_events_to_purge = False if ( event_type_to_event_type_ids := instance.event_type_manager.get_many( instance.exclude_event_types, session @@ -665,12 +667,12 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: if event_type_id is not None ] ): - has_more_events_to_purge = _purge_filtered_events( + has_more_to_purge |= not _purge_filtered_events( instance, session, excluded_event_type_ids, now_timestamp ) # Purge has completed if there are not more state or events to purge - return not (has_more_states_to_purge or has_more_events_to_purge) + return not has_more_to_purge def _purge_filtered_states( diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 245acf4603de8..e0b3f7ca8a862 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -964,6 +964,171 @@ def _add_db_entries(hass: HomeAssistant) -> None: assert session.query(StateAttributes).count() == 0 +@pytest.mark.parametrize( + "recorder_config", [{"exclude": {"entities": ["sensor.excluded"]}}] +) +async def test_purge_filtered_states_multiple_rounds( + hass: HomeAssistant, + recorder_mock: Recorder, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test filtered states are purged when there are multiple rounds to purge.""" + assert recorder_mock.entity_filter("sensor.excluded") is False + + def _add_db_entries(hass: HomeAssistant) -> None: + with session_scope(hass=hass) as session: + # Add states and state_changed events that should be purged + for days in range(1, 4): + timestamp = dt_util.utcnow() - timedelta(days=days) + for event_id in range(1000, 1020): + _add_state_with_state_attributes( + session, + "sensor.excluded", + "purgeme", + timestamp, + event_id * days, + ) + # Add state **without** state_changed event that should be purged + timestamp = dt_util.utcnow() - timedelta(days=1) + session.add( + States( + entity_id="sensor.excluded", + state="purgeme", + attributes="{}", + last_changed_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + # Add states and state_changed events that should be keeped + timestamp = dt_util.utcnow() - timedelta(days=2) + for event_id in range(200, 210): + _add_state_with_state_attributes( + session, + "sensor.keep", + "keep", + timestamp, + event_id, + ) + # Add states with linked old_state_ids that need to be handled + timestamp = dt_util.utcnow() - timedelta(days=0) + state_attrs = StateAttributes( + hash=0, + shared_attrs=json.dumps( + {"sensor.linked_old_state_id": "sensor.linked_old_state_id"} + ), + ) + state_1 = States( + entity_id="sensor.linked_old_state_id", + state="keep", + attributes="{}", + last_changed_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + old_state_id=1, + state_attributes=state_attrs, + ) + timestamp = dt_util.utcnow() - timedelta(days=4) + state_2 = States( + entity_id="sensor.linked_old_state_id", + state="keep", + attributes="{}", + last_changed_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + old_state_id=2, + state_attributes=state_attrs, + ) + state_3 = States( + entity_id="sensor.linked_old_state_id", + state="keep", + attributes="{}", + last_changed_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + old_state_id=62, # keep + state_attributes=state_attrs, + ) + session.add_all((state_attrs, state_1, state_2, state_3)) + # Add event that should be keeped + session.add( + Events( + event_id=100, + event_type="EVENT_KEEP", + event_data="{}", + origin="LOCAL", + time_fired_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + convert_pending_states_to_meta(recorder_mock, session) + convert_pending_events_to_event_types(recorder_mock, session) + + service_data = {"keep_days": 10, "apply_filter": True} + _add_db_entries(hass) + + with session_scope(hass=hass) as session: + states = session.query(States) + assert states.count() == 74 + events_keep = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_KEEP",))) + ) + assert events_keep.count() == 1 + + await hass.services.async_call( + RECORDER_DOMAIN, SERVICE_PURGE, service_data, blocking=True + ) + + for _ in range(2): + # Make sure the second round of purging runs + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + assert "Cleanup filtered data hasn't fully completed yet" in caplog.text + caplog.clear() + + with session_scope(hass=hass) as session: + states = session.query(States) + assert states.count() == 13 + events_keep = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_KEEP",))) + ) + assert events_keep.count() == 1 + + states_sensor_excluded = ( + session.query(States) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .filter(StatesMeta.entity_id == "sensor.excluded") + ) + assert states_sensor_excluded.count() == 0 + query = session.query(States) + + assert query.filter(States.state_id == 72).first().old_state_id is None + assert query.filter(States.state_id == 72).first().attributes_id == 71 + assert query.filter(States.state_id == 73).first().old_state_id is None + assert query.filter(States.state_id == 73).first().attributes_id == 71 + + final_keep_state = session.query(States).filter(States.state_id == 74).first() + assert final_keep_state.old_state_id == 62 # should have been kept + assert final_keep_state.attributes_id == 71 + + assert session.query(StateAttributes).count() == 11 + + # Do it again to make sure nothing changes + await hass.services.async_call(RECORDER_DOMAIN, SERVICE_PURGE, service_data) + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + with session_scope(hass=hass) as session: + final_keep_state = session.query(States).filter(States.state_id == 74).first() + assert final_keep_state.old_state_id == 62 # should have been kept + assert final_keep_state.attributes_id == 71 + + assert session.query(StateAttributes).count() == 11 + + for _ in range(2): + # Make sure the second round of purging runs + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + assert "Cleanup filtered data hasn't fully completed yet" not in caplog.text + + @pytest.mark.parametrize("use_sqlite", [True, False], indirect=True) @pytest.mark.parametrize( "recorder_config", [{"exclude": {"entities": ["sensor.excluded"]}}] From f9bd4ccc3f6d11cb1f18ce1edc4eda66257bf15f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 21 Nov 2024 20:51:38 +0100 Subject: [PATCH 0670/1070] Reolink log fast poll errors once (#131203) --- homeassistant/components/reolink/host.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 336876d4c4fc8..68a44bf0aae2d 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -110,6 +110,7 @@ def get_aiohttp_session() -> aiohttp.ClientSession: self._cancel_onvif_check: CALLBACK_TYPE | None = None self._cancel_long_poll_check: CALLBACK_TYPE | None = None self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True) + self._fast_poll_error: bool = False self._long_poll_task: asyncio.Task | None = None self._lost_subscription: bool = False @@ -699,14 +700,20 @@ async def _async_poll_all_motion(self, *_) -> None: return try: - await self._api.get_motion_state_all_ch() + if self._api.session_active: + await self._api.get_motion_state_all_ch() except ReolinkError as err: - _LOGGER.error( - "Reolink error while polling motion state for host %s:%s: %s", - self._api.host, - self._api.port, - err, - ) + if not self._fast_poll_error: + _LOGGER.error( + "Reolink error while polling motion state for host %s:%s: %s", + self._api.host, + self._api.port, + err, + ) + self._fast_poll_error = True + else: + if self._api.session_active: + self._fast_poll_error = False finally: # schedule next poll if not self._hass.is_stopping: From 7e752c051f3551283a8dfb1262754bdc31bfbaae Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 21 Nov 2024 21:02:01 +0100 Subject: [PATCH 0671/1070] Add check for quality_scale.yaml (#131096) --- script/hassfest/quality_scale.py | 1214 +++++++++++++++++++++++++++++- 1 file changed, 1212 insertions(+), 2 deletions(-) diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index dda6c73946a96..d36164e3b9285 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -5,6 +5,7 @@ import voluptuous as vol from voluptuous.humanize import humanize_error +from homeassistant.const import Platform from homeassistant.exceptions import HomeAssistantError from homeassistant.util.yaml import load_yaml_dict @@ -65,6 +66,1185 @@ "unique-config-entry", ] +INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ + "abode", + "acaia", + "accuweather", + "acer_projector", + "acmeda", + "actiontec", + "adax", + "adguard", + "ads", + "advantage_air", + "aemet", + "aftership", + "agent_dvr", + "airly", + "airnow", + "airq", + "airthings", + "airthings_ble", + "airtouch4", + "airtouch5", + "airvisual", + "airvisual_pro", + "airzone", + "airzone_cloud", + "aladdin_connect", + "alarmdecoder", + "alert", + "alexa", + "alpha_vantage", + "amazon_polly", + "amberelectric", + "ambient_network", + "ambient_station", + "amcrest", + "ampio", + "analytics", + "analytics_insights", + "android_ip_webcam", + "androidtv", + "androidtv_remote", + "anel_pwrctrl", + "anova", + "anthemav", + "anthropic", + "aosmith", + "apache_kafka", + "apcupsd", + "apple_tv", + "apprise", + "aprilaire", + "aprs", + "apsystems", + "aquacell", + "aqualogic", + "aquostv", + "aranet", + "arcam_fmj", + "arest", + "arris_tg2492lg", + "aruba", + "arve", + "arwn", + "aseko_pool_live", + "assist_pipeline", + "asterisk_mbox", + "asuswrt", + "atag", + "aten_pe", + "atome", + "august", + "aurora", + "aurora_abb_powerone", + "aussie_broadband", + "autarco", + "avea", + "avion", + "awair", + "aws", + "axis", + "azure_data_explorer", + "azure_devops", + "azure_event_hub", + "azure_service_bus", + "backup", + "baf", + "baidu", + "balboa", + "bang_olufsen", + "bayesian", + "bbox", + "beewi_smartclim", + "bitcoin", + "bizkaibus", + "blackbird", + "blebox", + "blink", + "blinksticklight", + "blockchain", + "blue_current", + "bluemaestro", + "bluesound", + "bluetooth", + "bluetooth_adapters", + "bluetooth_le_tracker", + "bluetooth_tracker", + "bmw_connected_drive", + "bond", + "bosch_shc", + "braviatv", + "bring", + "broadlink", + "brother", + "brottsplatskartan", + "browser", + "brunt", + "bryant_evolution", + "bsblan", + "bt_home_hub_5", + "bt_smarthub", + "bthome", + "buienradar", + "caldav", + "cambridge_audio", + "canary", + "cast", + "ccm15", + "cert_expiry", + "chacon_dio", + "channels", + "circuit", + "cisco_ios", + "cisco_mobility_express", + "cisco_webex_teams", + "citybikes", + "clementine", + "clickatell", + "clicksend", + "clicksend_tts", + "climacell", + "cloud", + "cloudflare", + "cmus", + "co2signal", + "coinbase", + "color_extractor", + "comed_hourly_pricing", + "comelit", + "comfoconnect", + "command_line", + "compensation", + "concord232", + "control4", + "coolmaster", + "cppm_tracker", + "cpuspeed", + "crownstone", + "cups", + "currencylayer", + "daikin", + "danfoss_air", + "datadog", + "ddwrt", + "deako", + "debugpy", + "deconz", + "decora", + "decora_wifi", + "delijn", + "deluge", + "demo", + "denon", + "denonavr", + "derivative", + "devialet", + "device_sun_light_trigger", + "devolo_home_control", + "devolo_home_network", + "dexcom", + "dhcp", + "dialogflow", + "digital_ocean", + "directv", + "discogs", + "discord", + "discovergy", + "dlib_face_detect", + "dlib_face_identify", + "dlink", + "dlna_dmr", + "dlna_dms", + "dnsip", + "dominos", + "doods", + "doorbird", + "dormakaba_dkey", + "dovado", + "downloader", + "dremel_3d_printer", + "drop_connect", + "dsmr", + "dsmr_reader", + "dte_energy_bridge", + "dublin_bus_transport", + "duckdns", + "duke_energy", + "dunehd", + "duotecno", + "dwd_weather_warnings", + "dweet", + "dynalite", + "eafm", + "easyenergy", + "ebox", + "ebusd", + "ecoal_boiler", + "ecobee", + "ecoforest", + "econet", + "ecovacs", + "ecowitt", + "eddystone_temperature", + "edimax", + "edl21", + "efergy", + "egardia", + "eight_sleep", + "electrasmart", + "electric_kiwi", + "elevenlabs", + "elgato", + "eliqonline", + "elkm1", + "elmax", + "elv", + "elvia", + "emby", + "emoncms", + "emoncms_history", + "emonitor", + "emulated_hue", + "emulated_kasa", + "emulated_roku", + "energenie_power_sockets", + "energy", + "energyzero", + "enigma2", + "enocean", + "enphase_envoy", + "entur_public_transport", + "environment_canada", + "envisalink", + "ephember", + "epic_games_store", + "epion", + "epson", + "eq3btsmart", + "escea", + "esphome", + "etherscan", + "eufy", + "eufylife_ble", + "everlights", + "evil_genius_labs", + "evohome", + "ezviz", + "faa_delays", + "facebook", + "fail2ban", + "familyhub", + "fastdotcom", + "feedreader", + "ffmpeg_motion", + "ffmpeg_noise", + "fibaro", + "fido", + "file", + "filesize", + "filter", + "fints", + "fireservicerota", + "firmata", + "fitbit", + "fivem", + "fixer", + "fjaraskupan", + "fleetgo", + "flexit", + "flexit_bacnet", + "flic", + "flick_electric", + "flipr", + "flo", + "flock", + "flume", + "flux", + "flux_led", + "folder", + "folder_watcher", + "foobot", + "forecast_solar", + "forked_daapd", + "fortios", + "foscam", + "foursquare", + "free_mobile", + "freebox", + "freedns", + "freedompro", + "fritzbox", + "fritzbox_callmonitor", + "fronius", + "frontier_silicon", + "fujitsu_fglair", + "fujitsu_hvac", + "futurenow", + "fyta", + "garadget", + "garages_amsterdam", + "gardena_bluetooth", + "gc100", + "gdacs", + "generic", + "generic_hygrostat", + "generic_thermostat", + "geniushub", + "geo_json_events", + "geo_rss_events", + "geocaching", + "geofency", + "geonetnz_quakes", + "geonetnz_volcano", + "gios", + "github", + "gitlab_ci", + "gitter", + "glances", + "go2rtc", + "goalzero", + "gogogate2", + "goodwe", + "google", + "google_assistant", + "google_assistant_sdk", + "google_cloud", + "google_domains", + "google_generative_ai_conversation", + "google_mail", + "google_maps", + "google_photos", + "google_pubsub", + "google_sheets", + "google_tasks", + "google_translate", + "google_travel_time", + "google_wifi", + "govee_ble", + "govee_light_local", + "gpsd", + "gpslogger", + "graphite", + "gree", + "greeneye_monitor", + "greenwave", + "group", + "growatt_server", + "gstreamer", + "gtfs", + "guardian", + "habitica", + "harman_kardon_avr", + "harmony", + "hassio", + "haveibeenpwned", + "hddtemp", + "hdmi_cec", + "heatmiser", + "heos", + "here_travel_time", + "hikvision", + "hikvisioncam", + "hisense_aehw4a1", + "history_stats", + "hitron_coda", + "hive", + "hko", + "hlk_sw16", + "holiday", + "home_connect", + "homekit", + "homekit_controller", + "homematic", + "homematicip_cloud", + "homewizard", + "homeworks", + "honeywell", + "horizon", + "hp_ilo", + "html5", + "http", + "huawei_lte", + "hue", + "huisbaasje", + "hunterdouglas_powerview", + "husqvarna_automower", + "husqvarna_automower_ble", + "huum", + "hvv_departures", + "hydrawise", + "hyperion", + "ialarm", + "iammeter", + "iaqualink", + "ibeacon", + "icloud", + "idasen_desk", + "idteck_prox", + "ifttt", + "iglo", + "ign_sismologia", + "ihc", + "imap", + "imgw_pib", + "improv_ble", + "incomfort", + "influxdb", + "inkbird", + "insteon", + "integration", + "intellifire", + "intesishome", + "ios", + "iotawatt", + "iotty", + "iperf3", + "ipma", + "ipp", + "iqvia", + "irish_rail_transport", + "iron_os", + "isal", + "iskra", + "islamic_prayer_times", + "israel_rail", + "iss", + "ista_ecotrend", + "isy994", + "itach", + "itunes", + "izone", + "jellyfin", + "jewish_calendar", + "joaoapps_join", + "juicenet", + "justnimbus", + "jvc_projector", + "kaiterra", + "kaleidescape", + "kankun", + "keba", + "keenetic_ndms2", + "kef", + "kegtron", + "keyboard", + "keyboard_remote", + "keymitt_ble", + "kira", + "kitchen_sink", + "kiwi", + "kmtronic", + "knocki", + "knx", + "kodi", + "konnected", + "kostal_plenticore", + "kraken", + "kulersky", + "kwb", + "lacrosse", + "lacrosse_view", + "lamarzocco", + "lametric", + "landisgyr_heat_meter", + "lannouncer", + "lastfm", + "launch_library", + "laundrify", + "lcn", + "ld2410_ble", + "leaone", + "led_ble", + "lektrico", + "lg_netcast", + "lg_soundbar", + "lg_thinq", + "lidarr", + "life360", + "lifx", + "lifx_cloud", + "lightwave", + "limitlessled", + "linear_garage_door", + "linkplay", + "linksys_smart", + "linode", + "linux_battery", + "lirc", + "litejet", + "litterrobot", + "livisi", + "llamalab_automate", + "local_calendar", + "local_file", + "local_ip", + "local_todo", + "location", + "locative", + "logentries", + "logi_circle", + "london_air", + "london_underground", + "lookin", + "loqed", + "luci", + "luftdaten", + "lupusec", + "lutron", + "lutron_caseta", + "lw12wifi", + "lyric", + "madvr", + "mailbox", + "mailgun", + "manual", + "manual_mqtt", + "map", + "marytts", + "mastodon", + "matrix", + "matter", + "maxcube", + "mazda", + "mealie", + "meater", + "medcom_ble", + "media_extractor", + "mediaroom", + "melcloud", + "melissa", + "melnor", + "meraki", + "message_bird", + "met", + "met_eireann", + "meteo_france", + "meteoalarm", + "meteoclimatic", + "metoffice", + "mfi", + "microbees", + "microsoft", + "microsoft_face", + "microsoft_face_detect", + "microsoft_face_identify", + "mikrotik", + "mill", + "min_max", + "minecraft_server", + "minio", + "mjpeg", + "moat", + "mobile_app", + "mochad", + "modbus", + "modem_callerid", + "modern_forms", + "moehlenhoff_alpha2", + "mold_indicator", + "monarch_money", + "monoprice", + "monzo", + "moon", + "mopeka", + "motion_blinds", + "motionblinds_ble", + "motioneye", + "motionmount", + "mpd", + "mqtt", + "mqtt_eventstream", + "mqtt_json", + "mqtt_room", + "mqtt_statestream", + "msteams", + "mullvad", + "music_assistant", + "mutesync", + "mvglive", + "mycroft", + "myq", + "mysensors", + "mystrom", + "mythicbeastsdns", + "myuplink", + "nad", + "nam", + "namecheapdns", + "nanoleaf", + "nasweb", + "neato", + "nederlandse_spoorwegen", + "ness_alarm", + "nest", + "netatmo", + "netdata", + "netgear", + "netgear_lte", + "netio", + "network", + "neurio_energy", + "nexia", + "nextbus", + "nextcloud", + "nextdns", + "nfandroidtv", + "nibe_heatpump", + "nice_go", + "nightscout", + "niko_home_control", + "nilu", + "nina", + "nissan_leaf", + "nmap_tracker", + "nmbs", + "no_ip", + "noaa_tides", + "nobo_hub", + "nordpool", + "norway_air", + "notify_events", + "notion", + "nsw_fuel_station", + "nsw_rural_fire_service_feed", + "nuheat", + "nuki", + "numato", + "nut", + "nws", + "nx584", + "nyt_games", + "nzbget", + "oasa_telematics", + "obihai", + "octoprint", + "oem", + "ohmconnect", + "ollama", + "ombi", + "omnilogic", + "oncue", + "ondilo_ico", + "onewire", + "onkyo", + "onvif", + "open_meteo", + "openai_conversation", + "openalpr_cloud", + "openerz", + "openevse", + "openexchangerates", + "opengarage", + "openhardwaremonitor", + "openhome", + "opensensemap", + "opensky", + "opentherm_gw", + "openuv", + "openweathermap", + "opnsense", + "opower", + "opple", + "oralb", + "oru", + "orvibo", + "osoenergy", + "osramlightify", + "otbr", + "otp", + "ourgroceries", + "overkiz", + "ovo_energy", + "owntracks", + "p1_monitor", + "palazzetti", + "panasonic_bluray", + "panasonic_viera", + "pandora", + "panel_iframe", + "peco", + "pegel_online", + "pencom", + "permobil", + "persistent_notification", + "person", + "philips_js", + "pi_hole", + "picnic", + "picotts", + "pilight", + "ping", + "pioneer", + "pjlink", + "plaato", + "plant", + "plex", + "plugwise", + "plum_lightpad", + "pocketcasts", + "point", + "poolsense", + "powerwall", + "private_ble_device", + "profiler", + "progettihwsw", + "proliphix", + "prometheus", + "prosegur", + "prowl", + "proximity", + "proxmoxve", + "prusalink", + "ps4", + "pulseaudio_loopback", + "pure_energie", + "purpleair", + "push", + "pushbullet", + "pushover", + "pushsafer", + "pvoutput", + "pvpc_hourly_pricing", + "pyload", + "qbittorrent", + "qingping", + "qld_bushfire", + "qnap", + "qnap_qsw", + "qrcode", + "quantum_gateway", + "qvr_pro", + "qwikswitch", + "rabbitair", + "rachio", + "radarr", + "radio_browser", + "radiotherm", + "rainbird", + "raincloud", + "rainforest_eagle", + "rainforest_raven", + "rainmachine", + "random", + "rapt_ble", + "raspyrfm", + "rdw", + "recollect_waste", + "recorder", + "recswitch", + "reddit", + "refoss", + "rejseplanen", + "remember_the_milk", + "remote_rpi_gpio", + "renault", + "renson", + "reolink", + "repetier", + "rest", + "rest_command", + "rflink", + "rfxtrx", + "rhasspy", + "ridwell", + "ring", + "ripple", + "risco", + "rituals_perfume_genie", + "rmvtransport", + "roborock", + "rocketchat", + "roku", + "romy", + "roomba", + "roon", + "route53", + "rova", + "rpi_camera", + "rpi_power", + "rss_feed_template", + "rtorrent", + "rtsp_to_webrtc", + "ruckus_unleashed", + "russound_rio", + "russound_rnet", + "ruuvi_gateway", + "ruuvitag_ble", + "rympro", + "sabnzbd", + "saj", + "samsungtv", + "sanix", + "satel_integra", + "schlage", + "schluter", + "scrape", + "screenlogic", + "scsgate", + "season", + "sendgrid", + "sense", + "sensibo", + "sensirion_ble", + "sensorpro", + "sensorpush", + "sensoterra", + "sentry", + "senz", + "serial", + "serial_pm", + "sesame", + "seven_segments", + "seventeentrack", + "sfr_box", + "sharkiq", + "shell_command", + "shelly", + "shodan", + "shopping_list", + "sia", + "sigfox", + "sighthound", + "signal_messenger", + "simplefin", + "simplepush", + "simplisafe", + "simulated", + "sinch", + "sisyphus", + "sky_hub", + "sky_remote", + "skybeacon", + "skybell", + "slack", + "sleepiq", + "slide", + "slimproto", + "sma", + "smappee", + "smart_meter_texas", + "smartthings", + "smarttub", + "smarty", + "smhi", + "smlight", + "sms", + "smtp", + "snapcast", + "snips", + "snmp", + "snooz", + "solaredge", + "solaredge_local", + "solarlog", + "solax", + "soma", + "somfy_mylink", + "sonarr", + "songpal", + "sonos", + "sony_projector", + "soundtouch", + "spaceapi", + "spc", + "speedtestdotnet", + "spider", + "splunk", + "spotify", + "sql", + "squeezebox", + "srp_energy", + "ssdp", + "starline", + "starlingbank", + "starlink", + "startca", + "statistics", + "statsd", + "steam_online", + "steamist", + "stiebel_eltron", + "stookalert", + "stookwijzer", + "stream", + "streamlabswater", + "subaru", + "suez_water", + "sun", + "sunweg", + "supervisord", + "supla", + "surepetcare", + "swiss_hydrological_data", + "swiss_public_transport", + "swisscom", + "switch_as_x", + "switchbee", + "switchbot", + "switchbot_cloud", + "switcher_kis", + "switchmate", + "syncthing", + "syncthru", + "synology_chat", + "synology_dsm", + "synology_srm", + "syslog", + "system_bridge", + "systemmonitor", + "tado", + "tailscale", + "tailwind", + "tami4", + "tank_utility", + "tankerkoenig", + "tapsaff", + "tasmota", + "tautulli", + "tcp", + "technove", + "ted5000", + "tedee", + "telegram", + "telegram_bot", + "tellduslive", + "tellstick", + "telnet", + "temper", + "template", + "tensorflow", + "tesla_fleet", + "tesla_wall_connector", + "teslemetry", + "tessie", + "tfiac", + "thermobeacon", + "thermopro", + "thermoworks_smoke", + "thethingsnetwork", + "thingspeak", + "thinkingcleaner", + "thomson", + "thread", + "threshold", + "tibber", + "tikteck", + "tile", + "tilt_ble", + "time_date", + "tmb", + "tod", + "todoist", + "tolo", + "tomato", + "tomorrowio", + "toon", + "torque", + "totalconnect", + "touchline", + "touchline_sl", + "tplink", + "tplink_lte", + "tplink_omada", + "traccar", + "traccar_server", + "tractive", + "tradfri", + "trafikverket_camera", + "trafikverket_ferry", + "trafikverket_train", + "trafikverket_weatherstation", + "transmission", + "transport_nsw", + "travisci", + "trend", + "triggercmd", + "tuya", + "twilio", + "twilio_call", + "twilio_sms", + "twinkly", + "twitch", + "twitter", + "ubus", + "uk_transport", + "ukraine_alarm", + "unifi", + "unifi_direct", + "unifiled", + "unifiprotect", + "universal", + "upb", + "upc_connect", + "upcloud", + "upnp", + "uptime", + "uptimerobot", + "usb", + "usgs_earthquakes_feed", + "utility_meter", + "uvc", + "v2c", + "vallox", + "vasttrafik", + "velbus", + "velux", + "venstar", + "vera", + "verisure", + "versasense", + "version", + "vesync", + "viaggiatreno", + "vicare", + "vilfo", + "vivotek", + "vizio", + "vlc", + "vlc_telnet", + "vodafone_station", + "voicerss", + "voip", + "volkszaehler", + "volumio", + "volvooncall", + "vulcan", + "vultr", + "w800rf32", + "wake_on_lan", + "wallbox", + "waqi", + "waterfurnace", + "watson_iot", + "watson_tts", + "watttime", + "waze_travel_time", + "weatherflow", + "weatherflow_cloud", + "weatherkit", + "webmin", + "webostv", + "weheat", + "wemo", + "whirlpool", + "whois", + "wiffi", + "wilight", + "wirelesstag", + "withings", + "wiz", + "wled", + "wmspro", + "wolflink", + "workday", + "worldclock", + "worldtidesinfo", + "worxlandroid", + "ws66i", + "wsdot", + "wyoming", + "x10", + "xbox", + "xeoma", + "xiaomi", + "xiaomi_aqara", + "xiaomi_ble", + "xiaomi_miio", + "xiaomi_tv", + "xmpp", + "xs1", + "yale", + "yale_smart_alarm", + "yalexs_ble", + "yamaha", + "yamaha_musiccast", + "yandex_transport", + "yandextts", + "yardian", + "yeelight", + "yeelightsunflower", + "yi", + "yolink", + "youless", + "youtube", + "zabbix", + "zamg", + "zengge", + "zeroconf", + "zerproc", + "zestimate", + "zeversolar", + "zha", + "zhong_hong", + "ziggo_mediabox_xl", + "zodiac", + "zoneminder", + "zwave_js", + "zwave_me", +] + +NO_QUALITY_SCALE = [ + *{platform.value for platform in Platform}, + "api", + "application_credentials", + "auth", + "automation", + "blueprint", + "config", + "configurator", + "counter", + "default_config", + "device_automation", + "device_tracker", + "diagnostics", + "ffmpeg", + "file_upload", + "frontend", + "hardkernel", + "hardware", + "history", + "homeassistant", + "homeassistant_alerts", + "homeassistant_green", + "homeassistant_hardware", + "homeassistant_sky_connect", + "homeassistant_yellow", + "image_upload", + "input_boolean", + "input_button", + "input_datetime", + "input_number", + "input_select", + "input_text", + "intent_script", + "intent", + "logbook", + "logger", + "lovelace", + "media_source", + "my", + "onboarding", + "panel_custom", + "proxy", + "python_script", + "raspberry_pi", + "recovery_mode", + "repairs", + "schedule", + "script", + "search", + "system_health", + "system_log", + "tag", + "timer", + "trace", + "webhook", + "websocket_api", + "zone", +] + SCHEMA = vol.Schema( { vol.Required("rules"): vol.Schema( @@ -93,10 +1273,40 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: """Validate quality scale file for integration.""" + if not integration.core: + return iqs_file = integration.path / "quality_scale.yaml" - if not iqs_file.is_file(): + has_file = iqs_file.is_file() + if not has_file: + if ( + integration.domain not in INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE + and integration.domain not in NO_QUALITY_SCALE + and integration.integration_type != "virtual" + ): + integration.add_error( + "quality_scale", + "Quality scale definition not found. New integrations are required to at least reach the Bronze tier.", + ) + return + return + if integration.integration_type == "virtual": + integration.add_error( + "quality_scale", + "Virtual integrations are not allowed to have a quality scale file.", + ) + return + if integration.domain in NO_QUALITY_SCALE: + integration.add_error( + "quality_scale", + "This integration is not supposed to have a quality scale file.", + ) + return + if integration.domain in INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE: + integration.add_error( + "quality_scale", + "Quality scale file found! Please remove from quality_scale.py", + ) return - name = str(iqs_file) try: From c6c7e86548a5865e27b83be9857ed9ad0454faca Mon Sep 17 00:00:00 2001 From: rappenze Date: Thu, 21 Nov 2024 21:16:37 +0100 Subject: [PATCH 0672/1070] Fix fibaro cover state is not always correct (#131206) --- homeassistant/components/fibaro/cover.py | 67 ++++++++---------------- 1 file changed, 23 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index c787ca702726c..0898d1c931842 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -69,37 +69,29 @@ def _is_open_close_only(self) -> bool: # so if it is missing we have a device which supports open / close only return not self.fibaro_device.value.has_value - @property - def current_cover_position(self) -> int | None: - """Return current position of cover. 0 is closed, 100 is open.""" - return self.bound(self.level) - - @property - def current_cover_tilt_position(self) -> int | None: - """Return the current tilt position for venetian blinds.""" - return self.bound(self.level2) - - @property - def is_opening(self) -> bool | None: - """Return if the cover is opening or not. - - Be aware that this property is only available for some modern devices. - For example the Fibaro Roller Shutter 4 reports this correctly. - """ - if self.fibaro_device.state.has_value: - return self.fibaro_device.state.str_value().lower() == "opening" - return None - - @property - def is_closing(self) -> bool | None: - """Return if the cover is closing or not. - - Be aware that this property is only available for some modern devices. - For example the Fibaro Roller Shutter 4 reports this correctly. - """ - if self.fibaro_device.state.has_value: - return self.fibaro_device.state.str_value().lower() == "closing" - return None + def update(self) -> None: + """Update the state.""" + super().update() + + self._attr_current_cover_position = self.bound(self.level) + self._attr_current_cover_tilt_position = self.bound(self.level2) + + device_state = self.fibaro_device.state + + # Be aware that opening and closing is only available for some modern + # devices. + # For example the Fibaro Roller Shutter 4 reports this correctly. + if device_state.has_value: + self._attr_is_opening = device_state.str_value().lower() == "opening" + self._attr_is_closing = device_state.str_value().lower() == "closing" + + closed: bool | None = None + if self._is_open_close_only(): + if device_state.has_value and device_state.str_value().lower() != "unknown": + closed = device_state.str_value().lower() == "closed" + elif self.current_cover_position is not None: + closed = self.current_cover_position == 0 + self._attr_is_closed = closed def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" @@ -109,19 +101,6 @@ def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" self.set_level2(cast(int, kwargs.get(ATTR_TILT_POSITION))) - @property - def is_closed(self) -> bool | None: - """Return if the cover is closed.""" - if self._is_open_close_only(): - state = self.fibaro_device.state - if not state.has_value or state.str_value().lower() == "unknown": - return None - return state.str_value().lower() == "closed" - - if self.current_cover_position is None: - return None - return self.current_cover_position == 0 - def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self.action("open") From d6170eb071669a390c1c48088fca05feb200de0e Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 21 Nov 2024 21:16:54 +0100 Subject: [PATCH 0673/1070] Bump pyenphase to 1.23.0 (#131205) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index aa06a1ff79f45..bdc90e6c63483 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.22.0"], + "requirements": ["pyenphase==1.23.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index be70978ce45e5..83358974e84f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1892,7 +1892,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.22.0 +pyenphase==1.23.0 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf9824b701242..833cf283f1550 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1527,7 +1527,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.22.0 +pyenphase==1.23.0 # homeassistant.components.everlights pyeverlights==0.1.0 From 2a6e08caf9a7a31fb9a76dee7bafc97a23cfc85f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 21 Nov 2024 21:23:05 +0100 Subject: [PATCH 0674/1070] Add missing unique_id check on blink user flows (#131209) --- homeassistant/components/blink/config_flow.py | 4 ++- tests/components/blink/test_config_flow.py | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index 62f15bd6e10c8..e37df26aaa818 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -10,7 +10,7 @@ from blinkpy.blinkpy import Blink, BlinkSetupError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_PIN, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -61,6 +61,8 @@ async def async_step_user( session=async_get_clientsession(self.hass), ) await self.async_set_unique_id(user_input[CONF_USERNAME]) + if self.source != SOURCE_REAUTH: + self._abort_if_unique_id_configured() try: await validate_input(self.auth) diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index c89ab65ea1d5c..ec1a8b95e0d59 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -55,6 +55,35 @@ async def test_form(hass: HomeAssistant) -> None: } assert len(mock_setup_entry.mock_calls) == 1 + # Now check for duplicates + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch("homeassistant.components.blink.config_flow.Auth.startup"), + patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=False, + ), + patch( + "homeassistant.components.blink.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "blink@example.com", "password": "example"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + assert len(mock_setup_entry.mock_calls) == 0 + async def test_form_2fa(hass: HomeAssistant) -> None: """Test we get the 2fa form.""" From de9f9f32c75fb4fa8da496c1ee5faab517b3d490 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 21 Nov 2024 21:25:23 +0100 Subject: [PATCH 0675/1070] Add translation for ConfigEntryAuthFailed to lamarzocco (#131145) --- homeassistant/components/lamarzocco/coordinator.py | 7 ++++--- homeassistant/components/lamarzocco/strings.json | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 05fee98c5997e..646aad0e8dd26 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -126,9 +126,10 @@ async def _async_handle_request[**_P]( try: await func(*args, **kwargs) except AuthFail as ex: - msg = "Authentication failed." - _LOGGER.debug(msg, exc_info=True) - raise ConfigEntryAuthFailed(msg) from ex + _LOGGER.debug("Authentication failed", exc_info=True) + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from ex except RequestNotSuccessful as ex: _LOGGER.debug(ex, exc_info=True) raise UpdateFailed(f"Querying API failed. Error: {ex}") from ex diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 959dda265a945..f9621d7cd3ba0 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -196,6 +196,9 @@ } }, "exceptions": { + "authentication_failed": { + "message": "Authentication failed" + }, "auto_on_off_error": { "message": "Error while setting auto on/off to {state} for {id}" }, From 96849f2e16de8e5708e66324102d98e425ff36a3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 21:26:50 +0100 Subject: [PATCH 0676/1070] =?UTF-8?q?Change=20"Add=20=E2=80=A6"=20to=20"Cr?= =?UTF-8?q?eate=20=E2=80=A6"=20for=20consistency=20(#131198)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/integration/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/integration/strings.json b/homeassistant/components/integration/strings.json index 6186521aa1b72..ed4f5de3ea7a9 100644 --- a/homeassistant/components/integration/strings.json +++ b/homeassistant/components/integration/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Add Riemann sum integral sensor", + "title": "Create Riemann sum integral sensor", "description": "Create a sensor that calculates a Riemann sum to estimate the integral of a sensor.", "data": { "method": "Integration method", From f64c81b7f85d8ea426d41cd27ba40bdf13d4f1cd Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 21:27:26 +0100 Subject: [PATCH 0677/1070] =?UTF-8?q?Change=20"Add=20=E2=80=A6"=20to=20"Cr?= =?UTF-8?q?eate=20=E2=80=A6"=20for=20consistency=20(#131199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/threshold/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/threshold/strings.json b/homeassistant/components/threshold/strings.json index fc9ee8fb7bf2c..94a1932cbbcc4 100644 --- a/homeassistant/components/threshold/strings.json +++ b/homeassistant/components/threshold/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Add Threshold Sensor", + "title": "Create Threshold Sensor", "description": "Create a binary sensor that turns on and off depending on the value of a sensor\n\nOnly lower limit configured - Turn on when the input sensor's value is less than the lower limit.\nOnly upper limit configured - Turn on when the input sensor's value is greater than the upper limit.\nBoth lower and upper limit configured - Turn on when the input sensor's value is in the range [lower limit .. upper limit].", "data": { "entity_id": "Input sensor", From a67c5e1fba491ed94216bd3d422a9c3e1ec2ab9c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 21:27:54 +0100 Subject: [PATCH 0678/1070] =?UTF-8?q?Change=20"Add=20=E2=80=A6"=20to=20"Cr?= =?UTF-8?q?eate=20=E2=80=A6"=20for=20consistency=20(#131197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/derivative/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json index 4b66c893d570c..bfdf861a019a1 100644 --- a/homeassistant/components/derivative/strings.json +++ b/homeassistant/components/derivative/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Add Derivative sensor", + "title": "Create Derivative sensor", "description": "Create a sensor that estimates the derivative of a sensor.", "data": { "name": "[%key:common::config_flow::data::name%]", From ed3140da76f57d02bbfb98ffe3a36f57d9f3b2d0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 21:33:30 +0100 Subject: [PATCH 0679/1070] Input number: Make description of decrement option consistent (#131089) --- homeassistant/components/input_number/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/input_number/strings.json b/homeassistant/components/input_number/strings.json index 8a2351ebad46b..ed6b6fad2081d 100644 --- a/homeassistant/components/input_number/strings.json +++ b/homeassistant/components/input_number/strings.json @@ -41,7 +41,7 @@ }, "increment": { "name": "Increment", - "description": "Increments the value by 1 step." + "description": "Increments the current value by 1 step." }, "set_value": { "name": "Set", From d4bc200cef7d063af4ce44cee2491969d5c6449d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 21:34:31 +0100 Subject: [PATCH 0680/1070] Improve description of Random helper by removing repetition (#131092) --- homeassistant/components/random/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index ef19dd6dd670e..ff44290d36821 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -20,7 +20,7 @@ "title": "Random sensor" }, "user": { - "description": "This helper allows you to create a helper that emits a random value.", + "description": "This helper allows you to create an entity that emits a random value.", "menu_options": { "binary_sensor": "Random binary sensor", "sensor": "Random sensor" From c81edfea4424366734722d41975ec4d4f05dbfe7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 21:35:01 +0100 Subject: [PATCH 0681/1070] Fix alarm_control_panel translation string (#131157) --- homeassistant/components/alarm_control_panel/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 6dac4d069a18f..733e02954c107 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -130,7 +130,7 @@ }, "alarm_trigger": { "name": "Trigger", - "description": "Enables an external alarm trigger.", + "description": "Trigger the alarm manually.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", From c0396a12685e7512ad985196d999a850f92cc4ec Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 21:35:31 +0100 Subject: [PATCH 0682/1070] Fix Xiaomi Miio translation strings (#131154) --- .../components/xiaomi_miio/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index 31fe547b1625b..bafc1ec543b26 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -216,22 +216,22 @@ "name": "Air quality index" }, "filter_life_remaining": { - "name": "Filter lifetime remaining" + "name": "Filter life remaining" }, "filter_hours_used": { "name": "Filter use" }, "filter_left_time": { - "name": "Filter lifetime left" + "name": "Filter lifetime remaining" }, "dust_filter_life_remaining": { - "name": "Dust filter lifetime remaining" + "name": "Dust filter life remaining" }, "dust_filter_life_remaining_days": { "name": "Dust filter lifetime remaining days" }, "upper_filter_life_remaining": { - "name": "Upper filter lifetime remaining" + "name": "Upper filter life remaining" }, "upper_filter_life_remaining_days": { "name": "Upper filter lifetime remaining days" @@ -276,16 +276,16 @@ "name": "Total dust collection count" }, "main_brush_left": { - "name": "Main brush left" + "name": "Main brush remaining" }, "side_brush_left": { - "name": "Side brush left" + "name": "Side brush remaining" }, "filter_left": { - "name": "Filter left" + "name": "Filter remaining" }, "sensor_dirty_left": { - "name": "Sensor dirty left" + "name": "Sensor dirty remaining" } }, "switch": { From 88b54bbaf7f5997fc42c87ccd3cec2918e24dde1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 21:36:06 +0100 Subject: [PATCH 0683/1070] Fix calendar translation strings (#131160) --- homeassistant/components/calendar/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 76e6c42b6663d..c0127c20d052d 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -82,11 +82,11 @@ }, "end_date_time": { "name": "End time", - "description": "Returns active events before this time (exclusive). Cannot be used with 'duration'." + "description": "Returns active events before this time (exclusive). Cannot be used with Duration." }, "duration": { "name": "Duration", - "description": "Returns active events from start_date_time until the specified duration." + "description": "Returns active events from Start time for the specified duration." } } } From da023ffbd519a68cabd227a4daf383366f3c5f59 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 21 Nov 2024 21:36:54 +0100 Subject: [PATCH 0684/1070] Use config entry title as sensor name in Filesize (#131109) * Use config entry title as sensor name in Filesize * snapshot * snapshot --- homeassistant/components/filesize/sensor.py | 2 - .../filesize/snapshots/test_sensor.ambr | 40 +++++++++---------- tests/components/filesize/test_sensor.py | 21 +++++++--- 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index dfab815739bb2..2eb170af99d9a 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -82,7 +82,6 @@ def __init__( ) -> None: """Initialize the Filesize sensor.""" super().__init__(coordinator) - base_name = str(coordinator.path.absolute()).rsplit("/", maxsplit=1)[-1] self._attr_unique_id = ( entry_id if description.key == "file" else f"{entry_id}-{description.key}" ) @@ -90,7 +89,6 @@ def __init__( self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry_id)}, - name=base_name, ) @property diff --git a/tests/components/filesize/snapshots/test_sensor.ambr b/tests/components/filesize/snapshots/test_sensor.ambr index fb3702e02554a..339d64acf91de 100644 --- a/tests/components/filesize/snapshots/test_sensor.ambr +++ b/tests/components/filesize/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[load_platforms0][sensor.file_txt_created-entry] +# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_created-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,7 +11,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.file_txt_created', + 'entity_id': 'sensor.mock_file_test_filesize_txt_created', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -32,21 +32,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[load_platforms0][sensor.file_txt_created-state] +# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_created-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'file.txt Created', + 'friendly_name': 'mock_file_test_filesize.txt Created', }), 'context': , - 'entity_id': 'sensor.file_txt_created', + 'entity_id': 'sensor.mock_file_test_filesize_txt_created', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2024-11-20T18:19:04+00:00', }) # --- -# name: test_sensors[load_platforms0][sensor.file_txt_last_updated-entry] +# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_last_updated-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -58,7 +58,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.file_txt_last_updated', + 'entity_id': 'sensor.mock_file_test_filesize_txt_last_updated', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -79,21 +79,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[load_platforms0][sensor.file_txt_last_updated-state] +# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_last_updated-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'file.txt Last updated', + 'friendly_name': 'mock_file_test_filesize.txt Last updated', }), 'context': , - 'entity_id': 'sensor.file_txt_last_updated', + 'entity_id': 'sensor.mock_file_test_filesize_txt_last_updated', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2024-11-20T18:19:24+00:00', }) # --- -# name: test_sensors[load_platforms0][sensor.file_txt_size-entry] +# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_size-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -107,7 +107,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.file_txt_size', + 'entity_id': 'sensor.mock_file_test_filesize_txt_size', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -128,23 +128,23 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[load_platforms0][sensor.file_txt_size-state] +# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_size-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', - 'friendly_name': 'file.txt Size', + 'friendly_name': 'mock_file_test_filesize.txt Size', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.file_txt_size', + 'entity_id': 'sensor.mock_file_test_filesize_txt_size', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_sensors[load_platforms0][sensor.file_txt_size_in_bytes-entry] +# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_size_in_bytes-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -158,7 +158,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.file_txt_size_in_bytes', + 'entity_id': 'sensor.mock_file_test_filesize_txt_size_in_bytes', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -179,16 +179,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[load_platforms0][sensor.file_txt_size_in_bytes-state] +# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_size_in_bytes-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', - 'friendly_name': 'file.txt Size in bytes', + 'friendly_name': 'mock_file_test_filesize.txt Size in bytes', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.file_txt_size_in_bytes', + 'entity_id': 'sensor.mock_file_test_filesize_txt_size_in_bytes', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/filesize/test_sensor.py b/tests/components/filesize/test_sensor.py index 62554b15b8ed5..8292800a86168 100644 --- a/tests/components/filesize/test_sensor.py +++ b/tests/components/filesize/test_sensor.py @@ -7,9 +7,10 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.filesize.const import DOMAIN from homeassistant.const import CONF_FILE_PATH, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from . import TEST_FILE_NAME, async_create_file @@ -68,7 +69,10 @@ async def test_invalid_path( async def test_valid_path( - hass: HomeAssistant, tmp_path: Path, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + tmp_path: Path, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test for a valid path.""" testfile = str(tmp_path.joinpath("file.txt")) @@ -82,10 +86,15 @@ async def test_valid_path( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.file_txt_size") + state = hass.states.get("sensor.mock_file_test_filesize_txt_size") assert state assert state.state == "0.0" + device = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device.name == mock_config_entry.title + await hass.async_add_executor_job(os.remove, testfile) @@ -104,12 +113,12 @@ async def test_state_unavailable( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.file_txt_size") + state = hass.states.get("sensor.mock_file_test_filesize_txt_size") assert state assert state.state == "0.0" await hass.async_add_executor_job(os.remove, testfile) - await async_update_entity(hass, "sensor.file_txt_size") + await async_update_entity(hass, "sensor.mock_file_test_filesize_txt_size") - state = hass.states.get("sensor.file_txt_size") + state = hass.states.get("sensor.mock_file_test_filesize_txt_size") assert state.state == STATE_UNAVAILABLE From 52147b151504fd46a28cd147f99cbfff63d507ba Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 21 Nov 2024 21:37:50 +0100 Subject: [PATCH 0685/1070] Fix group translation strings (#131150) --- homeassistant/components/group/strings.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index dbb6fb01f7b0c..cf694af0d98ad 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Add Group", + "title": "Create Group", "description": "Groups allow you to create a new entity that represents multiple entities of the same type.", "menu_options": { "binary_sensor": "Binary sensor group", @@ -283,20 +283,20 @@ }, "issues": { "uoms_not_matching_device_class": { - "title": "Unit of measurements are not correct", - "description": "Unit of measurements `{uoms}` of input sensors `{source_entities}` are not compatible and can't be converted with the device class `{device_class}` of sensor group `{entity_id}`.\n\nPlease correct the unit of measurements on the source entities and reload the group sensor to fix this issue." + "title": "Units of measurement are not correct", + "description": "Units of measurement `{uoms}` of input sensors `{source_entities}` are not compatible and can't be converted with the device class `{device_class}` of sensor group `{entity_id}`.\n\nPlease correct the unit of measurement on the source entities and reload the group sensor to fix this issue." }, "uoms_not_matching_no_device_class": { - "title": "Unit of measurements is not correct", - "description": "Unit of measurements `{uoms}` of input sensors `{source_entities}` are not compatible when not using a device class on sensor group `{entity_id}`.\n\nPlease correct the unit of measurements on the source entities or set a proper device class on the sensor group and reload the group sensor to fix this issue." + "title": "Units of measurement are not correct", + "description": "Units of measurement `{uoms}` of input sensors `{source_entities}` are not compatible when not using a device class on sensor group `{entity_id}`.\n\nPlease correct the unit of measurement on the source entities or set a proper device class on the sensor group and reload the group sensor to fix this issue." }, "device_classes_not_matching": { - "title": "Device classes is not correct", - "description": "Device classes `{device_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the device classes on the source entities and reload the group sensor to fix this issue." + "title": "Device classes are not correct", + "description": "Device classes `{device_classes}` on source entities `{source_entities}` need to be identical for sensor group `{entity_id}`.\n\nPlease correct the device classes on the source entities and reload the group sensor to fix this issue." }, "state_classes_not_matching": { - "title": "State classes is not correct", - "description": "State classes `{state_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the state classes on the source entities and reload the group sensor to fix this issue." + "title": "State classes are not correct", + "description": "State classes `{state_classes}` on source entities `{source_entities}` need to be identical for sensor group `{entity_id}`.\n\nPlease correct the state classes on the source entities and reload the group sensor to fix this issue." } } } From 158b0c8ce2722c591522015c1d947dc6640e89c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20=C3=98yvind=20=C3=98ygard?= <17528+peroo@users.noreply.github.com> Date: Thu, 21 Nov 2024 22:02:15 +0100 Subject: [PATCH 0686/1070] Bump pytouchlinesl to 0.2.0 (#131088) --- homeassistant/components/touchline_sl/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json index 063f772658715..ca3136f55c05c 100644 --- a/homeassistant/components/touchline_sl/manifest.json +++ b/homeassistant/components/touchline_sl/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/touchline_sl", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["pytouchlinesl==0.1.9"] + "requirements": ["pytouchlinesl==0.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 83358974e84f6..0a4b66f18ce74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2435,7 +2435,7 @@ pytomorrowio==0.3.6 pytouchline==0.7 # homeassistant.components.touchline_sl -pytouchlinesl==0.1.9 +pytouchlinesl==0.2.0 # homeassistant.components.traccar # homeassistant.components.traccar_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 833cf283f1550..56f67f8541a7a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1947,7 +1947,7 @@ pytile==2023.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline_sl -pytouchlinesl==0.1.9 +pytouchlinesl==0.2.0 # homeassistant.components.traccar # homeassistant.components.traccar_server From c94122e105c7556fa111f0ddeb21dbec18a7feb0 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Thu, 21 Nov 2024 22:50:33 +0100 Subject: [PATCH 0687/1070] Fix manifest.json schema violations (#131220) --- .../components/husqvarna_automower_ble/manifest.json | 2 +- homeassistant/components/lg_thinq/manifest.json | 3 +-- homeassistant/components/nasweb/manifest.json | 5 +---- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index 3e72d9707c779..7566b5c9d32bb 100644 --- a/homeassistant/components/husqvarna_automower_ble/manifest.json +++ b/homeassistant/components/husqvarna_automower_ble/manifest.json @@ -10,7 +10,7 @@ "codeowners": ["@alistair23"], "config_flow": true, "dependencies": ["bluetooth_adapters"], - "documentation": "https://www.home-assistant.io/integrations/???", + "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble", "iot_class": "local_polling", "requirements": ["automower-ble==0.2.0"] } diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index 5ffd9041676b8..daab135309818 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -3,8 +3,7 @@ "name": "LG ThinQ", "codeowners": ["@LG-ThinQ-Integration"], "config_flow": true, - "dependencies": [], - "documentation": "https://www.home-assistant.io/integrations/lg_thinq/", + "documentation": "https://www.home-assistant.io/integrations/lg_thinq", "iot_class": "cloud_push", "loggers": ["thinqconnect"], "requirements": ["thinqconnect==1.0.1"] diff --git a/homeassistant/components/nasweb/manifest.json b/homeassistant/components/nasweb/manifest.json index e7e06419dade1..69efdafbc8292 100644 --- a/homeassistant/components/nasweb/manifest.json +++ b/homeassistant/components/nasweb/manifest.json @@ -5,10 +5,7 @@ "config_flow": true, "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/nasweb", - "homekit": {}, "integration_type": "hub", "iot_class": "local_push", - "requirements": ["webio-api==0.1.8"], - "ssdp": [], - "zeroconf": [] + "requirements": ["webio-api==0.1.8"] } From 8a292184a5966b870ce141fc19b12b5b448a3dc4 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Thu, 21 Nov 2024 17:03:08 -0500 Subject: [PATCH 0688/1070] Add data_description for password in Fully Kiosk config flow (#131222) * Add data_description for password * Update phrasing * Add data_description in discovery as well --- homeassistant/components/fully_kiosk/strings.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index 9c0049d3e5f1d..ec7bd7b1c03a8 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -1,10 +1,16 @@ { + "common": { + "data_description_password": "The Remote Admin Password from the Fully Kiosk Browser app settings." + }, "config": { "step": { "discovery_confirm": { "description": "Do you want to set up {name} ({host})?", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::fully_kiosk::common::data_description_password%]" } }, "user": { @@ -15,7 +21,8 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "The hostname or IP address of the device running your Fully Kiosk Browser application." + "host": "The hostname or IP address of the device running your Fully Kiosk Browser application.", + "password": "[%key:component::fully_kiosk::common::data_description_password%]" } } }, From fa3d2a30310ddd6ae7f835bcf88c0ac23bfe825e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 22 Nov 2024 07:58:43 +0100 Subject: [PATCH 0689/1070] Remove configurable name in config flow from SABnzbd (#131073) --- homeassistant/components/sabnzbd/config_flow.py | 5 ++--- homeassistant/components/sabnzbd/const.py | 1 - homeassistant/components/sabnzbd/strings.json | 1 - tests/components/sabnzbd/conftest.py | 3 +-- tests/components/sabnzbd/test_config_flow.py | 4 +--- 5 files changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sabnzbd/config_flow.py b/homeassistant/components/sabnzbd/config_flow.py index 0d0934fee11b7..b3bf48a252b3a 100644 --- a/homeassistant/components/sabnzbd/config_flow.py +++ b/homeassistant/components/sabnzbd/config_flow.py @@ -8,9 +8,9 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL +from homeassistant.const import CONF_API_KEY, CONF_URL -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN from .sab import get_client _LOGGER = logging.getLogger(__name__) @@ -18,7 +18,6 @@ USER_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): str, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, vol.Required(CONF_URL): str, } ) diff --git a/homeassistant/components/sabnzbd/const.py b/homeassistant/components/sabnzbd/const.py index 55346509133d2..991490f5716fe 100644 --- a/homeassistant/components/sabnzbd/const.py +++ b/homeassistant/components/sabnzbd/const.py @@ -7,7 +7,6 @@ ATTR_API_KEY = "api_key" DEFAULT_HOST = "localhost" -DEFAULT_NAME = "SABnzbd" DEFAULT_PORT = 8080 DEFAULT_SPEED_LIMIT = "100" DEFAULT_SSL = False diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 5d573c6a8cb5a..d21ae1fd21933 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -4,7 +4,6 @@ "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", - "name": "[%key:common::config_flow::data::name%]", "url": "[%key:common::config_flow::data::url%]" }, "data_description": { diff --git a/tests/components/sabnzbd/conftest.py b/tests/components/sabnzbd/conftest.py index fc01429378b90..08cca82d3b6ad 100644 --- a/tests/components/sabnzbd/conftest.py +++ b/tests/components/sabnzbd/conftest.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.sabnzbd import DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL +from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -42,7 +42,6 @@ async def mock_config_entry(hass: HomeAssistant, sabnzbd: AsyncMock) -> MockConf title="Sabnzbd", entry_id="01JD2YVVPBC62D620DGYNG2R8H", data={ - CONF_NAME: "Sabnzbd", CONF_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0", CONF_URL: "http://localhost:8080", }, diff --git a/tests/components/sabnzbd/test_config_flow.py b/tests/components/sabnzbd/test_config_flow.py index fc0205915b841..aefbfedb4b52f 100644 --- a/tests/components/sabnzbd/test_config_flow.py +++ b/tests/components/sabnzbd/test_config_flow.py @@ -8,12 +8,11 @@ from homeassistant import config_entries from homeassistant.components.sabnzbd import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL +from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType VALID_CONFIG = { - CONF_NAME: "Sabnzbd", CONF_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0", CONF_URL: "http://localhost:8080", } @@ -43,7 +42,6 @@ async def test_create_entry(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> assert result2["title"] == "edc3eee7330e" assert result2["data"] == { CONF_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0", - CONF_NAME: "Sabnzbd", CONF_URL: "http://localhost:8080", } assert len(mock_setup_entry.mock_calls) == 1 From caac22f09fcae72e03c4b4831dee40cf06cc920d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 22 Nov 2024 08:44:33 +0100 Subject: [PATCH 0690/1070] Improve SABnzbd config flow tests (#131234) --- tests/components/sabnzbd/conftest.py | 6 +- tests/components/sabnzbd/test_config_flow.py | 77 +++++++++++--------- 2 files changed, 47 insertions(+), 36 deletions(-) diff --git a/tests/components/sabnzbd/conftest.py b/tests/components/sabnzbd/conftest.py index 08cca82d3b6ad..67243a6a19893 100644 --- a/tests/components/sabnzbd/conftest.py +++ b/tests/components/sabnzbd/conftest.py @@ -22,7 +22,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -@pytest.fixture(name="sabnzbd") +@pytest.fixture(name="sabnzbd", autouse=True) def mock_sabnzbd() -> Generator[AsyncMock]: """Mock the Sabnzbd API.""" with patch( @@ -35,7 +35,7 @@ def mock_sabnzbd() -> Generator[AsyncMock]: @pytest.fixture(name="config_entry") -async def mock_config_entry(hass: HomeAssistant, sabnzbd: AsyncMock) -> MockConfigEntry: +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return a MockConfigEntry for testing.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -53,7 +53,7 @@ async def mock_config_entry(hass: HomeAssistant, sabnzbd: AsyncMock) -> MockConf @pytest.fixture(name="setup_integration") async def mock_setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry, sabnzbd: AsyncMock + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Fixture for setting up the component.""" assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/sabnzbd/test_config_flow.py b/tests/components/sabnzbd/test_config_flow.py index aefbfedb4b52f..969e379160c53 100644 --- a/tests/components/sabnzbd/test_config_flow.py +++ b/tests/components/sabnzbd/test_config_flow.py @@ -1,6 +1,6 @@ """Define tests for the Sabnzbd config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from pysabnzbd import SabnzbdApiException import pytest @@ -28,35 +28,46 @@ async def test_create_entry(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.sabnzbd.sab.SabnzbdApi.check_available", - return_value=True, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - VALID_CONFIG, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "edc3eee7330e" - assert result2["data"] == { - CONF_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0", - CONF_URL: "http://localhost:8080", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_auth_error(hass: HomeAssistant) -> None: - """Test that the user step fails.""" - with patch( - "homeassistant.components.sabnzbd.sab.SabnzbdApi.check_available", - side_effect=SabnzbdApiException("Some error"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, - ) - - assert result["errors"] == {"base": "cannot_connect"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "edc3eee7330e" + assert result["data"] == { + CONF_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0", + CONF_URL: "http://localhost:8080", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_auth_error(hass: HomeAssistant, sabnzbd: AsyncMock) -> None: + """Test when the user step fails and if we can recover.""" + sabnzbd.check_available.side_effect = SabnzbdApiException("Some error") + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) + + assert result["errors"] == {"base": "cannot_connect"} + + # reset side effect and check if we can recover + sabnzbd.check_available.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert "errors" not in result + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "edc3eee7330e" + assert result["data"] == { + CONF_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0", + CONF_URL: "http://localhost:8080", + } From 0ce8d8d749bb163a2f8fa6fdaa01c2aff3037491 Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Fri, 22 Nov 2024 02:47:59 -0500 Subject: [PATCH 0691/1070] Upgrade to ayla-iot-unofficial 1.4.4 (#131228) --- homeassistant/components/fujitsu_fglair/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json index f7f3af8d03750..ea08a2cfe023d 100644 --- a/homeassistant/components/fujitsu_fglair/manifest.json +++ b/homeassistant/components/fujitsu_fglair/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair", "iot_class": "cloud_polling", - "requirements": ["ayla-iot-unofficial==1.4.3"] + "requirements": ["ayla-iot-unofficial==1.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a4b66f18ce74..f733cac09e916 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -546,7 +546,7 @@ av==13.1.0 axis==63 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.3 +ayla-iot-unofficial==1.4.4 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 56f67f8541a7a..af76662f048e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -495,7 +495,7 @@ av==13.1.0 axis==63 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.3 +ayla-iot-unofficial==1.4.4 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 From 764f72bdc3ad8cff9482f212e5095e5b953bc23d Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 22 Nov 2024 08:49:46 +0100 Subject: [PATCH 0692/1070] Patch entry setup in lamarzocco tests (#131217) --- tests/components/lamarzocco/conftest.py | 11 ++++++++++- tests/components/lamarzocco/test_config_flow.py | 12 +++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index 210dd9406cc9e..9c568962e3465 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -2,7 +2,7 @@ from collections.abc import Generator import json -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from bleak.backends.device import BLEDevice from pylamarzocco.const import FirmwareType, MachineModel, SteamLevel @@ -19,6 +19,15 @@ from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.lamarzocco.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture def mock_config_entry( hass: HomeAssistant, mock_lamarzocco: MagicMock diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index be93779848fb9..516fb1db31a8e 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -1,6 +1,7 @@ """Test the La Marzocco config flow.""" -from unittest.mock import MagicMock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch from pylamarzocco.const import MachineModel from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful @@ -81,6 +82,7 @@ async def test_form( hass: HomeAssistant, mock_cloud_client: MagicMock, mock_device_info: LaMarzoccoDeviceInfo, + mock_setup_entry: Generator[AsyncMock], ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -135,6 +137,7 @@ async def test_form_invalid_auth( hass: HomeAssistant, mock_device_info: LaMarzoccoDeviceInfo, mock_cloud_client: MagicMock, + mock_setup_entry: Generator[AsyncMock], ) -> None: """Test invalid auth error.""" @@ -162,6 +165,7 @@ async def test_form_invalid_host( hass: HomeAssistant, mock_cloud_client: MagicMock, mock_device_info: LaMarzoccoDeviceInfo, + mock_setup_entry: Generator[AsyncMock], ) -> None: """Test invalid auth error.""" result = await hass.config_entries.flow.async_init( @@ -204,6 +208,7 @@ async def test_form_cannot_connect( hass: HomeAssistant, mock_cloud_client: MagicMock, mock_device_info: LaMarzoccoDeviceInfo, + mock_setup_entry: Generator[AsyncMock], ) -> None: """Test cannot connect error.""" @@ -272,6 +277,7 @@ async def test_reconfigure_flow( mock_cloud_client: MagicMock, mock_config_entry: MockConfigEntry, mock_device_info: LaMarzoccoDeviceInfo, + mock_setup_entry: Generator[AsyncMock], ) -> None: """Testing reconfgure flow.""" mock_config_entry.add_to_hass(hass) @@ -327,6 +333,7 @@ async def test_bluetooth_discovery( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_cloud_client: MagicMock, + mock_setup_entry: Generator[AsyncMock], ) -> None: """Test bluetooth discovery.""" service_info = get_bluetooth_service_info( @@ -378,6 +385,7 @@ async def test_bluetooth_discovery_errors( mock_lamarzocco: MagicMock, mock_cloud_client: MagicMock, mock_device_info: LaMarzoccoDeviceInfo, + mock_setup_entry: Generator[AsyncMock], ) -> None: """Test bluetooth discovery errors.""" service_info = get_bluetooth_service_info( @@ -447,6 +455,7 @@ async def test_dhcp_discovery( mock_lamarzocco: MagicMock, mock_cloud_client: MagicMock, mock_device_info: LaMarzoccoDeviceInfo, + mock_setup_entry: Generator[AsyncMock], ) -> None: """Test dhcp discovery.""" @@ -486,6 +495,7 @@ async def test_options_flow( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, + mock_setup_entry: Generator[AsyncMock], ) -> None: """Test options flow.""" await async_init_integration(hass, mock_config_entry) From e12db0c88e3bff3fd98b93a8ee233c91fb87a42d Mon Sep 17 00:00:00 2001 From: ElmaxSrl <93348307+ElmaxSrl@users.noreply.github.com> Date: Fri, 22 Nov 2024 08:52:14 +0100 Subject: [PATCH 0693/1070] Update elmax_api to v0.0.6.1 (#130917) Co-authored-by: Alberto Geniola --- homeassistant/components/elmax/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json index c57b707906b35..efa97a9f6b958 100644 --- a/homeassistant/components/elmax/manifest.json +++ b/homeassistant/components/elmax/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/elmax", "iot_class": "cloud_polling", "loggers": ["elmax_api"], - "requirements": ["elmax-api==0.0.5"], + "requirements": ["elmax-api==0.0.6.1"], "zeroconf": [ { "type": "_elmax-ssl._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index f733cac09e916..d9d4829216b69 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -824,7 +824,7 @@ eliqonline==1.2.2 elkm1-lib==2.2.10 # homeassistant.components.elmax -elmax-api==0.0.5 +elmax-api==0.0.6.1 # homeassistant.components.elvia elvia==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af76662f048e6..f8ea12dbb410a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -699,7 +699,7 @@ elgato==5.1.2 elkm1-lib==2.2.10 # homeassistant.components.elmax -elmax-api==0.0.5 +elmax-api==0.0.6.1 # homeassistant.components.elvia elvia==0.1.0 From 786b779a680c3331536c05f4ec8116677fdb54e5 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 22 Nov 2024 08:56:10 +0100 Subject: [PATCH 0694/1070] Fix title upon discovery for lamarzocco (#131207) Co-authored-by: Franck Nijhof --- homeassistant/components/lamarzocco/config_flow.py | 7 +++++++ homeassistant/components/lamarzocco/strings.json | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 04e705edbdcac..b81fc8f9e4b82 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -125,6 +125,12 @@ async def async_step_user( self._config = data return await self.async_step_machine_selection() + placeholders: dict[str, str] | None = None + if self._discovered: + self.context["title_placeholders"] = placeholders = { + CONF_NAME: self._discovered[CONF_MACHINE] + } + return self.async_show_form( step_id="user", data_schema=vol.Schema( @@ -134,6 +140,7 @@ async def async_step_user( } ), errors=errors, + description_placeholders=placeholders, ) async def async_step_machine_selection( diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index f9621d7cd3ba0..e0e2ba105f28e 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -1,6 +1,5 @@ { "config": { - "flow_title": "La Marzocco Espresso {host}", "abort": { "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", From e2183a81aa66f13019a60aed142bd70521434aac Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 22 Nov 2024 09:37:35 +0100 Subject: [PATCH 0695/1070] Make UpdateFailed translateable (#131098) --- homeassistant/helpers/update_coordinator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 87d55891e90d6..6c94fba65d68a 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -24,6 +24,7 @@ ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, + HomeAssistantError, ) from homeassistant.util.dt import utcnow @@ -43,7 +44,7 @@ ) -class UpdateFailed(Exception): +class UpdateFailed(HomeAssistantError): """Raised when an update has failed.""" From 60e19967b198e00a74324b7db5dc148a4ab779a1 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 22 Nov 2024 09:48:42 +0100 Subject: [PATCH 0696/1070] Add parallel updates & exception translations to tedee (#131146) --- homeassistant/components/tedee/coordinator.py | 11 ++++++++--- homeassistant/components/tedee/lock.py | 2 ++ homeassistant/components/tedee/strings.json | 9 +++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 445585a1a2c6e..4012b6d07c5bc 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -99,14 +99,19 @@ async def _async_update(self, update_fn: Callable[[], Awaitable[None]]) -> None: await update_fn() except TedeeLocalAuthException as ex: raise ConfigEntryAuthFailed( - "Authentication failed. Local access token is invalid" + translation_domain=DOMAIN, + translation_key="authentification_failed", ) from ex except TedeeDataUpdateException as ex: _LOGGER.debug("Error while updating data: %s", str(ex)) - raise UpdateFailed(f"Error while updating data: {ex!s}") from ex + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_failed" + ) from ex except (TedeeClientException, TimeoutError) as ex: - raise UpdateFailed(f"Querying API failed. Error: {ex!s}") from ex + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="api_error" + ) from ex def webhook_received(self, message: dict[str, Any]) -> None: """Handle webhook message.""" diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index 6e89a48f2a066..38df85a9cdb7d 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -13,6 +13,8 @@ from .coordinator import TedeeApiCoordinator, TedeeConfigEntry from .entity import TedeeEntity +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json index b6966fa2933db..78cacd706d312 100644 --- a/homeassistant/components/tedee/strings.json +++ b/homeassistant/components/tedee/strings.json @@ -66,12 +66,21 @@ } }, "exceptions": { + "api_error": { + "message": "Error while communicating with the API" + }, + "authentication_failed": { + "message": "Authentication failed. Local access token is invalid" + }, "lock_failed": { "message": "Failed to lock the door. Lock {lock_id}" }, "unlock_failed": { "message": "Failed to unlock the door. Lock {lock_id}" }, + "update_failed": { + "message": "Error while updating data" + }, "open_failed": { "message": "Failed to unlatch the door. Lock {lock_id}" } From cd631abe880f0f72cfc2f7d71c72383a93c24f3d Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 22 Nov 2024 09:50:57 +0100 Subject: [PATCH 0697/1070] Bump pylamarzocco to 1.2.7 (#131236) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 6b2260511183e..8b78a92feca10 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -33,5 +33,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["pylamarzocco"], - "requirements": ["pylamarzocco==1.2.3"] + "requirements": ["pylamarzocco==1.2.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index d9d4829216b69..515cc9d2b4547 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2027,7 +2027,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.2.3 +pylamarzocco==1.2.7 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f8ea12dbb410a..af715b4681276 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1632,7 +1632,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.2.3 +pylamarzocco==1.2.7 # homeassistant.components.lastfm pylast==5.1.0 From 65a64ff7c43cd5164b782cc6771fc36c6752c73e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 22 Nov 2024 09:52:30 +0100 Subject: [PATCH 0698/1070] Bump reolink_aio to 0.11.2 (#131237) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 7921bdb6ed58f..0e2c918acc93c 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.11.1"] + "requirements": ["reolink-aio==0.11.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 515cc9d2b4547..b311d7f393063 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2556,7 +2556,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.1 +reolink-aio==0.11.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af715b4681276..ede6241c3d779 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2047,7 +2047,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.1 +reolink-aio==0.11.2 # homeassistant.components.rflink rflink==0.0.66 From 9e4368cfd44a2d1a2c44f72c1e1b5a4b39e24ee9 Mon Sep 17 00:00:00 2001 From: Nikolay Vasilchuk Date: Fri, 22 Nov 2024 13:44:04 +0300 Subject: [PATCH 0699/1070] Add StarLine flex logic and panic buttons (#130819) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/starline/button.py | 14 ++++++++++++++ homeassistant/components/starline/icons.json | 9 ++++++--- homeassistant/components/starline/strings.json | 6 ++++++ homeassistant/components/starline/switch.py | 4 ---- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/starline/button.py b/homeassistant/components/starline/button.py index ea1a27adc155a..6fb307cda7448 100644 --- a/homeassistant/components/starline/button.py +++ b/homeassistant/components/starline/button.py @@ -16,6 +16,20 @@ key="poke", translation_key="horn", ), + ButtonEntityDescription( + key="panic", + translation_key="panic", + entity_registry_enabled_default=False, + ), + *[ + ButtonEntityDescription( + key=f"flex_{i}", + translation_key="flex", + translation_placeholders={"num": str(i)}, + entity_registry_enabled_default=False, + ) + for i in range(1, 10) + ], ) diff --git a/homeassistant/components/starline/icons.json b/homeassistant/components/starline/icons.json index e240978ce74fd..d7d20ae03bda7 100644 --- a/homeassistant/components/starline/icons.json +++ b/homeassistant/components/starline/icons.json @@ -20,6 +20,12 @@ "button": { "horn": { "default": "mdi:bullhorn-outline" + }, + "flex": { + "default": "mdi:star-circle-outline" + }, + "panic": { + "default": "mdi:alarm-note" } }, "device_tracker": { @@ -63,9 +69,6 @@ "on": "mdi:access-point-network" } }, - "horn": { - "default": "mdi:bullhorn-outline" - }, "service_mode": { "default": "mdi:car-wrench", "state": { diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index a330354e5a9c2..0a30ea5b5be3d 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -124,6 +124,12 @@ "button": { "horn": { "name": "Horn" + }, + "flex": { + "name": "Flex logic {num}" + }, + "panic": { + "name": "Panic mode" } } }, diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index 1b48a72c7325b..05193d98c8af3 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -78,8 +78,6 @@ def extra_state_attributes(self): @property def is_on(self): """Return True if entity is on.""" - if self._key == "poke": - return False return self._device.car_state.get(self._key) def turn_on(self, **kwargs: Any) -> None: @@ -88,6 +86,4 @@ def turn_on(self, **kwargs: Any) -> None: def turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - if self._key == "poke": - return self._account.api.set_car_state(self._device.device_id, self._key, False) From 65652c0adbe767d3089b728bcbd5adcbaed7e8c4 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 22 Nov 2024 11:47:49 +0100 Subject: [PATCH 0700/1070] Enable strict typing for Reolink (#131239) --- .strict-typing | 1 + homeassistant/components/reolink/button.py | 2 +- homeassistant/components/reolink/entity.py | 2 +- homeassistant/components/reolink/host.py | 14 +++++++------- homeassistant/components/reolink/media_source.py | 11 ++++++++--- homeassistant/components/reolink/update.py | 6 +++--- mypy.ini | 10 ++++++++++ 7 files changed, 31 insertions(+), 15 deletions(-) diff --git a/.strict-typing b/.strict-typing index b0fd74bce54fa..1196f199c78b1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -385,6 +385,7 @@ homeassistant.components.recollect_waste.* homeassistant.components.recorder.* homeassistant.components.remote.* homeassistant.components.renault.* +homeassistant.components.reolink.* homeassistant.components.repairs.* homeassistant.components.rest.* homeassistant.components.rest_command.* diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 8863ef9f9a9e7..cd1e1b05fae0e 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -212,7 +212,7 @@ async def async_press(self) -> None: except ReolinkError as err: raise HomeAssistantError(err) from err - async def async_ptz_move(self, **kwargs) -> None: + async def async_ptz_move(self, **kwargs: Any) -> None: """PTZ move with speed.""" speed = kwargs[ATTR_SPEED] try: diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 6101eee8a4c6f..dc2366e8f569d 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -179,7 +179,7 @@ def available(self) -> bool: """Return True if entity is available.""" return super().available and self._host.api.camera_online(self._channel) - def register_callback(self, unique_id: str, cmd_id) -> None: + def register_callback(self, unique_id: str, cmd_id: int) -> None: """Register callback for TCP push events.""" self._host.api.baichuan.register_callback( unique_id, self._push_callback, cmd_id, self._channel diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 68a44bf0aae2d..d2b2bba627612 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -262,7 +262,7 @@ async def async_init(self) -> None: else: ir.async_delete_issue(self._hass, DOMAIN, f"firmware_update_{key}") - async def _async_check_tcp_push(self, *_) -> None: + async def _async_check_tcp_push(self, *_: Any) -> None: """Check the TCP push subscription.""" if self._api.baichuan.events_active: ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") @@ -323,7 +323,7 @@ async def _async_check_tcp_push(self, *_) -> None: self._cancel_tcp_push_check = None - async def _async_check_onvif(self, *_) -> None: + async def _async_check_onvif(self, *_: Any) -> None: """Check the ONVIF subscription.""" if self._webhook_reachable: ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") @@ -344,7 +344,7 @@ async def _async_check_onvif(self, *_) -> None: self._cancel_onvif_check = None - async def _async_check_onvif_long_poll(self, *_) -> None: + async def _async_check_onvif_long_poll(self, *_: Any) -> None: """Check if ONVIF long polling is working.""" if not self._long_poll_received: _LOGGER.debug( @@ -450,7 +450,7 @@ async def disconnect(self) -> None: err, ) - async def _async_start_long_polling(self, initial=False) -> None: + async def _async_start_long_polling(self, initial: bool = False) -> None: """Start ONVIF long polling task.""" if self._long_poll_task is None: try: @@ -495,7 +495,7 @@ async def _async_stop_long_polling(self) -> None: err, ) - async def stop(self, event=None) -> None: + async def stop(self, *_: Any) -> None: """Disconnect the API.""" if self._cancel_poll is not None: self._cancel_poll() @@ -651,7 +651,7 @@ def unregister_webhook(self) -> None: webhook.async_unregister(self._hass, self.webhook_id) self.webhook_id = None - async def _async_long_polling(self, *_) -> None: + async def _async_long_polling(self, *_: Any) -> None: """Use ONVIF long polling to immediately receive events.""" # This task will be cancelled once _async_stop_long_polling is called while True: @@ -688,7 +688,7 @@ async def _async_long_polling(self, *_) -> None: # Cooldown to prevent CPU over usage on camera freezes await asyncio.sleep(LONG_POLL_COOLDOWN) - async def _async_poll_all_motion(self, *_) -> None: + async def _async_poll_all_motion(self, *_: Any) -> None: """Poll motion and AI states until the first ONVIF push is received.""" if ( self._api.baichuan.events_active diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 9280df0f5bd40..0c23bed7e2f65 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -24,6 +24,7 @@ from .const import DOMAIN from .host import ReolinkHost +from .util import ReolinkConfigEntry _LOGGER = logging.getLogger(__name__) @@ -48,7 +49,9 @@ def res_name(stream: str) -> str: def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost: """Return the Reolink host from the config entry id.""" - config_entry = hass.config_entries.async_get_entry(config_entry_id) + config_entry: ReolinkConfigEntry | None = hass.config_entries.async_get_entry( + config_entry_id + ) assert config_entry is not None return config_entry.runtime_data.host @@ -65,7 +68,9 @@ def __init__(self, hass: HomeAssistant) -> None: async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" - identifier = item.identifier.split("|", 5) + identifier = ["UNKNOWN"] + if item.identifier is not None: + identifier = item.identifier.split("|", 5) if identifier[0] != "FILE": raise Unresolvable(f"Unknown media item '{item.identifier}'.") @@ -110,7 +115,7 @@ async def async_browse_media( item: MediaSourceItem, ) -> BrowseMediaSource: """Return media.""" - if item.identifier is None: + if not item.identifier: return await self._async_generate_root() identifier = item.identifier.split("|", 7) diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 73d2e53673d6d..aa607e2b29e3c 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -213,7 +213,7 @@ async def _pause_update_coordinator(self) -> None: self._reolink_data.device_coordinator.update_interval = None self._reolink_data.device_coordinator.async_set_updated_data(None) - async def _resume_update_coordinator(self, *args) -> None: + async def _resume_update_coordinator(self, *args: Any) -> None: """Resume updating the states using the data update coordinator (after reboots).""" self._reolink_data.device_coordinator.update_interval = DEVICE_UPDATE_INTERVAL try: @@ -221,7 +221,7 @@ async def _resume_update_coordinator(self, *args) -> None: finally: self._cancel_resume = None - async def _async_update_progress(self, *args) -> None: + async def _async_update_progress(self, *args: Any) -> None: """Request update.""" self.async_write_ha_state() if self._installing: @@ -229,7 +229,7 @@ async def _async_update_progress(self, *args) -> None: self.hass, POLL_PROGRESS, self._async_update_progress ) - async def _async_update_future(self, *args) -> None: + async def _async_update_future(self, *args: Any) -> None: """Request update.""" try: await self.async_update() diff --git a/mypy.ini b/mypy.ini index 4d33f16d968e1..04c07d82afaf7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3606,6 +3606,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.reolink.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.repairs.*] check_untyped_defs = true disallow_incomplete_defs = true From 37edf982ca414c263c0ba624290c1afb64c5d2c3 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Fri, 22 Nov 2024 12:17:53 +0100 Subject: [PATCH 0701/1070] Add waterheater platform bsblan (#129053) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/bsblan/__init__.py | 2 +- homeassistant/components/bsblan/climate.py | 19 +- .../components/bsblan/coordinator.py | 6 +- homeassistant/components/bsblan/sensor.py | 6 +- homeassistant/components/bsblan/strings.json | 6 + .../components/bsblan/water_heater.py | 107 +++++++++ tests/components/bsblan/conftest.py | 7 +- .../components/bsblan/fixtures/dhw_state.json | 110 +++++++++ .../bsblan/snapshots/test_climate.ambr | 77 +------ .../bsblan/snapshots/test_water_heater.ambr | 68 ++++++ tests/components/bsblan/test_climate.py | 37 +-- tests/components/bsblan/test_sensor.py | 42 +--- tests/components/bsblan/test_water_heater.py | 210 ++++++++++++++++++ 13 files changed, 528 insertions(+), 169 deletions(-) create mode 100644 homeassistant/components/bsblan/water_heater.py create mode 100644 tests/components/bsblan/fixtures/dhw_state.json create mode 100644 tests/components/bsblan/snapshots/test_water_heater.ambr create mode 100644 tests/components/bsblan/test_water_heater.py diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 4d3c6ee207380..623bfbfef565d 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -18,7 +18,7 @@ from .const import CONF_PASSKEY from .coordinator import BSBLanUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] type BSBLanConfigEntry = ConfigEntry[BSBLanData] diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index fcbe88f2face5..6d992da395a13 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -15,7 +15,7 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.device_registry import format_mac @@ -75,26 +75,19 @@ def __init__( super().__init__(data.coordinator, data) self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate" - self._attr_min_temp = float(data.static.min_temp.value) - self._attr_max_temp = float(data.static.max_temp.value) - if data.static.min_temp.unit in ("°C", "°C"): - self._attr_temperature_unit = UnitOfTemperature.CELSIUS - else: - self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + self._attr_min_temp = data.static.min_temp.value + self._attr_max_temp = data.static.max_temp.value + self._attr_temperature_unit = data.coordinator.client.get_temperature_unit @property def current_temperature(self) -> float | None: """Return the current temperature.""" - if self.coordinator.data.state.current_temperature.value == "---": - # device returns no current temperature - return None - - return float(self.coordinator.data.state.current_temperature.value) + return self.coordinator.data.state.current_temperature.value @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - return float(self.coordinator.data.state.target_temperature.value) + return self.coordinator.data.state.target_temperature.value @property def hvac_mode(self) -> HVACMode | None: diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index 1a4299fe72f76..be9030d95b0f8 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -4,7 +4,7 @@ from datetime import timedelta from random import randint -from bsblan import BSBLAN, BSBLANConnectionError, Sensor, State +from bsblan import BSBLAN, BSBLANConnectionError, HotWaterState, Sensor, State from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST @@ -20,6 +20,7 @@ class BSBLanCoordinatorData: state: State sensor: Sensor + dhw: HotWaterState class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]): @@ -59,6 +60,7 @@ async def _async_update_data(self) -> BSBLanCoordinatorData: state = await self.client.state() sensor = await self.client.sensor() + dhw = await self.client.hot_water_state() except BSBLANConnectionError as err: host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown" raise UpdateFailed( @@ -66,4 +68,4 @@ async def _async_update_data(self) -> BSBLanCoordinatorData: ) from err self.update_interval = self._get_update_interval() - return BSBLanCoordinatorData(state=state, sensor=sensor) + return BSBLanCoordinatorData(state=state, sensor=sensor, dhw=dhw) diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py index eab03d7a50cb6..c13b4ad7650ba 100644 --- a/homeassistant/components/bsblan/sensor.py +++ b/homeassistant/components/bsblan/sensor.py @@ -72,11 +72,9 @@ def __init__( super().__init__(data.coordinator, data) self.entity_description = description self._attr_unique_id = f"{data.device.MAC}-{description.key}" + self._attr_temperature_unit = data.coordinator.client.get_temperature_unit @property def native_value(self) -> StateType: """Return the state of the sensor.""" - value = self.entity_description.value_fn(self.coordinator.data) - if value == "---": - return None - return value + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 4fb374fee75ea..a73a89ca1cc4c 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -31,6 +31,12 @@ }, "set_data_error": { "message": "An error occurred while sending the data to the BSBLAN device" + }, + "set_temperature_error": { + "message": "An error occurred while setting the temperature" + }, + "set_operation_mode_error": { + "message": "An error occurred while setting the operation mode" } }, "entity": { diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py new file mode 100644 index 0000000000000..318408a91248d --- /dev/null +++ b/homeassistant/components/bsblan/water_heater.py @@ -0,0 +1,107 @@ +"""BSBLAN platform to control a compatible Water Heater Device.""" + +from __future__ import annotations + +from typing import Any + +from bsblan import BSBLANError + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BSBLanConfigEntry, BSBLanData +from .const import DOMAIN +from .entity import BSBLanEntity + +PARALLEL_UPDATES = 1 + +# Mapping between BSBLan and HA operation modes +OPERATION_MODES = { + "Eco": STATE_ECO, # Energy saving mode + "Off": STATE_OFF, # Protection mode + "On": STATE_ON, # Continuous comfort mode +} + +OPERATION_MODES_REVERSE = {v: k for k, v in OPERATION_MODES.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BSBLanConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up BSBLAN water heater based on a config entry.""" + data = entry.runtime_data + async_add_entities([BSBLANWaterHeater(data)]) + + +class BSBLANWaterHeater(BSBLanEntity, WaterHeaterEntity): + """Defines a BSBLAN water heater entity.""" + + _attr_name = None + _attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + + def __init__(self, data: BSBLanData) -> None: + """Initialize BSBLAN water heater.""" + super().__init__(data.coordinator, data) + self._attr_unique_id = format_mac(data.device.MAC) + self._attr_operation_list = list(OPERATION_MODES_REVERSE.keys()) + + # Set temperature limits based on device capabilities + self._attr_temperature_unit = data.coordinator.client.get_temperature_unit + self._attr_min_temp = data.coordinator.data.dhw.reduced_setpoint.value + self._attr_max_temp = data.coordinator.data.dhw.nominal_setpoint_max.value + + @property + def current_operation(self) -> str | None: + """Return current operation.""" + current_mode = self.coordinator.data.dhw.operating_mode.desc + return OPERATION_MODES.get(current_mode) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.coordinator.data.dhw.dhw_actual_value_top_temperature.value + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.coordinator.data.dhw.nominal_setpoint.value + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + try: + await self.coordinator.client.set_hot_water(nominal_setpoint=temperature) + except BSBLANError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_temperature_error", + ) from err + + await self.coordinator.async_request_refresh() + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + bsblan_mode = OPERATION_MODES_REVERSE.get(operation_mode) + try: + await self.coordinator.client.set_hot_water(operating_mode=bsblan_mode) + except BSBLANError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_operation_mode_error", + ) from err + + await self.coordinator.async_request_refresh() diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py index e46cdd75f2d23..7d2db2f8b4601 100644 --- a/tests/components/bsblan/conftest.py +++ b/tests/components/bsblan/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from bsblan import Device, Info, Sensor, State, StaticState +from bsblan import Device, HotWaterState, Info, Sensor, State, StaticState import pytest from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN @@ -58,6 +58,11 @@ def mock_bsblan() -> Generator[MagicMock]: bsblan.sensor.return_value = Sensor.from_json( load_fixture("sensor.json", DOMAIN) ) + bsblan.hot_water_state.return_value = HotWaterState.from_json( + load_fixture("dhw_state.json", DOMAIN) + ) + # mock get_temperature_unit property + bsblan.get_temperature_unit = "°C" yield bsblan diff --git a/tests/components/bsblan/fixtures/dhw_state.json b/tests/components/bsblan/fixtures/dhw_state.json new file mode 100644 index 0000000000000..41b8c7beda596 --- /dev/null +++ b/tests/components/bsblan/fixtures/dhw_state.json @@ -0,0 +1,110 @@ +{ + "operating_mode": { + "name": "DHW operating mode", + "error": 0, + "value": "On", + "desc": "On", + "dataType": 1, + "readonly": 0, + "unit": "" + }, + "nominal_setpoint": { + "name": "DHW nominal setpoint", + "error": 0, + "value": "50.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "nominal_setpoint_max": { + "name": "DHW nominal setpoint maximum", + "error": 0, + "value": "65.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "reduced_setpoint": { + "name": "DHW reduced setpoint", + "error": 0, + "value": "40.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "release": { + "name": "DHW release programme", + "error": 0, + "value": "1", + "desc": "Released", + "dataType": 1, + "readonly": 0, + "unit": "" + }, + "legionella_function": { + "name": "Legionella function fixed weekday", + "error": 0, + "value": "0", + "desc": "Off", + "dataType": 1, + "readonly": 0, + "unit": "" + }, + "legionella_setpoint": { + "name": "Legionella function setpoint", + "error": 0, + "value": "60.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "legionella_periodicity": { + "name": "Legionella function periodicity", + "error": 0, + "value": "7", + "desc": "Weekly", + "dataType": 0, + "readonly": 0, + "unit": "days" + }, + "legionella_function_day": { + "name": "Legionella function day", + "error": 0, + "value": "6", + "desc": "Saturday", + "dataType": 1, + "readonly": 0, + "unit": "" + }, + "legionella_function_time": { + "name": "Legionella function time", + "error": 0, + "value": "12:00", + "desc": "", + "dataType": 2, + "readonly": 0, + "unit": "" + }, + "dhw_actual_value_top_temperature": { + "name": "DHW temperature actual value", + "error": 0, + "value": "48.5", + "desc": "", + "dataType": 0, + "readonly": 1, + "unit": "°C" + }, + "state_dhw_pump": { + "name": "State DHW circulation pump", + "error": 0, + "value": "0", + "desc": "Off", + "dataType": 1, + "readonly": 1, + "unit": "" + } +} diff --git a/tests/components/bsblan/snapshots/test_climate.ambr b/tests/components/bsblan/snapshots/test_climate.ambr index 4eb70fe265815..16828fea7523f 100644 --- a/tests/components/bsblan/snapshots/test_climate.ambr +++ b/tests/components/bsblan/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_celsius_fahrenheit[static.json][climate.bsb_lan-entry] +# name: test_celsius_fahrenheit[climate.bsb_lan-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -44,7 +44,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_celsius_fahrenheit[static.json][climate.bsb_lan-state] +# name: test_celsius_fahrenheit[climate.bsb_lan-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 18.6, @@ -72,79 +72,6 @@ 'state': 'heat', }) # --- -# name: test_celsius_fahrenheit[static_F.json][climate.bsb_lan-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'hvac_modes': list([ - , - , - , - ]), - 'max_temp': -6.7, - 'min_temp': -13.3, - 'preset_modes': list([ - 'eco', - 'none', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'climate', - 'entity_category': None, - 'entity_id': 'climate.bsb_lan', - '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': None, - 'platform': 'bsblan', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': '00:80:41:19:69:90-climate', - 'unit_of_measurement': None, - }) -# --- -# name: test_celsius_fahrenheit[static_F.json][climate.bsb_lan-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_temperature': -7.4, - 'friendly_name': 'BSB-LAN', - 'hvac_modes': list([ - , - , - , - ]), - 'max_temp': -6.7, - 'min_temp': -13.3, - 'preset_mode': 'none', - 'preset_modes': list([ - 'eco', - 'none', - ]), - 'supported_features': , - 'temperature': -7.5, - }), - 'context': , - 'entity_id': 'climate.bsb_lan', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'heat', - }) -# --- # name: test_climate_entity_properties[climate.bsb_lan-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/bsblan/snapshots/test_water_heater.ambr b/tests/components/bsblan/snapshots/test_water_heater.ambr new file mode 100644 index 0000000000000..c1a13b764c008 --- /dev/null +++ b/tests/components/bsblan/snapshots/test_water_heater.ambr @@ -0,0 +1,68 @@ +# serializer version: 1 +# name: test_water_heater_states[dhw_state.json][water_heater.bsb_lan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 65.0, + 'min_temp': 40.0, + 'operation_list': list([ + 'eco', + 'off', + 'on', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.bsb_lan', + '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': None, + 'platform': 'bsblan', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:80:41:19:69:90', + 'unit_of_measurement': None, + }) +# --- +# name: test_water_heater_states[dhw_state.json][water_heater.bsb_lan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 48.5, + 'friendly_name': 'BSB-LAN', + 'max_temp': 65.0, + 'min_temp': 40.0, + 'operation_list': list([ + 'eco', + 'off', + 'on', + ]), + 'operation_mode': 'on', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 50.0, + }), + 'context': , + 'entity_id': 'water_heater.bsb_lan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py index c519c3043da6f..7ee12c5fa1acb 100644 --- a/tests/components/bsblan/test_climate.py +++ b/tests/components/bsblan/test_climate.py @@ -3,12 +3,11 @@ from datetime import timedelta from unittest.mock import AsyncMock, MagicMock -from bsblan import BSBLANError, StaticState +from bsblan import BSBLANError from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.bsblan.const import DOMAIN from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_PRESET_MODE, @@ -27,37 +26,19 @@ from . import setup_with_selected_platforms -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - load_json_object_fixture, - snapshot_platform, -) +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform ENTITY_ID = "climate.bsb_lan" -@pytest.mark.parametrize( - ("static_file"), - [ - ("static.json"), - ("static_F.json"), - ], -) async def test_celsius_fahrenheit( hass: HomeAssistant, mock_bsblan: AsyncMock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, - static_file: str, ) -> None: """Test Celsius and Fahrenheit temperature units.""" - - static_data = load_json_object_fixture(static_file, DOMAIN) - - mock_bsblan.static_values.return_value = StaticState.from_dict(static_data) - await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @@ -75,21 +56,9 @@ async def test_climate_entity_properties( await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - # Test when current_temperature is "---" - mock_current_temp = MagicMock() - mock_current_temp.value = "---" - mock_bsblan.state.return_value.current_temperature = mock_current_temp - - freezer.tick(timedelta(minutes=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state.attributes["current_temperature"] is None - # Test target_temperature mock_target_temp = MagicMock() - mock_target_temp.value = "23.5" + mock_target_temp.value = 23.5 mock_bsblan.state.return_value.target_temperature = mock_target_temp freezer.tick(timedelta(minutes=1)) diff --git a/tests/components/bsblan/test_sensor.py b/tests/components/bsblan/test_sensor.py index dc22574168d39..c95671a1a6b92 100644 --- a/tests/components/bsblan/test_sensor.py +++ b/tests/components/bsblan/test_sensor.py @@ -1,19 +1,17 @@ """Tests for the BSB-Lan sensor platform.""" -from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er from . import setup_with_selected_platforms -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform ENTITY_CURRENT_TEMP = "sensor.bsb_lan_current_temperature" ENTITY_OUTSIDE_TEMP = "sensor.bsb_lan_outside_temperature" @@ -30,37 +28,3 @@ async def test_sensor_entity_properties( """Test the sensor entity properties.""" await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -@pytest.mark.parametrize( - ("value", "expected_state"), - [ - (18.6, "18.6"), - (None, STATE_UNKNOWN), - ("---", STATE_UNKNOWN), - ], -) -async def test_current_temperature_scenarios( - hass: HomeAssistant, - mock_bsblan: AsyncMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, - value, - expected_state, -) -> None: - """Test various scenarios for current temperature sensor.""" - await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) - - # Set up the mock value - mock_current_temp = MagicMock() - mock_current_temp.value = value - mock_bsblan.sensor.return_value.current_temperature = mock_current_temp - - # Trigger an update - freezer.tick(timedelta(minutes=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - # Check the state - state = hass.states.get(ENTITY_CURRENT_TEMP) - assert state.state == expected_state diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py new file mode 100644 index 0000000000000..ed920774aa563 --- /dev/null +++ b/tests/components/bsblan/test_water_heater.py @@ -0,0 +1,210 @@ +"""Tests for the BSB-Lan water heater platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +from bsblan import BSBLANError +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.water_heater import ( + ATTR_OPERATION_MODE, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + SERVICE_SET_TEMPERATURE, + STATE_ECO, + STATE_OFF, + STATE_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "water_heater.bsb_lan" + + +@pytest.mark.parametrize( + ("dhw_file"), + [ + ("dhw_state.json"), + ], +) +async def test_water_heater_states( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + dhw_file: str, +) -> None: + """Test water heater states with different configurations.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_water_heater_entity_properties( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the water heater entity properties.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + + # Test when nominal setpoint is "10" + mock_setpoint = MagicMock() + mock_setpoint.value = 10 + mock_bsblan.hot_water_state.return_value.nominal_setpoint = mock_setpoint + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes.get("temperature") == 10 + + +@pytest.mark.parametrize( + ("mode", "bsblan_mode"), + [ + (STATE_ECO, "Eco"), + (STATE_OFF, "Off"), + (STATE_ON, "On"), + ], +) +async def test_set_operation_mode( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + mode: str, + bsblan_mode: str, +) -> None: + """Test setting operation mode.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + await hass.services.async_call( + domain=WATER_HEATER_DOMAIN, + service=SERVICE_SET_OPERATION_MODE, + service_data={ + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_OPERATION_MODE: mode, + }, + blocking=True, + ) + + mock_bsblan.set_hot_water.assert_called_once_with(operating_mode=bsblan_mode) + + +async def test_set_invalid_operation_mode( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting invalid operation mode.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + with pytest.raises( + HomeAssistantError, + match=r"Operation mode invalid_mode is not valid for water_heater\.bsb_lan\. Valid operation modes are: eco, off, on", + ): + await hass.services.async_call( + domain=WATER_HEATER_DOMAIN, + service=SERVICE_SET_OPERATION_MODE, + service_data={ + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_OPERATION_MODE: "invalid_mode", + }, + blocking=True, + ) + + +async def test_set_temperature( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting temperature.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + await hass.services.async_call( + domain=WATER_HEATER_DOMAIN, + service=SERVICE_SET_TEMPERATURE, + service_data={ + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 50, + }, + blocking=True, + ) + + mock_bsblan.set_hot_water.assert_called_once_with(nominal_setpoint=50) + + +async def test_set_temperature_failure( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting temperature with API failure.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + mock_bsblan.set_hot_water.side_effect = BSBLANError("Test error") + + with pytest.raises( + HomeAssistantError, match="An error occurred while setting the temperature" + ): + await hass.services.async_call( + domain=WATER_HEATER_DOMAIN, + service=SERVICE_SET_TEMPERATURE, + service_data={ + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 50, + }, + blocking=True, + ) + + +async def test_operation_mode_error( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test operation mode setting with API failure.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + mock_bsblan.set_hot_water.side_effect = BSBLANError("Test error") + + with pytest.raises( + HomeAssistantError, match="An error occurred while setting the operation mode" + ): + await hass.services.async_call( + domain=WATER_HEATER_DOMAIN, + service=SERVICE_SET_OPERATION_MODE, + service_data={ + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_OPERATION_MODE: STATE_ECO, + }, + blocking=True, + ) From 849ebd14353de75e4e76b7c654591e149e54b93e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 22 Nov 2024 12:22:26 +0100 Subject: [PATCH 0702/1070] Cleanup AWS config flow (#131244) --- homeassistant/components/aws/config_flow.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/aws/config_flow.py b/homeassistant/components/aws/config_flow.py index 3175e6bc56c4c..090d9747a6427 100644 --- a/homeassistant/components/aws/config_flow.py +++ b/homeassistant/components/aws/config_flow.py @@ -14,7 +14,4 @@ class AWSFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Import a config entry.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - return self.async_create_entry(title="configuration.yaml", data=import_data) From 2da73ea0687c0a7be8fa43bd71a872937aee111c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 22 Nov 2024 12:33:04 +0100 Subject: [PATCH 0703/1070] Add connectivity checks to renault config flow (#131251) * Add connectivity checks to renault config flow * Parametrize * Sort * merge --- .../components/renault/config_flow.py | 41 +++++++++---------- homeassistant/components/renault/strings.json | 4 +- tests/components/renault/test_config_flow.py | 27 +++++++++--- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py index 68024a7149930..70544a5637f2c 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -5,7 +5,9 @@ from collections.abc import Mapping from typing import Any +import aiohttp from renault_api.const import AVAILABLE_LOCALES +from renault_api.gigya.exceptions import GigyaException import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -27,12 +29,11 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Renault config flow.""" - VERSION = 1 + renault_hub: RenaultHub def __init__(self) -> None: """Initialize the Renault config flow.""" self.renault_config: dict[str, Any] = {} - self.renault_hub: RenaultHub | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -41,24 +42,28 @@ async def async_step_user( Ask the user for API keys. """ + errors: dict[str, str] = {} if user_input: locale = user_input[CONF_LOCALE] self.renault_config.update(user_input) self.renault_config.update(AVAILABLE_LOCALES[locale]) self.renault_hub = RenaultHub(self.hass, locale) - if not await self.renault_hub.attempt_login( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD] - ): - return self._show_user_form({"base": "invalid_credentials"}) - return await self.async_step_kamereon() - return self._show_user_form() - - def _show_user_form(self, errors: dict[str, Any] | None = None) -> ConfigFlowResult: - """Show the API keys form.""" + try: + login_success = await self.renault_hub.attempt_login( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + except (aiohttp.ClientConnectionError, GigyaException): + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + errors["base"] = "unknown" + else: + if login_success: + return await self.async_step_kamereon() + errors["base"] = "invalid_credentials" return self.async_show_form( step_id="user", data_schema=USER_SCHEMA, - errors=errors or {}, + errors=errors, ) async def async_step_kamereon( @@ -74,18 +79,12 @@ async def async_step_kamereon( title=user_input[CONF_KAMEREON_ACCOUNT_ID], data=self.renault_config ) - assert self.renault_hub accounts = await self.renault_hub.get_account_ids() if len(accounts) == 0: return self.async_abort(reason="kamereon_no_account") if len(accounts) == 1: - await self.async_set_unique_id(accounts[0]) - self._abort_if_unique_id_configured() - - self.renault_config[CONF_KAMEREON_ACCOUNT_ID] = accounts[0] - return self.async_create_entry( - title=self.renault_config[CONF_KAMEREON_ACCOUNT_ID], - data=self.renault_config, + return await self.async_step_kamereon( + user_input={CONF_KAMEREON_ACCOUNT_ID: accounts[0]} ) return self.async_show_form( @@ -122,6 +121,6 @@ async def async_step_reauth_confirm( return self.async_show_form( step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, - errors=errors or {}, + errors=errors, description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]}, ) diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 9cc34edb82f24..90463d7547821 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -6,7 +6,9 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { - "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { "kamereon": { diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py index ce69cc4a5c06f..56e0c8a99d70c 100644 --- a/tests/components/renault/test_config_flow.py +++ b/tests/components/renault/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, PropertyMock, patch +import aiohttp import pytest from renault_api.gigya.exceptions import InvalidCredentialsException from renault_api.kamereon import schemas @@ -23,20 +24,35 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (Exception, "unknown"), + (aiohttp.ClientConnectionError, "cannot_connect"), + ( + InvalidCredentialsException(403042, "invalid loginID or password"), + "invalid_credentials", + ), + ], +) async def test_config_flow_single_account( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception | type[Exception], + error: str, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + assert result["step_id"] == "user" + assert not result["errors"] - # Failed credentials + # Raise error with patch( "renault_api.renault_session.RenaultSession.login", - side_effect=InvalidCredentialsException(403042, "invalid loginID or password"), + side_effect=exception, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -48,7 +64,8 @@ async def test_config_flow_single_account( ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_credentials"} + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} renault_account = AsyncMock() type(renault_account).account_id = PropertyMock(return_value="account_id_1") From b38a614170eb6f73f0f2911c5175e5cd27944458 Mon Sep 17 00:00:00 2001 From: dotvav Date: Fri, 22 Nov 2024 12:53:39 +0100 Subject: [PATCH 0704/1070] Palazzetti sensors (#130804) --- .../components/palazzetti/__init__.py | 2 +- .../components/palazzetti/climate.py | 17 +- homeassistant/components/palazzetti/entity.py | 27 ++ homeassistant/components/palazzetti/sensor.py | 106 +++++ .../components/palazzetti/strings.json | 29 ++ tests/components/palazzetti/conftest.py | 39 ++ .../palazzetti/snapshots/test_sensor.ambr | 409 ++++++++++++++++++ tests/components/palazzetti/test_sensor.py | 27 ++ 8 files changed, 641 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/palazzetti/entity.py create mode 100644 homeassistant/components/palazzetti/sensor.py create mode 100644 tests/components/palazzetti/snapshots/test_sensor.ambr create mode 100644 tests/components/palazzetti/test_sensor.py diff --git a/homeassistant/components/palazzetti/__init__.py b/homeassistant/components/palazzetti/__init__.py index ecaa80890977e..4bea4434496b4 100644 --- a/homeassistant/components/palazzetti/__init__.py +++ b/homeassistant/components/palazzetti/__init__.py @@ -7,7 +7,7 @@ from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: PalazzettiConfigEntry) -> bool: diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py index 055b3b4017213..95e267301bc3d 100644 --- a/homeassistant/components/palazzetti/climate.py +++ b/homeassistant/components/palazzetti/climate.py @@ -13,13 +13,12 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PalazzettiConfigEntry -from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES, FAN_SILENT, PALAZZETTI +from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES, FAN_SILENT from .coordinator import PalazzettiDataUpdateCoordinator +from .entity import PalazzettiEntity async def async_setup_entry( @@ -31,9 +30,7 @@ async def async_setup_entry( async_add_entities([PalazzettiClimateEntity(entry.runtime_data)]) -class PalazzettiClimateEntity( - CoordinatorEntity[PalazzettiDataUpdateCoordinator], ClimateEntity -): +class PalazzettiClimateEntity(PalazzettiEntity, ClimateEntity): """Defines a Palazzetti climate.""" _attr_has_entity_name = True @@ -53,15 +50,7 @@ def __init__(self, coordinator: PalazzettiDataUpdateCoordinator) -> None: super().__init__(coordinator) client = coordinator.client mac = coordinator.config_entry.unique_id - assert mac is not None self._attr_unique_id = mac - self._attr_device_info = dr.DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, mac)}, - name=client.name, - manufacturer=PALAZZETTI, - sw_version=client.sw_version, - hw_version=client.hw_version, - ) self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] self._attr_min_temp = client.target_temperature_min self._attr_max_temp = client.target_temperature_max diff --git a/homeassistant/components/palazzetti/entity.py b/homeassistant/components/palazzetti/entity.py new file mode 100644 index 0000000000000..ec850848154b1 --- /dev/null +++ b/homeassistant/components/palazzetti/entity.py @@ -0,0 +1,27 @@ +"""Base class for Palazzetti entities.""" + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import PALAZZETTI +from .coordinator import PalazzettiDataUpdateCoordinator + + +class PalazzettiEntity(CoordinatorEntity[PalazzettiDataUpdateCoordinator]): + """Defines a base Palazzetti entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: PalazzettiDataUpdateCoordinator) -> None: + """Initialize Palazzetti entity.""" + super().__init__(coordinator) + client = coordinator.client + mac = coordinator.config_entry.unique_id + assert mac is not None + self._attr_device_info = dr.DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + name=client.name, + manufacturer=PALAZZETTI, + sw_version=client.sw_version, + hw_version=client.hw_version, + ) diff --git a/homeassistant/components/palazzetti/sensor.py b/homeassistant/components/palazzetti/sensor.py new file mode 100644 index 0000000000000..ead2b236b1740 --- /dev/null +++ b/homeassistant/components/palazzetti/sensor.py @@ -0,0 +1,106 @@ +"""Support for Palazzetti sensors.""" + +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfLength, UnitOfMass, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import PalazzettiConfigEntry +from .coordinator import PalazzettiDataUpdateCoordinator +from .entity import PalazzettiEntity + + +@dataclass(frozen=True, kw_only=True) +class PropertySensorEntityDescription(SensorEntityDescription): + """Describes a Palazzetti sensor entity that is read from a `PalazzettiClient` property.""" + + client_property: str + presence_flag: None | str = None + + +PROPERTY_SENSOR_DESCRIPTIONS: list[PropertySensorEntityDescription] = [ + PropertySensorEntityDescription( + key="pellet_quantity", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + state_class=SensorStateClass.MEASUREMENT, + translation_key="pellet_quantity", + client_property="pellet_quantity", + ), + PropertySensorEntityDescription( + key="pellet_level", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.CENTIMETERS, + state_class=SensorStateClass.MEASUREMENT, + translation_key="pellet_level", + presence_flag="has_pellet_level", + client_property="pellet_level", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PalazzettiConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Palazzetti sensor entities based on a config entry.""" + + coordinator = entry.runtime_data + + sensors = [ + PalazzettiSensor( + coordinator, + PropertySensorEntityDescription( + key=sensor.description_key.value, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + translation_key=sensor.description_key.value, + client_property=sensor.state_property, + ), + ) + for sensor in coordinator.client.list_temperatures() + ] + + sensors.extend( + [ + PalazzettiSensor(coordinator, description) + for description in PROPERTY_SENSOR_DESCRIPTIONS + if not description.presence_flag + or getattr(coordinator.client, description.presence_flag) + ] + ) + + if sensors: + async_add_entities(sensors) + + +class PalazzettiSensor(PalazzettiEntity, SensorEntity): + """Define a Palazzetti sensor.""" + + entity_description: PropertySensorEntityDescription + + def __init__( + self, + coordinator: PalazzettiDataUpdateCoordinator, + description: PropertySensorEntityDescription, + ) -> None: + """Initialize Palazzetti sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{description.key}" + + @property + def native_value(self) -> StateType: + """Return the state value of the sensor.""" + + return getattr(self.coordinator.client, self.entity_description.client_property) diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index cc10c8ed5c63a..718a21f1889c5 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -47,6 +47,35 @@ } } } + }, + "sensor": { + "pellet_quantity": { + "name": "Pellet quantity" + }, + "pellet_level": { + "name": "Pellet level" + }, + "air_outlet_temperature": { + "name": "Air outlet temperature" + }, + "wood_combustion_temperature": { + "name": "Wood combustion temperature" + }, + "room_temperature": { + "name": "Room temperature" + }, + "return_water_temperature": { + "name": "Return water temperature" + }, + "tank_water_temperature": { + "name": "Tank water temperature" + }, + "t1_hydro": { + "name": "Hydro temperature 1" + }, + "t2_hydro": { + "name": "Hydro temperature 2" + } } } } diff --git a/tests/components/palazzetti/conftest.py b/tests/components/palazzetti/conftest.py index 33dca845098d3..b36a58879c151 100644 --- a/tests/components/palazzetti/conftest.py +++ b/tests/components/palazzetti/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from pypalazzetti.temperature import TemperatureDefinition, TemperatureDescriptionKey import pytest from homeassistant.components.palazzetti.const import DOMAIN @@ -56,12 +57,20 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: mock_client.has_fan_high = True mock_client.has_fan_auto = True mock_client.has_on_off_switch = True + mock_client.has_pellet_level = False mock_client.connected = True mock_client.is_heating = True mock_client.room_temperature = 18 + mock_client.T1 = 21.5 + mock_client.T2 = 25.1 + mock_client.T3 = 45 + mock_client.T4 = 0 + mock_client.T5 = 0 mock_client.target_temperature = 21 mock_client.target_temperature_min = 5 mock_client.target_temperature_max = 50 + mock_client.pellet_quantity = 1248 + mock_client.pellet_level = 0 mock_client.fan_speed = 3 mock_client.connect.return_value = True mock_client.update_state.return_value = True @@ -71,4 +80,34 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: mock_client.set_fan_silent.return_value = True mock_client.set_fan_high.return_value = True mock_client.set_fan_auto.return_value = True + mock_client.list_temperatures.return_value = [ + TemperatureDefinition( + description_key=TemperatureDescriptionKey.ROOM_TEMP, + state_property="T1", + ), + TemperatureDefinition( + description_key=TemperatureDescriptionKey.RETURN_WATER_TEMP, + state_property="T4", + ), + TemperatureDefinition( + description_key=TemperatureDescriptionKey.TANK_WATER_TEMP, + state_property="T5", + ), + TemperatureDefinition( + description_key=TemperatureDescriptionKey.WOOD_COMBUSTION_TEMP, + state_property="T3", + ), + TemperatureDefinition( + description_key=TemperatureDescriptionKey.AIR_OUTLET_TEMP, + state_property="T2", + ), + TemperatureDefinition( + description_key=TemperatureDescriptionKey.T1_HYDRO_TEMP, + state_property="T1", + ), + TemperatureDefinition( + description_key=TemperatureDescriptionKey.T2_HYDRO_TEMP, + state_property="T2", + ), + ] yield mock_client diff --git a/tests/components/palazzetti/snapshots/test_sensor.ambr b/tests/components/palazzetti/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..107b818f19574 --- /dev/null +++ b/tests/components/palazzetti/snapshots/test_sensor.ambr @@ -0,0 +1,409 @@ +# serializer version: 1 +# name: test_all_entities[sensor.stove_air_outlet_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stove_air_outlet_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air outlet temperature', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_outlet_temperature', + 'unique_id': '11:22:33:44:55:66-air_outlet_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.stove_air_outlet_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Stove Air outlet temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.stove_air_outlet_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.1', + }) +# --- +# name: test_all_entities[sensor.stove_hydro_temperature_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stove_hydro_temperature_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydro temperature 1', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 't1_hydro', + 'unique_id': '11:22:33:44:55:66-t1_hydro', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.stove_hydro_temperature_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Stove Hydro temperature 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.stove_hydro_temperature_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.5', + }) +# --- +# name: test_all_entities[sensor.stove_hydro_temperature_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stove_hydro_temperature_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydro temperature 2', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 't2_hydro', + 'unique_id': '11:22:33:44:55:66-t2_hydro', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.stove_hydro_temperature_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Stove Hydro temperature 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.stove_hydro_temperature_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.1', + }) +# --- +# name: test_all_entities[sensor.stove_pellet_quantity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stove_pellet_quantity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pellet quantity', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pellet_quantity', + 'unique_id': '11:22:33:44:55:66-pellet_quantity', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.stove_pellet_quantity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'Stove Pellet quantity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.stove_pellet_quantity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1248', + }) +# --- +# name: test_all_entities[sensor.stove_return_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stove_return_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Return water temperature', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'return_water_temperature', + 'unique_id': '11:22:33:44:55:66-return_water_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.stove_return_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Stove Return water temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.stove_return_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.stove_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stove_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Room temperature', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'room_temperature', + 'unique_id': '11:22:33:44:55:66-room_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.stove_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Stove Room temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.stove_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.5', + }) +# --- +# name: test_all_entities[sensor.stove_tank_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stove_tank_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank water temperature', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tank_water_temperature', + 'unique_id': '11:22:33:44:55:66-tank_water_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.stove_tank_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Stove Tank water temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.stove_tank_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.stove_wood_combustion_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stove_wood_combustion_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wood combustion temperature', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wood_combustion_temperature', + 'unique_id': '11:22:33:44:55:66-wood_combustion_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.stove_wood_combustion_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Stove Wood combustion temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.stove_wood_combustion_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45', + }) +# --- diff --git a/tests/components/palazzetti/test_sensor.py b/tests/components/palazzetti/test_sensor.py new file mode 100644 index 0000000000000..c7d7317bb0b81 --- /dev/null +++ b/tests/components/palazzetti/test_sensor.py @@ -0,0 +1,27 @@ +"""Tests for the Palazzetti sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import 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_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.palazzetti.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 047b16ec96178febae701943b616347823396bb6 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 22 Nov 2024 13:05:13 +0100 Subject: [PATCH 0705/1070] Bump aioacaia to 0.1.8 (#131235) --- homeassistant/components/acaia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json index c907a70a38e7a..afe4490a41745 100644 --- a/homeassistant/components/acaia/manifest.json +++ b/homeassistant/components/acaia/manifest.json @@ -25,5 +25,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioacaia"], - "requirements": ["aioacaia==0.1.6"] + "requirements": ["aioacaia==0.1.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index b311d7f393063..bee4ef26e8a4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -173,7 +173,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.6 +aioacaia==0.1.8 # homeassistant.components.airq aioairq==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ede6241c3d779..953a2e095b351 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -161,7 +161,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.6 +aioacaia==0.1.8 # homeassistant.components.airq aioairq==0.4.3 From 040a73421f63aa17372c5ca969960749ac133e47 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 22 Nov 2024 13:07:29 +0100 Subject: [PATCH 0706/1070] Update manifest JSON schema for new quality scale (#131213) --- script/json_schemas/manifest_schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/json_schemas/manifest_schema.json b/script/json_schemas/manifest_schema.json index 40f08fd2c856b..7349f12b55abf 100644 --- a/script/json_schemas/manifest_schema.json +++ b/script/json_schemas/manifest_schema.json @@ -308,7 +308,7 @@ "quality_scale": { "description": "The quality scale of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-quality-scale", "type": "string", - "enum": ["internal", "silver", "gold", "platinum"] + "enum": ["bronze", "silver", "gold", "platinum", "internal", "legacy"] }, "requirements": { "description": "The PyPI package requirements for the integration. The package has to be pinned to a specific version.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#requirements", From f58a6fa2cb0e9666b68b078ef08a7d1ecf2cd44c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 22 Nov 2024 13:20:32 +0100 Subject: [PATCH 0707/1070] Use TextSelector in SABnzbd config flow (#131255) --- homeassistant/components/sabnzbd/config_flow.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sabnzbd/config_flow.py b/homeassistant/components/sabnzbd/config_flow.py index b3bf48a252b3a..c52572ed762d8 100644 --- a/homeassistant/components/sabnzbd/config_flow.py +++ b/homeassistant/components/sabnzbd/config_flow.py @@ -9,6 +9,11 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from .const import DOMAIN from .sab import get_client @@ -17,8 +22,16 @@ USER_SCHEMA = vol.Schema( { - vol.Required(CONF_API_KEY): str, - vol.Required(CONF_URL): str, + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + ) + ), + vol.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ) + ), } ) From 430a47138a5fd733367a98748ec16c7d0c204013 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 22 Nov 2024 13:22:29 +0100 Subject: [PATCH 0708/1070] Add consistent descriptions to turn on / off and toggle commands (#130985) --- homeassistant/components/remote/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index e3df487a57bd3..09b270b968766 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -28,7 +28,7 @@ "services": { "turn_on": { "name": "[%key:common::action::turn_on%]", - "description": "Sends the power on command.", + "description": "Sends the turn on command.", "fields": { "activity": { "name": "Activity", @@ -38,11 +38,11 @@ }, "toggle": { "name": "[%key:common::action::toggle%]", - "description": "Toggles a device on/off." + "description": "Sends the toggle command." }, "turn_off": { "name": "[%key:common::action::turn_off%]", - "description": "Turns the device off." + "description": "Sends the turn off command." }, "send_command": { "name": "Send command", From ce46bac245020a5f8d08d877063fa402a13ae6cf Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 22 Nov 2024 13:26:16 +0100 Subject: [PATCH 0709/1070] Add flow rate sensor to acaia (#131254) --- homeassistant/components/acaia/sensor.py | 10 +++- tests/components/acaia/conftest.py | 1 + .../acaia/snapshots/test_sensor.ambr | 54 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/acaia/sensor.py b/homeassistant/components/acaia/sensor.py index 49ee101b4a2b2..6e6ce6afcb8e0 100644 --- a/homeassistant/components/acaia/sensor.py +++ b/homeassistant/components/acaia/sensor.py @@ -14,7 +14,7 @@ SensorExtraStoredData, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, UnitOfMass +from homeassistant.const import PERCENTAGE, UnitOfMass, UnitOfVolumeFlowRate from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -49,6 +49,14 @@ class AcaiaDynamicUnitSensorEntityDescription(AcaiaSensorEntityDescription): ), value_fn=lambda scale: scale.weight, ), + AcaiaDynamicUnitSensorEntityDescription( + key="flow_rate", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND, + suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda scale: scale.flow_rate, + ), ) RESTORE_SENSORS: tuple[AcaiaSensorEntityDescription, ...] = ( AcaiaSensorEntityDescription( diff --git a/tests/components/acaia/conftest.py b/tests/components/acaia/conftest.py index f1757a7f102d2..ff151f3b09698 100644 --- a/tests/components/acaia/conftest.py +++ b/tests/components/acaia/conftest.py @@ -80,4 +80,5 @@ def mock_scale() -> Generator[MagicMock]: ) scale.weight = 123.45 scale.timer = 23 + scale.flow_rate = 1.23 yield scale diff --git a/tests/components/acaia/snapshots/test_sensor.ambr b/tests/components/acaia/snapshots/test_sensor.ambr index 46995877b4f1f..c3c8ce966ee6b 100644 --- a/tests/components/acaia/snapshots/test_sensor.ambr +++ b/tests/components/acaia/snapshots/test_sensor.ambr @@ -50,6 +50,60 @@ 'state': '42', }) # --- +# name: test_sensors[sensor.lunar_ddeeff_volume_flow_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lunar_ddeeff_volume_flow_rate', + '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': 'Volume flow rate', + 'platform': 'acaia', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_flow_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.lunar_ddeeff_volume_flow_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'LUNAR-DDEEFF Volume flow rate', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lunar_ddeeff_volume_flow_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- # name: test_sensors[sensor.lunar_ddeeff_weight-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From c16f14c85612da073d1d7d35117972ca5b553ee8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 22 Nov 2024 13:34:49 +0100 Subject: [PATCH 0710/1070] Enhance data_description in SABnzbd (#131256) --- homeassistant/components/sabnzbd/strings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index d21ae1fd21933..742c327ed2357 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -7,7 +7,8 @@ "url": "[%key:common::config_flow::data::url%]" }, "data_description": { - "url": "The full URL, including port, of the SABnzbd server. Example: `http://localhost:8080` or `http://a02368d7-sabnzbd:8080`" + "url": "The full URL, including port, of the SABnzbd server. Example: `http://localhost:8080` or `http://a02368d7-sabnzbd:8080`, if you are using the add-on.", + "api_key": "The API key of the SABnzbd server. This can be found in the SABnzbd web interface under Config cog (top right) > General > Security." } } }, From 4b5a8bf9fe8383e31b1617344e49203a740e2af4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 22 Nov 2024 13:36:18 +0100 Subject: [PATCH 0711/1070] =?UTF-8?q?Replace=20"Add=20=E2=80=A6"=20with=20?= =?UTF-8?q?"Create=20=E2=80=A6"=20for=20New=20Helper=20title=20(#131253)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/generic_thermostat/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json index 51549dc844e2a..fd89bec634995 100644 --- a/homeassistant/components/generic_thermostat/strings.json +++ b/homeassistant/components/generic_thermostat/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Add generic thermostat", + "title": "Create generic thermostat", "description": "Create a climate entity that controls the temperature via a switch and sensor.", "data": { "ac_mode": "Cooling mode", From 154282ff5c47dc117ea0aac7bb9b6a6b08a4dd18 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 22 Nov 2024 13:42:33 +0100 Subject: [PATCH 0712/1070] Deprecate camera frontend_stream_type (#130932) --- homeassistant/components/camera/__init__.py | 34 ++++++++++++- .../components/camera/media_source.py | 8 ++-- homeassistant/components/nest/camera.py | 6 --- tests/components/camera/conftest.py | 40 +++++++--------- tests/components/camera/test_init.py | 25 ++++++++++ tests/components/camera/test_media_source.py | 4 +- tests/components/camera/test_webrtc.py | 2 +- tests/components/nest/test_camera.py | 48 ++++++++----------- .../custom_components/test/camera.py | 41 ++++++++++++++++ 9 files changed, 143 insertions(+), 65 deletions(-) create mode 100644 tests/testing_config/custom_components/test/camera.py diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 64a4480d9d38c..176cba8ae8b96 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -60,6 +60,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.network import get_url from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolDictType @@ -466,6 +467,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Entity Properties _attr_brand: str | None = None _attr_frame_interval: float = MIN_STREAM_INTERVAL + # Deprecated in 2024.12. Remove in 2025.6 _attr_frontend_stream_type: StreamType | None _attr_is_on: bool = True _attr_is_recording: bool = False @@ -497,6 +499,16 @@ def __init__(self) -> None: type(self).async_handle_async_webrtc_offer != Camera.async_handle_async_webrtc_offer ) + self._deprecate_attr_frontend_stream_type_logged = False + if type(self).frontend_stream_type != Camera.frontend_stream_type: + report_usage( + ( + f"is overwriting the 'frontend_stream_type' property in the {type(self).__name__} class," + " which is deprecated and will be removed in Home Assistant 2025.6, " + ), + core_integration_behavior=ReportBehavior.ERROR, + exclude_integrations={DOMAIN}, + ) @cached_property def entity_picture(self) -> str: @@ -566,11 +578,29 @@ def frontend_stream_type(self) -> StreamType | None: frontend which camera attributes and player to use. The default type is to use HLS, and components can override to change the type. """ + # Deprecated in 2024.12. Remove in 2025.6 + # Use the camera_capabilities instead if hasattr(self, "_attr_frontend_stream_type"): + if not self._deprecate_attr_frontend_stream_type_logged: + report_usage( + ( + f"is setting the '_attr_frontend_stream_type' attribute in the {type(self).__name__} class," + " which is deprecated and will be removed in Home Assistant 2025.6, " + ), + core_integration_behavior=ReportBehavior.ERROR, + exclude_integrations={DOMAIN}, + ) + + self._deprecate_attr_frontend_stream_type_logged = True return self._attr_frontend_stream_type if CameraEntityFeature.STREAM not in self.supported_features_compat: return None - if self._webrtc_provider or self._legacy_webrtc_provider: + if ( + self._webrtc_provider + or self._legacy_webrtc_provider + or self._supports_native_sync_webrtc + or self._supports_native_async_webrtc + ): return StreamType.WEB_RTC return StreamType.HLS @@ -628,7 +658,7 @@ async def async_handle_async_webrtc_offer( Async means that it could take some time to process the offer and responses/message will be sent with the send_message callback. - This method is used by cameras with CameraEntityFeature.STREAM and StreamType.WEB_RTC. + This method is used by cameras with CameraEntityFeature.STREAM. An integration overriding this method must also implement async_on_webrtc_candidate. Integrations can override with a native WebRTC implementation. diff --git a/homeassistant/components/camera/media_source.py b/homeassistant/components/camera/media_source.py index 222c95ff998f9..701457afc3e91 100644 --- a/homeassistant/components/camera/media_source.py +++ b/homeassistant/components/camera/media_source.py @@ -95,14 +95,16 @@ async def async_browse_media( can_stream_hls = "stream" in self.hass.config.components async def _filter_browsable_camera(camera: Camera) -> BrowseMediaSource | None: - stream_type = camera.frontend_stream_type - if stream_type is None: + stream_types = camera.camera_capabilities.frontend_stream_types + if not stream_types: return _media_source_for_camera(self.hass, camera, camera.content_type) if not can_stream_hls: return None content_type = FORMAT_CONTENT_TYPE[HLS_PROVIDER] - if stream_type != StreamType.HLS and not (await camera.stream_source()): + if StreamType.HLS not in stream_types and not ( + await camera.stream_source() + ): return None return _media_source_for_camera(self.hass, camera, content_type) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 281e6b0bb2820..b7e0f210741e7 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -24,7 +24,6 @@ from homeassistant.components.camera import ( Camera, CameraEntityFeature, - StreamType, WebRTCAnswer, WebRTCClientConfiguration, WebRTCSendMessage, @@ -254,11 +253,6 @@ def __init__(self, device: Device) -> None: self._webrtc_sessions: dict[str, WebRtcStream] = {} self._refresh_unsub: dict[str, Callable[[], None]] = {} - @property - def frontend_stream_type(self) -> StreamType | None: - """Return the type of stream supported by this camera.""" - return StreamType.WEB_RTC - async def _async_refresh_stream(self, session_id: str) -> datetime.datetime | None: """Refresh stream to extend expiration time.""" if not (webrtc_stream := self._webrtc_sessions.get(session_id)): diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index cb25b366029d0..b529ee3e9b92f 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -62,32 +62,17 @@ async def mock_camera_fixture(hass: HomeAssistant) -> AsyncGenerator[None]: def mock_camera_hls_fixture(mock_camera: None) -> Generator[None]: """Initialize a demo camera platform with HLS.""" with patch( - "homeassistant.components.camera.Camera.frontend_stream_type", - new_callable=PropertyMock(return_value=StreamType.HLS), - ): - yield - - -@pytest.fixture -async def mock_camera_webrtc_frontendtype_only( - hass: HomeAssistant, -) -> AsyncGenerator[None]: - """Initialize a demo camera platform with WebRTC.""" - assert await async_setup_component( - hass, "camera", {camera.DOMAIN: {"platform": "demo"}} - ) - await hass.async_block_till_done() - - with patch( - "homeassistant.components.camera.Camera.frontend_stream_type", - new_callable=PropertyMock(return_value=StreamType.WEB_RTC), + "homeassistant.components.camera.Camera.camera_capabilities", + new_callable=PropertyMock( + return_value=camera.CameraCapabilities({StreamType.HLS}) + ), ): yield @pytest.fixture async def mock_camera_webrtc( - mock_camera_webrtc_frontendtype_only: None, + mock_camera: None, ) -> AsyncGenerator[None]: """Initialize a demo camera platform with WebRTC.""" @@ -96,9 +81,17 @@ async def async_handle_async_webrtc_offer( ) -> None: send_message(WebRTCAnswer(WEBRTC_ANSWER)) - with patch( - "homeassistant.components.camera.Camera.async_handle_async_webrtc_offer", - side_effect=async_handle_async_webrtc_offer, + with ( + patch( + "homeassistant.components.camera.Camera.async_handle_async_webrtc_offer", + side_effect=async_handle_async_webrtc_offer, + ), + patch( + "homeassistant.components.camera.Camera.camera_capabilities", + new_callable=PropertyMock( + return_value=camera.CameraCapabilities({StreamType.WEB_RTC}) + ), + ), ): yield @@ -168,7 +161,6 @@ class BaseCamera(camera.Camera): _attr_supported_features: camera.CameraEntityFeature = ( camera.CameraEntityFeature.STREAM ) - _attr_frontend_stream_type: camera.StreamType = camera.StreamType.WEB_RTC async def stream_source(self) -> str | None: return STREAM_SOURCE diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index af8c220bbe4ca..f9d30c240db42 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -27,6 +27,7 @@ from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_PLATFORM, EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, ) @@ -1054,3 +1055,27 @@ async def test_camera_capabilities_changing_native_support( await hass.async_block_till_done() await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set()) + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_deprecated_frontend_stream_type_logs( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test using (_attr_)frontend_stream_type will log.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + for entity_id in ( + "camera.property_frontend_stream_type", + "camera.attr_frontend_stream_type", + ): + camera_obj = get_camera_from_entity_id(hass, entity_id) + assert camera_obj.frontend_stream_type == StreamType.WEB_RTC + + assert ( + "Detected that custom integration 'test' is overwriting the 'frontend_stream_type' property in the PropertyFrontendStreamTypeCamera class, which is deprecated and will be removed in Home Assistant 2025.6," + ) in caplog.text + assert ( + "Detected that custom integration 'test' is setting the '_attr_frontend_stream_type' attribute in the AttrFrontendStreamTypeCamera class, which is deprecated and will be removed in Home Assistant 2025.6," + ) in caplog.text diff --git a/tests/components/camera/test_media_source.py b/tests/components/camera/test_media_source.py index 3b75b58c53f9b..bd92010d242ca 100644 --- a/tests/components/camera/test_media_source.py +++ b/tests/components/camera/test_media_source.py @@ -92,7 +92,7 @@ async def test_browsing_webrtc(hass: HomeAssistant) -> None: assert item.children[0].media_content_type == FORMAT_CONTENT_TYPE["hls"] -@pytest.mark.usefixtures("mock_camera_hls") +@pytest.mark.usefixtures("mock_camera") async def test_resolving(hass: HomeAssistant) -> None: """Test resolving.""" # Adding stream enables HLS camera @@ -110,7 +110,7 @@ async def test_resolving(hass: HomeAssistant) -> None: assert item.mime_type == FORMAT_CONTENT_TYPE["hls"] -@pytest.mark.usefixtures("mock_camera_hls") +@pytest.mark.usefixtures("mock_camera") async def test_resolving_errors(hass: HomeAssistant) -> None: """Test resolving.""" diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 89bd74be30184..04cafbf4ae527 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -399,7 +399,7 @@ async def test_ws_get_client_config_custom_config( } -@pytest.mark.usefixtures("mock_camera_hls") +@pytest.mark.usefixtures("mock_camera") async def test_ws_get_client_config_no_rtc_camera( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index eb15b998507b2..698e9b7a27480 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -176,16 +176,6 @@ async def async_get_image( return image.content -def get_frontend_stream_type_attribute( - hass: HomeAssistant, entity_id: str -) -> StreamType: - """Get the frontend_stream_type camera attribute.""" - cam = hass.states.get(entity_id) - assert cam is not None - assert cam.state == CameraState.STREAMING - return cam.attributes.get("frontend_stream_type") - - async def async_frontend_stream_types( client: MockHAClientWebSocket, entity_id: str ) -> list[str] | None: @@ -268,9 +258,9 @@ async def test_camera_stream( await setup_platform() assert len(hass.states.async_all()) == 1 - assert ( - get_frontend_stream_type_attribute(hass, "camera.my_camera") == StreamType.HLS - ) + cam = hass.states.get("camera.my_camera") + assert cam is not None + assert cam.state == CameraState.STREAMING client = await hass_ws_client(hass) frontend_stream_types = await async_frontend_stream_types( client, "camera.my_camera" @@ -294,10 +284,9 @@ async def test_camera_ws_stream( await setup_platform() assert len(hass.states.async_all()) == 1 - assert ( - get_frontend_stream_type_attribute(hass, "camera.my_camera") == StreamType.HLS - ) - + cam = hass.states.get("camera.my_camera") + assert cam is not None + assert cam.state == CameraState.STREAMING client = await hass_ws_client(hass) frontend_stream_types = await async_frontend_stream_types( client, "camera.my_camera" @@ -671,7 +660,10 @@ async def test_camera_web_rtc( cam = hass.states.get("camera.my_camera") assert cam is not None assert cam.state == CameraState.STREAMING - assert cam.attributes["frontend_stream_type"] == StreamType.WEB_RTC + client = await hass_ws_client(hass) + assert await async_frontend_stream_types(client, "camera.my_camera") == [ + StreamType.WEB_RTC + ] client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -720,17 +712,11 @@ async def test_camera_web_rtc_unsupported( cam = hass.states.get("camera.my_camera") assert cam is not None assert cam.state == CameraState.STREAMING - assert cam.attributes["frontend_stream_type"] == StreamType.HLS client = await hass_ws_client(hass) - await client.send_json_auto_id( - {"type": "camera/capabilities", "entity_id": "camera.my_camera"} - ) - msg = await client.receive_json() - - assert msg["type"] == TYPE_RESULT - assert msg["success"] - assert msg["result"] == {"frontend_stream_types": ["hls"]} + assert await async_frontend_stream_types(client, "camera.my_camera") == [ + StreamType.HLS + ] await client.send_json_auto_id( { @@ -844,6 +830,10 @@ async def test_camera_multiple_streams( assert cam.state == CameraState.STREAMING # Prefer WebRTC over RTSP/HLS assert cam.attributes["frontend_stream_type"] == StreamType.WEB_RTC + client = await hass_ws_client(hass) + assert await async_frontend_stream_types(client, "camera.my_camera") == [ + StreamType.WEB_RTC + ] # RTSP stream is not supported stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") @@ -919,6 +909,10 @@ async def test_webrtc_refresh_expired_stream( assert cam is not None assert cam.state == CameraState.STREAMING assert cam.attributes["frontend_stream_type"] == StreamType.WEB_RTC + client = await hass_ws_client(hass) + assert await async_frontend_stream_types(client, "camera.my_camera") == [ + StreamType.WEB_RTC + ] client = await hass_ws_client(hass) await client.send_json_auto_id( diff --git a/tests/testing_config/custom_components/test/camera.py b/tests/testing_config/custom_components/test/camera.py new file mode 100644 index 0000000000000..b2aa1bbc53b50 --- /dev/null +++ b/tests/testing_config/custom_components/test/camera.py @@ -0,0 +1,41 @@ +"""Provide a mock remote platform. + +Call init before using it in your tests to ensure clean test data. +""" + +from homeassistant.components.camera import Camera, CameraEntityFeature, StreamType +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities_callback: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Return mock entities.""" + async_add_entities_callback( + [AttrFrontendStreamTypeCamera(), PropertyFrontendStreamTypeCamera()] + ) + + +class AttrFrontendStreamTypeCamera(Camera): + """attr frontend stream type Camera.""" + + _attr_name = "attr frontend stream type" + _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM + _attr_frontend_stream_type: StreamType = StreamType.WEB_RTC + + +class PropertyFrontendStreamTypeCamera(Camera): + """property frontend stream type Camera.""" + + _attr_name = "property frontend stream type" + _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM + + @property + def frontend_stream_type(self) -> StreamType | None: + """Return the stream type of the camera.""" + return StreamType.WEB_RTC From 3d98be8593327e531fb8f2a2625ef5d486ea6ca6 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Fri, 22 Nov 2024 12:44:52 +0000 Subject: [PATCH 0713/1070] Add data descriptions for all config fields in Mastodon integration (#131260) --- homeassistant/components/mastodon/strings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index fd4dd890b3714..fb51d86664259 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -9,7 +9,10 @@ "access_token": "[%key:common::config_flow::data::access_token%]" }, "data_description": { - "base_url": "The URL of your Mastodon instance e.g. https://mastodon.social." + "base_url": "The URL of your Mastodon instance e.g. https://mastodon.social.", + "client_id": "The client key for the application created within your Mastodon account.", + "client_secret": "The client secret for the application created within your Mastodon account.", + "access_token": "The access token for the application created within your Mastodon account." } } }, From 7a42c423845fa91220f7cb49058ac43ad553d9fc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 22 Nov 2024 14:51:44 +0100 Subject: [PATCH 0714/1070] Fix incorrect action descriptions of Nexia integration (#131087) --- homeassistant/components/nexia/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index aec145b8806f8..4e0b095289cfa 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -64,7 +64,7 @@ "services": { "set_aircleaner_mode": { "name": "Set air cleaner mode", - "description": "The air cleaner mode.", + "description": "Sets the air cleaner mode.", "fields": { "aircleaner_mode": { "name": "Air cleaner mode", @@ -74,7 +74,7 @@ }, "set_humidify_setpoint": { "name": "Set humidify set point", - "description": "The humidification set point.", + "description": "Sets the target humidity.", "fields": { "humidity": { "name": "Humidify", @@ -84,7 +84,7 @@ }, "set_hvac_run_mode": { "name": "Set hvac run mode", - "description": "The HVAC run mode.", + "description": "Sets the HVAC operation mode.", "fields": { "run_mode": { "name": "Run mode", From ae592a0c355dacaa1fb53344e43494a31ef9487d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 22 Nov 2024 14:59:11 +0100 Subject: [PATCH 0715/1070] Use ServiceValidationError in Renault (#131265) --- homeassistant/components/renault/services.py | 14 ++++++++++++-- homeassistant/components/renault/strings.json | 8 ++++++++ tests/components/renault/test_services.py | 12 ++++++++---- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index 4409d9f284b9a..80fb2363b1e44 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import DOMAIN @@ -169,18 +170,27 @@ def get_vehicle_proxy(service_call_data: Mapping) -> RenaultVehicleProxy: device_id = service_call_data[ATTR_VEHICLE] device_entry = device_registry.async_get(device_id) if device_entry is None: - raise ValueError(f"Unable to find device with id: {device_id}") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, + ) loaded_entries: list[RenaultConfigEntry] = [ entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.state == ConfigEntryState.LOADED + and entry.entry_id in device_entry.config_entries ] for entry in loaded_entries: for vin, vehicle in entry.runtime_data.vehicles.items(): if (DOMAIN, vin) in device_entry.identifiers: return vehicle - raise ValueError(f"Unable to find vehicle with VIN: {device_entry.identifiers}") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_config_entry_for_device", + translation_placeholders={"device_id": device_entry.name or device_id}, + ) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 90463d7547821..a6487772bb6ac 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -213,5 +213,13 @@ } } } + }, + "exceptions": { + "invalid_device_id": { + "message": "No device with id {device_id} was found" + }, + "no_config_entry_for_device": { + "message": "No loaded config entry was found for device with id {device_id}" + } } } diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index bdb233f4d97be..970d7cf4ad84d 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -30,7 +30,7 @@ ATTR_NAME, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from .const import MOCK_VEHICLES @@ -341,12 +341,14 @@ async def test_service_invalid_device_id( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - data = {ATTR_VEHICLE: "VF1AAAAA555777999"} + data = {ATTR_VEHICLE: "some_random_id"} - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True ) + assert err.value.translation_key == "invalid_device_id" + assert err.value.translation_placeholders == {"device_id": "some_random_id"} async def test_service_invalid_device_id2( @@ -372,7 +374,9 @@ async def test_service_invalid_device_id2( data = {ATTR_VEHICLE: device_id} - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True ) + assert err.value.translation_key == "no_config_entry_for_device" + assert err.value.translation_placeholders == {"device_id": "REG-NUMBER"} From 384b2af31eec04f87ce92d47993526c8baacd75f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 22 Nov 2024 15:03:24 +0100 Subject: [PATCH 0716/1070] Add entity translations and entity category for IMAP mail count sensor (#131152) * Add entity translations and entity category for IMAP mail count sensor * Update tests * Support unit_of_measurement * Add unit_of_measurement --- homeassistant/components/imap/sensor.py | 4 +-- homeassistant/components/imap/strings.json | 8 +++++ tests/components/imap/test_diagnostics.py | 2 +- tests/components/imap/test_init.py | 42 +++++++++++----------- 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 625af9ce6a1c8..b484586e05728 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -7,7 +7,7 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -19,10 +19,10 @@ IMAP_MAIL_COUNT_DESCRIPTION = SensorEntityDescription( key="imap_mail_count", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, translation_key="imap_mail_count", - name=None, ) diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 7c4a0d9a9736a..8070d0e7a80a4 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -35,6 +35,14 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "entity": { + "sensor": { + "imap_mail_count": { + "name": "Messages", + "unit_of_measurement": "messages" + } + } + }, "exceptions": { "copy_failed": { "message": "Copying the message failed with \"{error}\"." diff --git a/tests/components/imap/test_diagnostics.py b/tests/components/imap/test_diagnostics.py index 23450104aeda2..43f837679c85d 100644 --- a/tests/components/imap/test_diagnostics.py +++ b/tests/components/imap/test_diagnostics.py @@ -41,7 +41,7 @@ async def test_entry_diagnostics( # Make sure we have had one update (when polling) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # we should have received one message assert state is not None assert state.state == "1" diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 40c3ce013e4e8..7bdfc44571adf 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -153,7 +153,7 @@ async def test_receiving_message_successfully( # Make sure we have had one update (when polling) async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # we should have received one message assert state is not None assert state.state == "1" @@ -202,7 +202,7 @@ async def test_receiving_message_with_invalid_encoding( # Make sure we have had one update (when polling) async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # we should have received one message assert state is not None assert state.state == "1" @@ -237,7 +237,7 @@ async def test_receiving_message_no_subject_to_from( # Make sure we have had one update (when polling) async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # we should have received one message assert state is not None assert state.state == "1" @@ -273,7 +273,7 @@ async def test_initial_authentication_error( assert await hass.config_entries.async_setup(config_entry.entry_id) == success await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") assert (state is not None) == success @@ -290,7 +290,7 @@ async def test_initial_invalid_folder_error( assert await hass.config_entries.async_setup(config_entry.entry_id) == success await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") assert (state is not None) == success @@ -330,7 +330,7 @@ async def test_late_authentication_retry( assert "Authentication failed, retrying" in caplog.text # we still should have an entity with an unavailable state - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") assert state is not None assert state.state == STATE_UNAVAILABLE @@ -371,7 +371,7 @@ async def test_late_authentication_error( assert "Username or password incorrect, starting reauthentication" in caplog.text # we still should have an entity with an unavailable state - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") assert state is not None assert state.state == STATE_UNAVAILABLE @@ -415,7 +415,7 @@ async def test_late_folder_error( assert "Selected mailbox folder is invalid" in caplog.text # we still should have an entity with an unavailable state - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") assert state is not None assert state.state == STATE_UNAVAILABLE @@ -444,7 +444,7 @@ async def test_handle_cleanup_exception( async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # we should have an entity assert state is not None assert state.state == "0" @@ -456,7 +456,7 @@ async def test_handle_cleanup_exception( await hass.async_block_till_done() assert "Error while cleaning up imap connection" in caplog.text - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # we should have an entity with an unavailable state assert state is not None @@ -487,7 +487,7 @@ async def test_lost_connection_with_imap_push( await hass.async_block_till_done() assert "Lost imap.server.com (will attempt to reconnect after 10 s)" in caplog.text - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # Our entity should keep its current state as this assert state is not None assert state.state == "0" @@ -511,7 +511,7 @@ async def test_fetch_number_of_messages( await hass.async_block_till_done() assert "Invalid response for search" in caplog.text - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # we should have an entity with an unavailable state assert state is not None assert state.state == STATE_UNAVAILABLE @@ -556,7 +556,7 @@ async def _sleep_till_event() -> None: # Make sure we have had one update (when polling) async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # We should have received one message assert state is not None assert state.state == "1" @@ -590,7 +590,7 @@ async def _sleep_till_event() -> None: await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # We should have message assert state is not None assert state.state == "0" @@ -607,7 +607,7 @@ async def _sleep_till_event() -> None: await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # We should have received one message assert state is not None assert state.state == "1" @@ -637,7 +637,7 @@ async def test_event_skipped_message_too_large( # Make sure we have had one update (when polling) async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # We should have received one message assert state is not None assert state.state == "1" @@ -667,7 +667,7 @@ async def test_message_is_truncated( # Make sure we have had one update (when polling) async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # We should have received one message assert state is not None assert state.state == "1" @@ -702,7 +702,7 @@ async def test_message_data( # Make sure we have had one update (when polling) async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # We should have received one message assert state is not None assert state.state == "1" @@ -747,7 +747,7 @@ async def test_custom_template( # Make sure we have had one update (when polling) async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # we should have received one message assert state is not None assert state.state == "1" @@ -798,7 +798,7 @@ async def test_enforce_polling( # Make sure we have had one update (when polling) async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # we should have received one message assert state is not None assert state.state == "1" @@ -838,7 +838,7 @@ async def test_services(hass: HomeAssistant, mock_imap_protocol: MagicMock) -> N # Make sure we have had one update (when polling) async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") + state = hass.states.get("sensor.imap_email_email_com_messages") # we should have received one message assert state is not None assert state.state == "1" From f51662f31b44360cd68e8ce51a96a22331d604b0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 22 Nov 2024 15:04:41 +0100 Subject: [PATCH 0717/1070] Mark abode as single_config_entry (#131241) --- homeassistant/components/abode/config_flow.py | 3 -- homeassistant/components/abode/manifest.json | 3 +- homeassistant/components/abode/strings.json | 1 - homeassistant/generated/integrations.json | 3 +- tests/components/abode/test_init.py | 31 +++++++++---------- 5 files changed, 18 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/abode/config_flow.py b/homeassistant/components/abode/config_flow.py index 1c0186e1003ba..01b6c7f568f30 100644 --- a/homeassistant/components/abode/config_flow.py +++ b/homeassistant/components/abode/config_flow.py @@ -112,9 +112,6 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - if user_input is None: return self.async_show_form( step_id="user", data_schema=vol.Schema(self.data_schema) diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json index 9f5806d544a5b..c1ffb9f699bfd 100644 --- a/homeassistant/components/abode/manifest.json +++ b/homeassistant/components/abode/manifest.json @@ -9,5 +9,6 @@ }, "iot_class": "cloud_push", "loggers": ["jaraco.abode", "lomond"], - "requirements": ["jaraco.abode==6.2.1"] + "requirements": ["jaraco.abode==6.2.1"], + "single_config_entry": true } diff --git a/homeassistant/components/abode/strings.json b/homeassistant/components/abode/strings.json index 4b98b69eb19f0..b3d5704275458 100644 --- a/homeassistant/components/abode/strings.json +++ b/homeassistant/components/abode/strings.json @@ -28,7 +28,6 @@ "invalid_mfa_code": "Invalid MFA code" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f007db878686d..b2e09587c5fb3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -9,7 +9,8 @@ "name": "Abode", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "single_config_entry": true }, "acaia": { "name": "Acaia", diff --git a/tests/components/abode/test_init.py b/tests/components/abode/test_init.py index 9fca6dcbdd30f..ed71cb550a791 100644 --- a/tests/components/abode/test_init.py +++ b/tests/components/abode/test_init.py @@ -13,7 +13,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType from .common import setup_platform @@ -63,25 +62,23 @@ async def test_unload_entry(hass: HomeAssistant) -> None: async def test_invalid_credentials(hass: HomeAssistant) -> None: """Test Abode credentials changing.""" - with ( - patch( - "homeassistant.components.abode.Abode", - side_effect=AbodeAuthenticationException( - (HTTPStatus.BAD_REQUEST, "auth error") - ), + with patch( + "homeassistant.components.abode.Abode", + side_effect=AbodeAuthenticationException( + (HTTPStatus.BAD_REQUEST, "auth error") ), - patch( - "homeassistant.components.abode.config_flow.AbodeFlowHandler.async_step_reauth", - return_value={ - "type": FlowResultType.FORM, - "flow_id": "mock_flow", - "step_id": "reauth_confirm", - }, - ) as mock_async_step_reauth, ): - await setup_platform(hass, ALARM_DOMAIN) + config_entry = await setup_platform(hass, ALARM_DOMAIN) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" - mock_async_step_reauth.assert_called_once() + hass.config_entries.flow.async_abort(flows[0]["flow_id"]) + assert not hass.config_entries.flow.async_progress() async def test_raise_config_entry_not_ready_when_offline(hass: HomeAssistant) -> None: From e82130e6fefe5db659f571ee030d62cdbd349a5a Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Fri, 22 Nov 2024 16:06:31 +0200 Subject: [PATCH 0718/1070] Bump hdate to 0.11.1 (#130456) --- .../components/jewish_calendar/entity.py | 1 + .../components/jewish_calendar/manifest.json | 2 +- .../components/jewish_calendar/sensor.py | 21 ++++++++++-------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/jewish_calendar/test_sensor.py | 22 ++++++++++++++++++- 6 files changed, 37 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index ad5ac8e21374a..1d2a6e45c0a8b 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -44,6 +44,7 @@ def __init__( data = config_entry.runtime_data self._location = data.location self._hebrew = data.language == "hebrew" + self._language = data.language self._candle_lighting_offset = data.candle_lighting_offset self._havdalah_offset = data.havdalah_offset self._diaspora = data.diaspora diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index 6d2fe8ecfa1e0..aca45320002d9 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "iot_class": "calculated", "loggers": ["hdate"], - "requirements": ["hdate==0.10.9"], + "requirements": ["hdate==0.11.1"], "single_config_entry": true } diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index c32647af07c6d..d3e70eb411c42 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -275,15 +275,18 @@ def get_state( # Compute the weekly portion based on the upcoming shabbat. return after_tzais_date.upcoming_shabbat.parasha if self.entity_description.key == "holiday": - self._attrs = { - "id": after_shkia_date.holiday_name, - "type": after_shkia_date.holiday_type.name, - "type_id": after_shkia_date.holiday_type.value, - } - self._attr_options = [ - h.description.hebrew.long if self._hebrew else h.description.english - for h in htables.HOLIDAYS - ] + _id = _type = _type_id = "" + _holiday_type = after_shkia_date.holiday_type + if isinstance(_holiday_type, list): + _id = ", ".join(after_shkia_date.holiday_name) + _type = ", ".join([_htype.name for _htype in _holiday_type]) + _type_id = ", ".join([str(_htype.value) for _htype in _holiday_type]) + else: + _id = after_shkia_date.holiday_name + _type = _holiday_type.name + _type_id = _holiday_type.value + self._attrs = {"id": _id, "type": _type, "type_id": _type_id} + self._attr_options = htables.get_all_holidays(self._language) return after_shkia_date.holiday_description if self.entity_description.key == "omer_count": diff --git a/requirements_all.txt b/requirements_all.txt index bee4ef26e8a4d..a612a74df47ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1099,7 +1099,7 @@ hass-splunk==0.1.1 hassil==2.0.2 # homeassistant.components.jewish_calendar -hdate==0.10.9 +hdate==0.11.1 # homeassistant.components.heatmiser heatmiserV3==2.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 953a2e095b351..1b48206a347cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -934,7 +934,7 @@ hass-nabucasa==0.84.0 hassil==2.0.2 # homeassistant.components.jewish_calendar -hdate==0.10.9 +hdate==0.11.1 # homeassistant.components.here_travel_time here-routing==1.0.1 diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index cb054751f678e..4897ef7749b7e 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -93,7 +93,26 @@ async def test_jewish_calendar_hebrew(hass: HomeAssistant) -> None: "id": "rosh_hashana_i", "type": "YOM_TOV", "type_id": 1, - "options": [h.description.english for h in htables.HOLIDAYS], + "options": htables.get_all_holidays("english"), + }, + ), + ( + dt(2024, 12, 31), + "UTC", + 31.778, + 35.235, + "english", + "holiday", + False, + "Chanukah, Rosh Chodesh", + { + "device_class": "enum", + "friendly_name": "Jewish Calendar Holiday", + "icon": "mdi:calendar-star", + "id": "chanukah, rosh_chodesh", + "type": "MELACHA_PERMITTED_HOLIDAY, ROSH_CHODESH", + "type_id": "4, 10", + "options": htables.get_all_holidays("english"), }, ), ( @@ -180,6 +199,7 @@ async def test_jewish_calendar_hebrew(hass: HomeAssistant) -> None: "date_output_hebrew", "holiday", "holiday_english", + "holiday_multiple", "torah_reading", "first_stars_ny", "first_stars_jerusalem", From 32dca7d4a5db5fde66636457d78cfe38fb0cc0c1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 22 Nov 2024 15:20:01 +0100 Subject: [PATCH 0719/1070] Fix typo in humidity::name of Nexia integration (#131267) --- homeassistant/components/nexia/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index 4e0b095289cfa..d88ce0b898d35 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -77,7 +77,7 @@ "description": "Sets the target humidity.", "fields": { "humidity": { - "name": "Humidify", + "name": "Humidity", "description": "The humidification setpoint." } } From 7fba788f18eb5af85392ce1eede96c1e4fe41a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 22 Nov 2024 15:25:22 +0100 Subject: [PATCH 0720/1070] Use `ConfigEntry.runtime_data` to store runtime data at Home Connect (#131014) Co-authored-by: Joost Lekkerkerker --- .../components/home_connect/__init__.py | 84 +++++++++++-------- .../components/home_connect/binary_sensor.py | 7 +- .../components/home_connect/diagnostics.py | 12 ++- .../components/home_connect/light.py | 10 +-- .../components/home_connect/number.py | 9 +- .../components/home_connect/sensor.py | 9 +- .../components/home_connect/switch.py | 9 +- homeassistant/components/home_connect/time.py | 9 +- .../home_connect/test_diagnostics.py | 25 ++++++ 9 files changed, 100 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index c05b04a2c24f5..47a5aa99edbb6 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -4,7 +4,7 @@ from datetime import timedelta import logging -from typing import Any +from typing import Any, cast from requests import HTTPError import voluptuous as vol @@ -40,6 +40,8 @@ SERVICE_START_PROGRAM, ) +type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth] + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=1) @@ -89,13 +91,17 @@ ] -def _get_appliance_by_device_id( - hass: HomeAssistant, device_id: str +def _get_appliance( + hass: HomeAssistant, + device_id: str | None = None, + device_entry: dr.DeviceEntry | None = None, + entry: HomeConnectConfigEntry | None = None, ) -> api.HomeConnectAppliance: - """Return a Home Connect appliance instance given an device_id.""" - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device_id) - assert device_entry + """Return a Home Connect appliance instance given a device id or a device entry.""" + if device_id is not None and device_entry is None: + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + assert device_entry, "Either a device id or a device entry must be provided" ha_id = next( ( @@ -107,17 +113,30 @@ def _get_appliance_by_device_id( ) assert ha_id - for hc_api in hass.data[DOMAIN].values(): - for device in hc_api.devices: + def find_appliance( + entry: HomeConnectConfigEntry, + ) -> api.HomeConnectAppliance | None: + for device in entry.runtime_data.devices: appliance = device.appliance if appliance.haId == ha_id: return appliance - raise ValueError(f"Appliance for device id {device_id} not found") + return None + + if entry is None: + for entry_id in device_entry.config_entries: + entry = hass.config_entries.async_get_entry(entry_id) + assert entry + if entry.domain == DOMAIN: + entry = cast(HomeConnectConfigEntry, entry) + if (appliance := find_appliance(entry)) is not None: + return appliance + elif (appliance := find_appliance(entry)) is not None: + return appliance + raise ValueError(f"Appliance for device id {device_entry.id} not found") async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - hass.data[DOMAIN] = {} async def _async_service_program(call, method): """Execute calls to services taking a program.""" @@ -136,14 +155,14 @@ async def _async_service_program(call, method): options.append(option) - appliance = _get_appliance_by_device_id(hass, device_id) + appliance = _get_appliance(hass, device_id) await hass.async_add_executor_job(getattr(appliance, method), program, options) async def _async_service_command(call, command): """Execute calls to services executing a command.""" device_id = call.data[ATTR_DEVICE_ID] - appliance = _get_appliance_by_device_id(hass, device_id) + appliance = _get_appliance(hass, device_id) await hass.async_add_executor_job(appliance.execute_command, command) async def _async_service_key_value(call, method): @@ -153,7 +172,7 @@ async def _async_service_key_value(call, method): unit = call.data.get(ATTR_UNIT) device_id = call.data[ATTR_DEVICE_ID] - appliance = _get_appliance_by_device_id(hass, device_id) + appliance = _get_appliance(hass, device_id) if unit is not None: await hass.async_add_executor_job( getattr(appliance, method), @@ -239,7 +258,7 @@ async def async_service_start_program(call): return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) -> bool: """Set up Home Connect from a config entry.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -247,9 +266,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hc_api = api.ConfigEntryAuth(hass, entry, implementation) - - hass.data[DOMAIN][entry.entry_id] = hc_api + entry.runtime_data = api.ConfigEntryAuth(hass, entry, implementation) await update_all_devices(hass, entry) @@ -258,20 +275,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: HomeConnectConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @Throttle(SCAN_INTERVAL) -async def update_all_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_all_devices( + hass: HomeAssistant, entry: HomeConnectConfigEntry +) -> None: """Update all the devices.""" - data = hass.data[DOMAIN] - hc_api = data[entry.entry_id] + hc_api = entry.runtime_data try: await hass.async_add_executor_job(hc_api.get_devices) @@ -281,11 +297,13 @@ async def update_all_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: _LOGGER.warning("Cannot update devices: %s", err.response.status_code) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, entry: HomeConnectConfigEntry +) -> bool: """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) + _LOGGER.debug("Migrating from version %s", entry.version) - if config_entry.version == 1 and config_entry.minor_version == 1: + if entry.version == 1 and entry.minor_version == 1: @callback def update_unique_id( @@ -301,11 +319,11 @@ def update_unique_id( } return None - await async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + await async_migrate_entries(hass, entry.entry_id, update_unique_id) - hass.config_entries.async_update_entry(config_entry, minor_version=2) + hass.config_entries.async_update_entry(entry, minor_version=2) - _LOGGER.debug("Migration to version %s successful", config_entry.version) + _LOGGER.debug("Migration to version %s successful", entry.version) return True diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 232b581d58bfb..f9775918f1640 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -10,7 +10,6 @@ BinarySensorEntityDescription, ) from homeassistant.components.script import scripts_with_entity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -20,6 +19,7 @@ async_delete_issue, ) +from . import HomeConnectConfigEntry from .api import HomeConnectDevice from .const import ( ATTR_VALUE, @@ -118,15 +118,14 @@ class HomeConnectBinarySensorEntityDescription(BinarySensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HomeConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect binary sensor.""" def get_entities() -> list[BinarySensorEntity]: entities: list[BinarySensorEntity] = [] - hc_api = hass.data[DOMAIN][config_entry.entry_id] - for device in hc_api.devices: + for device in entry.runtime_data.devices: entities.extend( HomeConnectBinarySensor(device, description) for description in BINARY_SENSORS diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index beedafe671598..d2505853d23e6 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -6,13 +6,11 @@ from homeconnect.api import HomeConnectAppliance -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from . import _get_appliance_by_device_id +from . import HomeConnectConfigEntry, _get_appliance from .api import HomeConnectDevice -from .const import DOMAIN def _generate_appliance_diagnostics(appliance: HomeConnectAppliance) -> dict[str, Any]: @@ -32,17 +30,17 @@ def _generate_entry_diagnostics( async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" return await hass.async_add_executor_job( - _generate_entry_diagnostics, hass.data[DOMAIN][config_entry.entry_id].devices + _generate_entry_diagnostics, entry.runtime_data.devices ) async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: HomeConnectConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - appliance = _get_appliance_by_device_id(hass, device.id) + appliance = _get_appliance(hass, device_entry=device, entry=entry) return await hass.async_add_executor_job(_generate_appliance_diagnostics, appliance) diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 873e7d24f9360..97efc0413ab8d 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -15,14 +15,13 @@ LightEntity, LightEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util -from . import get_dict_from_home_connect_error -from .api import ConfigEntryAuth, HomeConnectDevice +from . import HomeConnectConfigEntry, get_dict_from_home_connect_error +from .api import HomeConnectDevice from .const import ( ATTR_VALUE, BSH_AMBIENT_LIGHT_BRIGHTNESS, @@ -88,18 +87,17 @@ class HomeConnectLightEntityDescription(LightEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HomeConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect light.""" def get_entities() -> list[LightEntity]: """Get a list of entities.""" - hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id] return [ HomeConnectLight(device, description) for description in LIGHTS - for device in hc_api.devices + for device in entry.runtime_data.devices if description.key in device.appliance.status ] diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index ad853df77d084..d1063a2026ff3 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -11,13 +11,11 @@ NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import get_dict_from_home_connect_error -from .api import ConfigEntryAuth +from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( ATTR_CONSTRAINTS, ATTR_STEPSIZE, @@ -84,18 +82,17 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HomeConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect number.""" def get_entities() -> list[HomeConnectNumberEntity]: """Get a list of entities.""" - hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id] return [ HomeConnectNumberEntity(device, description) for description in NUMBERS - for device in hc_api.devices + for device in entry.runtime_data.devices if description.key in device.appliance.status ] diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 70096313d86b2..3ccf55bac6e10 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -14,14 +14,13 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify import homeassistant.util.dt as dt_util -from .api import ConfigEntryAuth +from . import HomeConnectConfigEntry from .const import ( ATTR_VALUE, BSH_DOOR_STATE, @@ -34,7 +33,6 @@ COFFEE_EVENT_WATER_TANK_EMPTY, DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, DISHWASHER_EVENT_SALT_NEARLY_EMPTY, - DOMAIN, REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, @@ -253,7 +251,7 @@ class HomeConnectSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HomeConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect sensor.""" @@ -261,8 +259,7 @@ async def async_setup_entry( def get_entities() -> list[SensorEntity]: """Get a list of entities.""" entities: list[SensorEntity] = [] - hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id] - for device in hc_api.devices: + for device in entry.runtime_data.devices: entities.extend( HomeConnectSensor( device, diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 25bbb85278af9..cad6e81081602 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -7,13 +7,11 @@ from homeconnect.api import HomeConnectError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import get_dict_from_home_connect_error -from .api import ConfigEntryAuth +from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( ATTR_ALLOWED_VALUES, ATTR_CONSTRAINTS, @@ -105,7 +103,7 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HomeConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect switch.""" @@ -113,8 +111,7 @@ async def async_setup_entry( def get_entities() -> list[SwitchEntity]: """Get a list of entities.""" entities: list[SwitchEntity] = [] - hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id] - for device in hc_api.devices: + for device in entry.runtime_data.devices: if device.appliance.type in APPLIANCES_WITH_PROGRAMS: with contextlib.suppress(HomeConnectError): programs = device.appliance.get_programs_available() diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index 946a23549384d..f28339b35952a 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -6,13 +6,11 @@ from homeconnect.api import HomeConnectError from homeassistant.components.time import TimeEntity, TimeEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import get_dict_from_home_connect_error -from .api import ConfigEntryAuth +from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( ATTR_VALUE, DOMAIN, @@ -35,18 +33,17 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HomeConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect switch.""" def get_entities() -> list[HomeConnectTimeEntity]: """Get a list of entities.""" - hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id] return [ HomeConnectTimeEntity(device, description) for description in TIME_ENTITIES - for device in hc_api.devices + for device in entry.runtime_data.devices if description.key in device.appliance.status ] diff --git a/tests/components/home_connect/test_diagnostics.py b/tests/components/home_connect/test_diagnostics.py index e391580459979..d0bc5e777357f 100644 --- a/tests/components/home_connect/test_diagnostics.py +++ b/tests/components/home_connect/test_diagnostics.py @@ -60,3 +60,28 @@ async def test_async_get_device_diagnostics( ) assert await async_get_device_diagnostics(hass, config_entry, device) == snapshot + + +@pytest.mark.usefixtures("bypass_throttle") +async def test_async_device_diagnostics_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device config entry diagnostics.""" + get_appliances.side_effect = get_all_appliances + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "Random-Device-ID")}, + ) + + with pytest.raises(ValueError): + await async_get_device_diagnostics(hass, config_entry, device) From 11ef2b6da8646dc3b2b8917234c10832910f5dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20=C3=98yvind=20=C3=98ygard?= <17528+peroo@users.noreply.github.com> Date: Fri, 22 Nov 2024 15:27:25 +0100 Subject: [PATCH 0721/1070] Populate HVACAction/HVACMode for TouchlineSL zones (#131075) --- homeassistant/components/touchline_sl/climate.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/homeassistant/components/touchline_sl/climate.py b/homeassistant/components/touchline_sl/climate.py index 93328823749b9..0035bd07c34da 100644 --- a/homeassistant/components/touchline_sl/climate.py +++ b/homeassistant/components/touchline_sl/climate.py @@ -7,6 +7,7 @@ from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature @@ -41,6 +42,7 @@ class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEn """Roth Touchline SL Zone.""" _attr_has_entity_name = True + _attr_hvac_action = HVACAction.IDLE _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_name = None @@ -124,3 +126,16 @@ def set_attr(self) -> None: elif self.zone.mode == "globalSchedule": schedule = self.zone.schedule self._attr_preset_mode = schedule.name + + if self.zone.algorithm == "heating": + self._attr_hvac_action = ( + HVACAction.HEATING if self.zone.relay_on else HVACAction.IDLE + ) + self._attr_hvac_mode = HVACMode.HEAT + self._attr_hvac_modes = [HVACMode.HEAT] + elif self.zone.algorithm == "cooling": + self._attr_hvac_action = ( + HVACAction.COOLING if self.zone.relay_on else HVACAction.IDLE + ) + self._attr_hvac_mode = HVACMode.COOL + self._attr_hvac_modes = [HVACMode.COOL] From d3f3fdc7efa47e79257ffd0caf5957665b3d3afc Mon Sep 17 00:00:00 2001 From: Krisjanis Lejejs Date: Fri, 22 Nov 2024 15:08:31 +0000 Subject: [PATCH 0722/1070] Bump hass-nabucasa to 0.85.0 (#131271) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 4201cb1b2d44e..60b105b401ec4 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,6 +8,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.84.0"], + "requirements": ["hass-nabucasa==0.85.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6847dc612e515..40232ffb24c55 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,7 +31,7 @@ fnv-hash-fast==1.0.2 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 habluetooth==3.6.0 -hass-nabucasa==0.84.0 +hass-nabucasa==0.85.0 hassil==2.0.2 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241106.2 diff --git a/pyproject.toml b/pyproject.toml index 235c18c621393..1990411f94fff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ dependencies = [ "fnv-hash-fast==1.0.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.84.0", + "hass-nabucasa==0.85.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.27.2", diff --git a/requirements.txt b/requirements.txt index edfb611f8aa5f..ba4ed56a7a042 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.1 fnv-hash-fast==1.0.2 -hass-nabucasa==0.84.0 +hass-nabucasa==0.85.0 httpx==0.27.2 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index a612a74df47ae..384767f6d42d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1090,7 +1090,7 @@ habitipy==0.3.3 habluetooth==3.6.0 # homeassistant.components.cloud -hass-nabucasa==0.84.0 +hass-nabucasa==0.85.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b48206a347cf..4f91fdaddae7c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -928,7 +928,7 @@ habitipy==0.3.3 habluetooth==3.6.0 # homeassistant.components.cloud -hass-nabucasa==0.84.0 +hass-nabucasa==0.85.0 # homeassistant.components.conversation hassil==2.0.2 From 9e98e446a21bb05234bbdd4f9118a4d58520e989 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 22 Nov 2024 16:53:26 +0100 Subject: [PATCH 0723/1070] Bump ruff to 0.8.0 (#131273) --- .pre-commit-config.yaml | 2 +- .../alarm_control_panel/__init__.py | 6 +++--- homeassistant/components/climate/__init__.py | 6 +++--- homeassistant/components/fan/__init__.py | 6 +++--- homeassistant/components/hdmi_cec/entity.py | 2 +- .../components/shopping_list/__init__.py | 6 +++--- .../components/steam_online/coordinator.py | 6 +++--- homeassistant/util/json.py | 18 ++++++++---------- pyproject.toml | 11 +++++------ requirements_test_pre_commit.txt | 2 +- script/gen_requirements_all.py | 4 ++-- script/hassfest/docker/Dockerfile | 2 +- script/hassfest/zeroconf.py | 6 +++--- tests/components/template/test_config_flow.py | 4 ++-- tests/components/zha/test_helpers.py | 4 ++-- tests/conftest.py | 19 ++++++++++--------- tests/helpers/test_event.py | 14 +++++++------- tests/test_config_entries.py | 8 ++++---- 18 files changed, 62 insertions(+), 64 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2b2a77ae17ba..eff1eafe6fde0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.4 + rev: v0.8.0 hooks: - id: ruff args: diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index a9e433a365050..e3d08af29ef36 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -173,17 +173,17 @@ def __init_subclass__(cls, **kwargs: Any) -> None: # setting the state directly. cls.__alarm_legacy_state = True - def __setattr__(self, __name: str, __value: Any) -> None: + def __setattr__(self, name: str, value: Any, /) -> None: """Set attribute. Deprecation warning if setting '_attr_state' directly unless already reported. """ - if __name == "_attr_state": + if name == "_attr_state": if self.__alarm_legacy_state_reported is not True: self._report_deprecated_alarm_state_handling() self.__alarm_legacy_state_reported = True - return super().__setattr__(__name, __value) + return super().__setattr__(name, value) @callback def add_to_platform_start( diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 94db8008aa1e0..08bad57b6d0c0 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -314,14 +314,14 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # once migrated and set the feature flags TURN_ON/TURN_OFF as needed. _enable_turn_on_off_backwards_compatibility: bool = True - def __getattribute__(self, __name: str) -> Any: + def __getattribute__(self, name: str, /) -> Any: """Get attribute. Modify return of `supported_features` to include `_mod_supported_features` if attribute is set. """ - if __name != "supported_features": - return super().__getattribute__(__name) + if name != "supported_features": + return super().__getattribute__(name) # Convert the supported features to ClimateEntityFeature. # Remove this compatibility shim in 2025.1 or later. diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index bfef182f1e2cb..b31a18d0eac2f 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -245,14 +245,14 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # once migrated and set the feature flags TURN_ON/TURN_OFF as needed. _enable_turn_on_off_backwards_compatibility: bool = True - def __getattribute__(self, __name: str) -> Any: + def __getattribute__(self, name: str, /) -> Any: """Get attribute. Modify return of `supported_features` to include `_mod_supported_features` if attribute is set. """ - if __name != "supported_features": - return super().__getattribute__(__name) + if name != "supported_features": + return super().__getattribute__(name) # Convert the supported features to ClimateEntityFeature. # Remove this compatibility shim in 2025.1 or later. diff --git a/homeassistant/components/hdmi_cec/entity.py b/homeassistant/components/hdmi_cec/entity.py index b1bcb2720d438..bdb796e6a3607 100644 --- a/homeassistant/components/hdmi_cec/entity.py +++ b/homeassistant/components/hdmi_cec/entity.py @@ -36,7 +36,7 @@ def __init__(self, device, logical) -> None: """Initialize the device.""" self._device = device self._logical_address = logical - self.entity_id = "%s.%d" % (DOMAIN, self._logical_address) + self.entity_id = f"{DOMAIN}.{self._logical_address}" self._set_attr_name() self._attr_icon = ICONS_BY_TYPE.get(self._device.type, ICON_UNKNOWN) diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 20d3078228c9e..531bbf379808a 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -320,15 +320,15 @@ def async_reorder( # Remove the item from mapping after it's appended in the result array. del all_items_mapping[item_id] # Append the rest of the items - for key in all_items_mapping: + for value in all_items_mapping.values(): # All the unchecked items must be passed in the item_ids array, # so all items left in the mapping should be checked items. - if all_items_mapping[key]["complete"] is False: + if value["complete"] is False: raise vol.Invalid( "The item ids array doesn't contain all the unchecked shopping list" " items." ) - new_items.append(all_items_mapping[key]) + new_items.append(value) self.items = new_items self.hass.async_add_executor_job(self.save) self._async_notify() diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py index 6e7bdf4b91c05..81a3bb0d898be 100644 --- a/homeassistant/components/steam_online/coordinator.py +++ b/homeassistant/components/steam_online/coordinator.py @@ -60,9 +60,9 @@ def _update(self) -> dict[str, dict[str, str | int]]: for player in response["response"]["players"]["player"] if player["steamid"] in _ids } - for k in players: - data = self.player_interface.GetSteamLevel(steamid=players[k]["steamid"]) - players[k]["level"] = data["response"].get("player_level") + for value in players.values(): + data = self.player_interface.GetSteamLevel(steamid=value["steamid"]) + value["level"] = data["response"].get("player_level") return players async def _async_update_data(self) -> dict[str, dict[str, str | int]]: diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index fa67f6b1dcc84..968567ae0c923 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -30,32 +30,30 @@ class SerializationError(HomeAssistantError): """Error serializing the data to JSON.""" -def json_loads(__obj: bytes | bytearray | memoryview | str) -> JsonValueType: +def json_loads(obj: bytes | bytearray | memoryview | str, /) -> JsonValueType: """Parse JSON data. This adds a workaround for orjson not handling subclasses of str, https://github.com/ijl/orjson/issues/445. """ # Avoid isinstance overhead for the common case - if type(__obj) not in (bytes, bytearray, memoryview, str) and isinstance( - __obj, str - ): - return orjson.loads(str(__obj)) # type:ignore[no-any-return] - return orjson.loads(__obj) # type:ignore[no-any-return] + if type(obj) not in (bytes, bytearray, memoryview, str) and isinstance(obj, str): + return orjson.loads(str(obj)) # type:ignore[no-any-return] + return orjson.loads(obj) # type:ignore[no-any-return] -def json_loads_array(__obj: bytes | bytearray | memoryview | str) -> JsonArrayType: +def json_loads_array(obj: bytes | bytearray | memoryview | str, /) -> JsonArrayType: """Parse JSON data and ensure result is a list.""" - value: JsonValueType = json_loads(__obj) + value: JsonValueType = json_loads(obj) # Avoid isinstance overhead as we are not interested in list subclasses if type(value) is list: # noqa: E721 return value raise ValueError(f"Expected JSON to be parsed as a list got {type(value)}") -def json_loads_object(__obj: bytes | bytearray | memoryview | str) -> JsonObjectType: +def json_loads_object(obj: bytes | bytearray | memoryview | str, /) -> JsonObjectType: """Parse JSON data and ensure result is a dictionary.""" - value: JsonValueType = json_loads(__obj) + value: JsonValueType = json_loads(obj) # Avoid isinstance overhead as we are not interested in dict subclasses if type(value) is dict: # noqa: E721 return value diff --git a/pyproject.toml b/pyproject.toml index 1990411f94fff..6b0b920678ca2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -700,7 +700,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.6.8" +required-version = ">=0.8.0" [tool.ruff.lint] select = [ @@ -783,7 +783,7 @@ select = [ "SLOT", # flake8-slots "T100", # Trace found: {name} used "T20", # flake8-print - "TCH", # flake8-type-checking + "TC", # flake8-type-checking "TID", # Tidy imports "TRY", # tryceratops "UP", # pyupgrade @@ -807,7 +807,6 @@ ignore = [ "PLR0915", # Too many statements ({statements} > {max_statements}) "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target - "PT004", # Fixture {fixture} does not return anything, add leading underscore "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception "PT018", # Assertion should be broken down into multiple parts "RUF001", # String contains ambiguous unicode character. @@ -820,9 +819,9 @@ ignore = [ "SIM115", # Use context handler for opening files # Moving imports into type-checking blocks can mess with pytest.patch() - "TCH001", # Move application import {} into a type-checking block - "TCH002", # Move third-party import {} into a type-checking block - "TCH003", # Move standard library import {} into a type-checking block + "TC001", # Move application import {} into a type-checking block + "TC002", # Move third-party import {} into a type-checking block + "TC003", # Move standard library import {} into a type-checking block "TRY003", # Avoid specifying long messages outside the exception class "TRY400", # Use `logging.exception` instead of `logging.error` diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 85e7bfc4edaca..6523c4d0e430e 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.3.0 -ruff==0.7.4 +ruff==0.8.0 yamllint==1.35.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 689db744f1732..e9e4cf5bbb36f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -350,8 +350,8 @@ def gather_modules() -> dict[str, list[str]] | None: gather_requirements_from_manifests(errors, reqs) gather_requirements_from_modules(errors, reqs) - for key in reqs: - reqs[key] = sorted(reqs[key], key=lambda name: (len(name.split(".")), name)) + for value in reqs.values(): + value = sorted(value, key=lambda name: (len(name.split(".")), name)) if errors: print("******* ERROR") diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 73edada8992b9..89b10fa3027b5 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -22,7 +22,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.4 \ + stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.0 \ PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.2 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 48fcc0a458927..fe3e5bb387512 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -55,19 +55,19 @@ def generate_and_validate(integrations: dict[str, Integration]) -> str: # HomeKit models are matched on starting string, make sure none overlap. warned = set() - for key in homekit_dict: + for key, value in homekit_dict.items(): if key in warned: continue # n^2 yoooo - for key_2 in homekit_dict: + for key_2, value_2 in homekit_dict.items(): if key == key_2 or key_2 in warned: continue if key.startswith(key_2) or key_2.startswith(key): integration.add_error( "zeroconf", - f"Integrations {homekit_dict[key]} and {homekit_dict[key_2]} " + f"Integrations {value} and {value_2} " "have overlapping HomeKit models", ) warned.add(key) diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index a3e53aab9e113..18b55d05672cb 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -222,8 +222,8 @@ async def test_config_flow( state = hass.states.get(f"{template_type}.my_template") assert state.state == template_state - for key in extra_attrs: - assert state.attributes[key] == extra_attrs[key] + for key, value in extra_attrs.items(): + assert state.attributes[key] == value @pytest.mark.parametrize( diff --git a/tests/components/zha/test_helpers.py b/tests/components/zha/test_helpers.py index f6dc8291d9f90..f8a809df51e48 100644 --- a/tests/components/zha/test_helpers.py +++ b/tests/components/zha/test_helpers.py @@ -182,8 +182,8 @@ def test_exclude_none_values( result = exclude_none_values(obj) assert result == expected_output - for key in expected_output: - assert expected_output[key] == obj[key] + for key, value in expected_output.items(): + assert value == obj[key] async def test_create_zha_config_remove_unused( diff --git a/tests/conftest.py b/tests/conftest.py index 35b65c5653cc1..b858073a5e4e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -510,30 +510,31 @@ def aiohttp_client( clients = [] async def go( - __param: Application | BaseTestServer, + param: Application | BaseTestServer, + /, *args: Any, server_kwargs: dict[str, Any] | None = None, **kwargs: Any, ) -> TestClient: - if isinstance(__param, Callable) and not isinstance( # type: ignore[arg-type] - __param, (Application, BaseTestServer) + if isinstance(param, Callable) and not isinstance( # type: ignore[arg-type] + param, (Application, BaseTestServer) ): - __param = __param(loop, *args, **kwargs) + param = param(loop, *args, **kwargs) kwargs = {} else: assert not args, "args should be empty" client: TestClient - if isinstance(__param, Application): + if isinstance(param, Application): server_kwargs = server_kwargs or {} - server = TestServer(__param, loop=loop, **server_kwargs) + server = TestServer(param, loop=loop, **server_kwargs) # Registering a view after starting the server should still work. server.app._router.freeze = lambda: None client = CoalescingClient(server, loop=loop, **kwargs) - elif isinstance(__param, BaseTestServer): - client = TestClient(__param, loop=loop, **kwargs) + elif isinstance(param, BaseTestServer): + client = TestClient(param, loop=loop, **kwargs) else: - raise TypeError(f"Unknown argument type: {type(__param)!r}") + raise TypeError(f"Unknown argument type: {type(param)!r}") await client.start_server() clients.append(client) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 69e3a10d4d456..f01fcf3dddad7 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4387,8 +4387,8 @@ async def test_call_later(hass: HomeAssistant) -> None: schedule_utctime = dt_util.utcnow() @callback - def action(__utcnow: datetime): - _current_delay = __utcnow.timestamp() - schedule_utctime.timestamp() + def action(utcnow: datetime, /): + _current_delay = utcnow.timestamp() - schedule_utctime.timestamp() future.set_result(delay < _current_delay < (delay + delay_tolerance)) async_call_later(hass, delay, action) @@ -4407,8 +4407,8 @@ async def test_async_call_later(hass: HomeAssistant) -> None: schedule_utctime = dt_util.utcnow() @callback - def action(__utcnow: datetime): - _current_delay = __utcnow.timestamp() - schedule_utctime.timestamp() + def action(utcnow: datetime, /): + _current_delay = utcnow.timestamp() - schedule_utctime.timestamp() future.set_result(delay < _current_delay < (delay + delay_tolerance)) remove = async_call_later(hass, delay, action) @@ -4429,8 +4429,8 @@ async def test_async_call_later_timedelta(hass: HomeAssistant) -> None: schedule_utctime = dt_util.utcnow() @callback - def action(__utcnow: datetime): - _current_delay = __utcnow.timestamp() - schedule_utctime.timestamp() + def action(utcnow: datetime, /): + _current_delay = utcnow.timestamp() - schedule_utctime.timestamp() future.set_result(delay < _current_delay < (delay + delay_tolerance)) remove = async_call_later(hass, timedelta(seconds=delay), action) @@ -4450,7 +4450,7 @@ async def test_async_call_later_cancel(hass: HomeAssistant) -> None: delay_tolerance = 0.1 @callback - def action(__now: datetime): + def action(now: datetime, /): future.set_result(False) remove = async_call_later(hass, delay, action) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 4fad1a32b43d4..aba85a35349d3 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5697,8 +5697,8 @@ async def test_starting_config_flow_on_single_config_entry( "comp", context=context, data=user_input ) - for key in expected_result: - assert result[key] == expected_result[key] + for key, value in expected_result.items(): + assert result[key] == value @pytest.mark.parametrize( @@ -5778,8 +5778,8 @@ async def test_starting_config_flow_on_single_config_entry_2( "comp", context=context, data=user_input ) - for key in expected_result: - assert result[key] == expected_result[key] + for key, value in expected_result.items(): + assert result[key] == value async def test_avoid_adding_second_config_entry_on_single_config_entry( From f65d97322fd7809d7266d1c40909727af841f3f4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 22 Nov 2024 16:55:04 +0100 Subject: [PATCH 0724/1070] Add default placeholders for config validation errors (#131277) --- homeassistant/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/config.py b/homeassistant/config.py index cab4d0c7affc2..e9089f2766264 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -814,6 +814,8 @@ def _get_log_message_and_stack_print_pref( "domain": domain, "error": str(exception), "p_name": platform_path, + "config_file": "?", + "line": "?", } show_stack_trace: bool | None = _CONFIG_LOG_SHOW_STACK_TRACE.get( From 61bfc59d51beeaf514591040db168eda948c060a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 22 Nov 2024 16:56:53 +0100 Subject: [PATCH 0725/1070] =?UTF-8?q?Change=20"Add=20=E2=80=A6"=20to=20"Cr?= =?UTF-8?q?eate=20=E2=80=A6"=20for=20New=20Helper=20dialog=20(#131278)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/tod/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tod/strings.json b/homeassistant/components/tod/strings.json index bd4a48df91519..c32b996c29a28 100644 --- a/homeassistant/components/tod/strings.json +++ b/homeassistant/components/tod/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Add Times of the Day Sensor", + "title": "Create Times of the Day Sensor", "description": "Create a binary sensor that turns on or off depending on the time.", "data": { "after_time": "On time", From d4dbceba024cd2aee11b766ff3776566c32e7de8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 22 Nov 2024 16:58:58 +0100 Subject: [PATCH 0726/1070] Bump pytrafikverket to 1.1.1 (#131270) --- .../trafikverket_camera/manifest.json | 2 +- .../trafikverket_ferry/manifest.json | 2 +- .../trafikverket_train/config_flow.py | 4 ++-- .../trafikverket_train/coordinator.py | 4 ++-- .../trafikverket_train/manifest.json | 2 +- .../trafikverket_weatherstation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/trafikverket_train/conftest.py | 2 +- .../trafikverket_train/test_config_flow.py | 20 +++++++++---------- .../trafikverket_train/test_init.py | 12 +++++------ 11 files changed, 27 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index f424f47f7c50c..08d945e0a0c73 100644 --- a/homeassistant/components/trafikverket_camera/manifest.json +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==1.0.0"] + "requirements": ["pytrafikverket==1.1.1"] } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 0b7b056754c45..4177587db7e11 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==1.0.0"] + "requirements": ["pytrafikverket==1.1.1"] } diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index 298e6a44f2d2e..363b9bb2542ea 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -93,8 +93,8 @@ async def validate_input( try: web_session = async_get_clientsession(hass) train_api = TrafikverketTrain(web_session, api_key) - from_station = await train_api.async_get_train_station(train_from) - to_station = await train_api.async_get_train_station(train_to) + from_station = await train_api.async_search_train_station(train_from) + to_station = await train_api.async_search_train_station(train_to) if train_time: await train_api.async_get_train_stop( from_station, to_station, when, product_filter diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index 16a7a649b85a7..49d4e1ded74f0 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -94,10 +94,10 @@ def __init__(self, hass: HomeAssistant) -> None: async def _async_setup(self) -> None: """Initiate stations.""" try: - self.to_station = await self._train_api.async_get_train_station( + self.to_station = await self._train_api.async_search_train_station( self.config_entry.data[CONF_TO] ) - self.from_station = await self._train_api.async_get_train_station( + self.from_station = await self._train_api.async_search_train_station( self.config_entry.data[CONF_FROM] ) except InvalidAuthentication as error: diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 222b23dbe9a3a..40f3a39a2bb37 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==1.0.0"] + "requirements": ["pytrafikverket==1.1.1"] } diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 858387261780d..3996379540f26 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==1.0.0"] + "requirements": ["pytrafikverket==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 384767f6d42d8..d85ed4984bba4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2448,7 +2448,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==1.0.0 +pytrafikverket==1.1.1 # homeassistant.components.v2c pytrydan==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f91fdaddae7c..96fceb5ff33cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1960,7 +1960,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==1.0.0 +pytrafikverket==1.1.1 # homeassistant.components.v2c pytrydan==0.8.0 diff --git a/tests/components/trafikverket_train/conftest.py b/tests/components/trafikverket_train/conftest.py index 2fa5d099ee9d2..234269cc9f8a5 100644 --- a/tests/components/trafikverket_train/conftest.py +++ b/tests/components/trafikverket_train/conftest.py @@ -38,7 +38,7 @@ async def setup_config_entry_with_mocked_data(config_entry_id: str) -> None: return_value=get_train_stop, ), patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", ), ): await hass.config_entries.async_setup(config_entry_id) diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index d75ad5d1b468e..eac5e629bf0c1 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -40,7 +40,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", @@ -103,7 +103,7 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", @@ -163,7 +163,7 @@ async def test_flow_fails( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", side_effect=side_effect(), ), patch( @@ -208,7 +208,7 @@ async def test_flow_fails_departures( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_next_train_stops", @@ -254,7 +254,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", @@ -326,7 +326,7 @@ async def test_reauth_flow_error( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", side_effect=side_effect(), ), patch( @@ -345,7 +345,7 @@ async def test_reauth_flow_error( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", @@ -409,7 +409,7 @@ async def test_reauth_flow_error_departures( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", @@ -428,7 +428,7 @@ async def test_reauth_flow_error_departures( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", @@ -479,7 +479,7 @@ async def test_options_flow( with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", diff --git a/tests/components/trafikverket_train/test_init.py b/tests/components/trafikverket_train/test_init.py index 972e6bffbb4d7..41c8e2432efb1 100644 --- a/tests/components/trafikverket_train/test_init.py +++ b/tests/components/trafikverket_train/test_init.py @@ -35,7 +35,7 @@ async def test_unload_entry( with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -71,7 +71,7 @@ async def test_auth_failed( entry.add_to_hass(hass) with patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", side_effect=InvalidAuthentication, ): await hass.config_entries.async_setup(entry.entry_id) @@ -102,7 +102,7 @@ async def test_no_stations( entry.add_to_hass(hass) with patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", side_effect=NoTrainStationFound, ): await hass.config_entries.async_setup(entry.entry_id) @@ -139,7 +139,7 @@ async def test_migrate_entity_unique_id( with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -174,7 +174,7 @@ async def test_migrate_entry( with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -208,7 +208,7 @@ async def test_migrate_entry_from_future_version_fails( with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", From d4e362486d1ad1b578d084b2ab3adbc0a2015d14 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 22 Nov 2024 17:07:55 +0100 Subject: [PATCH 0727/1070] =?UTF-8?q?Add=20"Create=20=E2=80=A6"=20for=20ne?= =?UTF-8?q?w=20Random=20Helper=20dialog=20(#131283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/random/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index ff44290d36821..e5c5543e39ff0 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -25,7 +25,7 @@ "binary_sensor": "Random binary sensor", "sensor": "Random sensor" }, - "title": "Random helper" + "title": "Create Random helper" } } }, From 0460046d328d01a5877c792f3a6e3c84dde22ad0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 22 Nov 2024 17:08:12 +0100 Subject: [PATCH 0728/1070] Fix incorrect translation string in palazzetti (#131272) --- homeassistant/components/palazzetti/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index 718a21f1889c5..435ec0aab857d 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -27,7 +27,7 @@ "invalid_fan_mode": { "message": "Fan mode {value} is invalid." }, - "invalid_target_temperatures": { + "invalid_target_temperature": { "message": "Target temperature {value} is invalid." }, "cannot_connect": { From 1dbb92e7f3bd17d3cf686afdd633b7ad5da06cc4 Mon Sep 17 00:00:00 2001 From: rappenze Date: Fri, 22 Nov 2024 17:10:38 +0100 Subject: [PATCH 0729/1070] Use _attr_is_on in fibaro light (#131211) --- homeassistant/components/fibaro/light.py | 31 +++++-------- tests/components/fibaro/conftest.py | 23 ++++++++++ tests/components/fibaro/test_light.py | 57 ++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 19 deletions(-) create mode 100644 tests/components/fibaro/test_light.py diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 17831a36a4a8b..18f86b6df7d5f 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -132,32 +132,25 @@ def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" self.call_turn_off() - @property - def is_on(self) -> bool | None: - """Return true if device is on. - - Dimmable and RGB lights can be on based on different - properties, so we need to check here several values. + def update(self) -> None: + """Update the state.""" + super().update() - JSON for HC2 uses always string, HC3 uses int for integers. - """ - if self.current_binary_state: - return True + # Dimmable and RGB lights can be on based on different + # properties, so we need to check here several values + # to see if the light is on. + light_is_on = self.current_binary_state with suppress(TypeError): if self.fibaro_device.brightness != 0: - return True + light_is_on = True with suppress(TypeError): if self.fibaro_device.current_program != 0: - return True + light_is_on = True with suppress(TypeError): if self.fibaro_device.current_program_id != 0: - return True + light_is_on = True + self._attr_is_on = light_is_on - return False - - def update(self) -> None: - """Update the state.""" - super().update() # Brightness handling if brightness_supported(self.supported_color_modes): self._attr_brightness = scaleto255(self.fibaro_device.value.int_value()) @@ -172,7 +165,7 @@ def update(self) -> None: if rgbw == (0, 0, 0, 0) and self.fibaro_device.last_color_set.has_color: rgbw = self.fibaro_device.last_color_set.rgbw_color - if self._attr_color_mode == ColorMode.RGB: + if self.color_mode == ColorMode.RGB: self._attr_rgb_color = rgbw[:3] else: self._attr_rgbw_color = rgbw diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index ac10d4fc79d2b..1976a8f310b2e 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -106,6 +106,29 @@ def mock_cover() -> Mock: return cover +@pytest.fixture +def mock_light() -> Mock: + """Fixture for a dimmmable light.""" + light = Mock() + light.fibaro_id = 3 + light.parent_fibaro_id = 0 + light.name = "Test light" + light.room_id = 1 + light.dead = False + light.visible = True + light.enabled = True + light.type = "com.fibaro.FGD212" + light.base_type = "com.fibaro.device" + light.properties = {"manufacturer": ""} + light.actions = {"setValue": 1, "on": 0, "off": 0} + light.supported_features = {} + value_mock = Mock() + value_mock.has_value = True + value_mock.int_value.return_value = 20 + light.value = value_mock + return light + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/fibaro/test_light.py b/tests/components/fibaro/test_light.py new file mode 100644 index 0000000000000..d0a24e009b7a1 --- /dev/null +++ b/tests/components/fibaro/test_light.py @@ -0,0 +1,57 @@ +"""Test the Fibaro light platform.""" + +from unittest.mock import Mock, patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_light_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test that the light creates an entity.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + entry = entity_registry.async_get("light.room_1_test_light_3") + assert entry + assert entry.unique_id == "hc2_111111.3" + assert entry.original_name == "Room 1 Test light" + + +async def test_light_brightness( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test that the light brightness value is translated.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("light.room_1_test_light_3") + assert state.attributes["brightness"] == 51 + assert state.state == "on" From 6064055150cc93be66c3dca61dad249868cb3543 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 22 Nov 2024 17:14:23 +0100 Subject: [PATCH 0730/1070] Improve imap config flow strings and add data descriptions (#131279) --- homeassistant/components/imap/strings.json | 26 +++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 8070d0e7a80a4..8ff5d83819931 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -10,8 +10,21 @@ "charset": "Character set", "folder": "Folder", "search": "IMAP search", + "event_message_data": "Message data to be included in the `imap_content` event data:", "ssl_cipher_list": "SSL cipher list (Advanced)", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "username": "The IMAP username.", + "password": "The IMAP password", + "server": "The IMAP server.", + "port": "The IMAP port supporting SSL, usually this is 993.", + "charset": "The character set used. Common values are `utf-8` or `US-ASCII`.", + "folder": "In generally the folder is set to `INBOX`, but e.g. in case of a sub folder, named `Test`, this should be `INBOX.Test`.", + "search": "The IMAP search command which is `UnSeen UnDeleted` by default.", + "event_message_data": "Note that the event size is limited, and not all message text might be sent with the event if the message is too large.", + "ssl_cipher_list": "If the IMAP service only supports legacy encryption, try to change this.", + "verify_ssl": "Recommended, to ensure the server certificate is valid. Turn off, if the server certificate is not trusted (e.g. self signed)." } }, "reauth_confirm": { @@ -19,6 +32,9 @@ "title": "[%key:common::config_flow::title::reauth%]", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Correct the IMAP password." } } }, @@ -81,7 +97,15 @@ "custom_event_data_template": "Template to create custom event data", "max_message_size": "Max message size (2048 < size < 30000)", "enable_push": "Enable Push-IMAP if the server supports it. Turn off if Push-IMAP updates are unreliable.", - "event_message_data": "Message data to be included in the `imap_content` event data:" + "event_message_data": "Message data to be included in the `imap_content` event data." + }, + "data_description": { + "folder": "[%key:component::imap::config::step::user::data_description::folder%]", + "search": "[%key:component::imap::config::step::user::data_description::search%]", + "event_message_data": "[%key:component::imap::config::step::user::data_description::event_message_data%]", + "custom_event_data_template": "This template is evaluated when a new message was received, and the result is added to the `custom` attribute of the event data.", + "max_message_size": "Limit the maximum size of the event. Instead of passing the (whole) text message, using a template is a better option.", + "enable_push": "Using Push-IMAP is recommended. Polling will increase the time to respond." } } }, From 46abf9790b465262e22763116f50b0e5b990a984 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 22 Nov 2024 17:36:57 +0100 Subject: [PATCH 0731/1070] Fix honeywell translation_placeholder (#131288) --- homeassistant/components/honeywell/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 98cbae4eb7e25..d4e5ee10a6b45 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -398,7 +398,7 @@ async def _set_temperature(self, **kwargs) -> None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="temp_failed_value", - translation_placeholders={"temp": temperature}, + translation_placeholders={"temperature": temperature}, ) from err async def async_set_temperature(self, **kwargs: Any) -> None: @@ -422,7 +422,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="temp_failed_value", - translation_placeholders={"temp": str(temperature)}, + translation_placeholders={"temperature": str(temperature)}, ) from err async def async_set_fan_mode(self, fan_mode: str) -> None: From 9991a3b6880b2c4ab5a6d3c6d38edc0ea97ae32c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 22 Nov 2024 17:37:11 +0100 Subject: [PATCH 0732/1070] Fix missing exception translation in tibber (#131287) --- homeassistant/components/tibber/services.py | 1 - homeassistant/components/tibber/strings.json | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py index 72943a0215a09..5033cda11d005 100644 --- a/homeassistant/components/tibber/services.py +++ b/homeassistant/components/tibber/services.py @@ -79,7 +79,6 @@ def __get_date(date_input: str | None, mode: str | None) -> datetime: return dt_util.as_local(value) raise ServiceValidationError( - "Invalid datetime provided.", translation_domain=DOMAIN, translation_key="invalid_date", translation_placeholders={ diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index 8d73d435c8cfc..05b98b9799583 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -119,6 +119,9 @@ } }, "exceptions": { + "invalid_date": { + "message": "Invalid datetime provided {date}" + }, "send_message_timeout": { "message": "Timeout sending message with Tibber" } From 3052e296894a00c15400983773b9a6f1c97aaf8e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 22 Nov 2024 17:37:24 +0100 Subject: [PATCH 0733/1070] Fix missing exception translation in alarm_control_panel (#131280) --- homeassistant/components/alarm_control_panel/__init__.py | 1 - homeassistant/components/alarm_control_panel/strings.json | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index e3d08af29ef36..4389e3a9ad9df 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -275,7 +275,6 @@ def check_code_arm_required(self, code: str | None) -> str | None: """Check if arm code is required, raise if no code is given.""" if not (_code := self.code_or_default_code(code)) and self.code_arm_required: raise ServiceValidationError( - f"Arming requires a code but none was given for {self.entity_id}", translation_domain=DOMAIN, translation_key="code_arm_required", translation_placeholders={ diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 733e02954c107..5f7182805667b 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -138,5 +138,10 @@ } } } + }, + "exceptions": { + "code_arm_required": { + "message": "Arming requires a code but none was given for {entity_id}." + } } } From 97f574a86a0c4c10b4fb2ac8ef443bf41705bd47 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 22 Nov 2024 17:37:40 +0100 Subject: [PATCH 0734/1070] Fix lamarzocco translation_placeholder (#131284) --- homeassistant/components/lamarzocco/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index f7690885f0591..9f2598affaefa 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -108,7 +108,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="switch_off_error", - translation_placeholders={"name": self.entity_description.key}, + translation_placeholders={"key": self.entity_description.key}, ) from exc self.async_write_ha_state() From 754cf1fdb480699c1a3131d784d59eecde31c840 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 22 Nov 2024 17:37:56 +0100 Subject: [PATCH 0735/1070] Deprecate camera async_handle_web_rtc_offer (#131285) --- homeassistant/components/camera/__init__.py | 6 +++++- tests/components/camera/test_webrtc.py | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 176cba8ae8b96..781388f12d6be 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -55,6 +55,7 @@ DeprecatedConstantEnum, all_with_deprecated_constants, check_if_deprecated_constant, + deprecated_function, dir_with_deprecated_constants, ) from homeassistant.helpers.entity import Entity, EntityDescription @@ -665,7 +666,10 @@ async def async_handle_async_webrtc_offer( """ if self._supports_native_sync_webrtc: try: - answer = await self.async_handle_web_rtc_offer(offer_sdp) + answer = await deprecated_function( + "async_handle_async_webrtc_offer", + breaks_in_ha_version="2025.6", + )(self.async_handle_web_rtc_offer)(offer_sdp) except ValueError as ex: _LOGGER.error("Error handling WebRTC offer: %s", ex) send_message( diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 04cafbf4ae527..ba90788bdc37d 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -65,7 +65,6 @@ class MockCamera(Camera): _attr_name = "Test" _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - _attr_frontend_stream_type: StreamType = StreamType.WEB_RTC def __init__(self) -> None: """Initialize the mock entity.""" @@ -684,24 +683,33 @@ async def test_websocket_webrtc_offer_failure( } +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_websocket_webrtc_offer_sync( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - init_test_integration: MockCamera, + caplog: pytest.LogCaptureFixture, ) -> None: """Test sync WebRTC stream offer.""" client = await hass_ws_client(hass) - init_test_integration.set_sync_answer(WEBRTC_ANSWER) await client.send_json_auto_id( { "type": "camera/webrtc/offer", - "entity_id": "camera.test", + "entity_id": "camera.sync", "offer": WEBRTC_OFFER, } ) response = await client.receive_json() + assert ( + "tests.components.camera.conftest", + logging.WARNING, + ( + "async_handle_web_rtc_offer was called from camera, this is a deprecated " + "function which will be removed in HA Core 2025.6. Use " + "async_handle_async_webrtc_offer instead" + ), + ) in caplog.record_tuples assert response["type"] == TYPE_RESULT assert response["success"] subscription_id = response["id"] From 7621012ee69ea379b77887dc251e0c7ddc1d624c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 22 Nov 2024 10:38:19 -0600 Subject: [PATCH 0736/1070] Ensure sentence triggers are only checked once (#131210) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/assist_pipeline/pipeline.py | 58 +++++++++---------- tests/components/assist_pipeline/test_init.py | 11 +++- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index d90424d52d329..96beaf792a7ab 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1032,39 +1032,37 @@ async def recognize_intent( agent_id=self.intent_agent, ) - # Sentence triggers override conversation agent - if ( - trigger_response_text - := await conversation.async_handle_sentence_triggers( - self.hass, user_input - ) - ): - # Sentence trigger matched - trigger_response = intent.IntentResponse( - self.pipeline.conversation_language - ) - trigger_response.async_set_speech(trigger_response_text) - conversation_result = conversation.ConversationResult( - response=trigger_response, - conversation_id=user_input.conversation_id, - ) - # Try local intents first, if preferred. - # Skip this step if the default agent is already used. - elif ( - self.pipeline.prefer_local_intents - and (user_input.agent_id != conversation.HOME_ASSISTANT_AGENT) - and ( + conversation_result: conversation.ConversationResult | None = None + if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT: + # Sentence triggers override conversation agent + if ( + trigger_response_text + := await conversation.async_handle_sentence_triggers( + self.hass, user_input + ) + ): + # Sentence trigger matched + trigger_response = intent.IntentResponse( + self.pipeline.conversation_language + ) + trigger_response.async_set_speech(trigger_response_text) + conversation_result = conversation.ConversationResult( + response=trigger_response, + conversation_id=user_input.conversation_id, + ) + # Try local intents first, if preferred. + elif self.pipeline.prefer_local_intents and ( intent_response := await conversation.async_handle_intents( self.hass, user_input ) - ) - ): - # Local intent matched - conversation_result = conversation.ConversationResult( - response=intent_response, - conversation_id=user_input.conversation_id, - ) - else: + ): + # Local intent matched + conversation_result = conversation.ConversationResult( + response=intent_response, + conversation_id=user_input.conversation_id, + ) + + if conversation_result is None: # Fall back to pipeline conversation agent conversation_result = await conversation.async_converse( hass=self.hass, diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index bdca27d527f76..a8ada11fb4531 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -941,7 +941,7 @@ async def test_sentence_trigger_overrides_conversation_agent( init_components, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: - """Test that sentence triggers are checked before the conversation agent.""" + """Test that sentence triggers are checked before a non-default conversation agent.""" assert await async_setup_component( hass, "automation", @@ -975,9 +975,16 @@ async def test_sentence_trigger_overrides_conversation_agent( start_stage=assist_pipeline.PipelineStage.INTENT, end_stage=assist_pipeline.PipelineStage.INTENT, event_callback=events.append, + intent_agent="test-agent", # not the default agent ), ) - await pipeline_input.validate() + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ): + await pipeline_input.validate() with patch( "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse" From 0626b005e26f96b036eb5c3a0d1073f19a2f33bf Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 22 Nov 2024 19:26:41 +0100 Subject: [PATCH 0737/1070] Bump aiopegelonline to 0.1.0 (#131295) --- homeassistant/components/pegel_online/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pegel_online/manifest.json b/homeassistant/components/pegel_online/manifest.json index d51278d0c1b3b..443e8c58467c0 100644 --- a/homeassistant/components/pegel_online/manifest.json +++ b/homeassistant/components/pegel_online/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiopegelonline"], - "requirements": ["aiopegelonline==0.0.10"] + "requirements": ["aiopegelonline==0.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d85ed4984bba4..75a2b0fee7e99 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -325,7 +325,7 @@ aioopenexchangerates==0.6.8 aiooui==0.1.7 # homeassistant.components.pegel_online -aiopegelonline==0.0.10 +aiopegelonline==0.1.0 # homeassistant.components.acmeda aiopulse==0.4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96fceb5ff33cb..93ed9f5cea948 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -307,7 +307,7 @@ aioopenexchangerates==0.6.8 aiooui==0.1.7 # homeassistant.components.pegel_online -aiopegelonline==0.0.10 +aiopegelonline==0.1.0 # homeassistant.components.acmeda aiopulse==0.4.6 From 96e67373dbb397c9a95499951aa4e32d55220420 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 22 Nov 2024 10:27:40 -0800 Subject: [PATCH 0738/1070] Validate quality scale tiers against the tier declared in the integration manifest (#131286) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- script/hassfest/manifest.py | 13 +-- script/hassfest/model.py | 10 ++ script/hassfest/quality_scale.py | 155 ++++++++++++++++++++----------- 3 files changed, 111 insertions(+), 67 deletions(-) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 53e63e0f1e65f..fdbcf5bcb78a5 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from enum import IntEnum, StrEnum, auto +from enum import StrEnum, auto import json from pathlib import Path import subprocess @@ -20,7 +20,7 @@ from homeassistant.const import Platform from homeassistant.helpers import config_validation as cv -from .model import Config, Integration +from .model import Config, Integration, ScaledQualityScaleTiers DOCUMENTATION_URL_SCHEMA = "https" DOCUMENTATION_URL_HOST = "www.home-assistant.io" @@ -28,15 +28,6 @@ DOCUMENTATION_URL_EXCEPTIONS = {"https://www.home-assistant.io/hassio"} -class ScaledQualityScaleTiers(IntEnum): - """Supported manifest quality scales.""" - - BRONZE = 1 - SILVER = 2 - GOLD = 3 - PLATINUM = 4 - - class NonScaledQualityScaleTiers(StrEnum): """Supported manifest quality scales.""" diff --git a/script/hassfest/model.py b/script/hassfest/model.py index 63e9b025ed459..377f82b0d5c24 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from enum import IntEnum import json import pathlib from typing import Any, Literal @@ -230,3 +231,12 @@ def load_manifest(self) -> None: self._manifest = manifest self.manifest_path = manifest_path + + +class ScaledQualityScaleTiers(IntEnum): + """Supported manifest quality scales.""" + + BRONZE = 1 + SILVER = 2 + GOLD = 3 + PLATINUM = 4 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index d36164e3b9285..8dab8f3b8acf0 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -9,62 +9,73 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util.yaml import load_yaml_dict -from .model import Config, Integration +from .model import Config, Integration, ScaledQualityScaleTiers -RULES = [ - "action-exceptions", - "action-setup", - "appropriate-polling", - "async-dependency", - "brands", - "common-modules", - "config-entry-unloading", - "config-flow", - "config-flow-test-coverage", - "dependency-transparency", - "devices", - "diagnostics", - "discovery", - "discovery-update-info", - "docs-actions", - "docs-configuration-parameters", - "docs-data-update", - "docs-examples", - "docs-high-level-description", - "docs-installation-instructions", - "docs-installation-parameters", - "docs-known-limitations", - "docs-removal-instructions", - "docs-supported-devices", - "docs-supported-functions", - "docs-troubleshooting", - "docs-use-cases", - "dynamic-devices", - "entity-category", - "entity-device-class", - "entity-disabled-by-default", - "entity-event-setup", - "entity-translations", - "entity-unavailable", - "entity-unique-id", - "exception-translations", - "has-entity-name", - "icon-translations", - "inject-websession", - "integration-owner", - "log-when-unavailable", - "parallel-updates", - "reauthentication-flow", - "reconfiguration-flow", - "repair-issues", - "runtime-data", - "stale-devices", - "strict-typing", - "test-before-configure", - "test-before-setup", - "test-coverage", - "unique-config-entry", -] +QUALITY_SCALE_TIERS = {value.name.lower(): value for value in ScaledQualityScaleTiers} + +RULES = { + ScaledQualityScaleTiers.BRONZE: [ + "action-setup", + "appropriate-polling", + "brands", + "common-modules", + "config-flow", + "config-flow-test-coverage", + "dependency-transparency", + "docs-actions", + "docs-high-level-description", + "docs-installation-parameters", + "docs-installation-instructions", + "docs-removal-instructions", + "entity-event-setup", + "entity-unique-id", + "has-entity-name", + "runtime-data", + "test-before-configure", + "test-before-setup", + "unique-config-entry", + ], + ScaledQualityScaleTiers.SILVER: [ + "action-exceptions", + "config-entry-unloading", + "docs-configuration-parameters", + "docs-installation-parameters", + "entity-unavailable", + "integration-owner", + "log-when-unavailable", + "parallel-updates", + "reauthentication-flow", + "test-coverage", + ], + ScaledQualityScaleTiers.GOLD: [ + "devices", + "diagnostics", + "discovery", + "discovery-update-info", + "docs-data-update", + "docs-examples", + "docs-known-limitations", + "docs-supported-devices", + "docs-supported-functions", + "docs-troubleshooting", + "docs-use-cases", + "dynamic-devices", + "entity-category", + "entity-device-class", + "entity-disabled-by-default", + "entity-translations", + "exception-translations", + "icon-translations", + "reconfiguration-flow", + "repair-issues", + "stale-devices", + ], + ScaledQualityScaleTiers.PLATINUM: [ + "async-dependency", + "inject-websession", + "strict-typing", + ], +} INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "abode", @@ -1264,7 +1275,8 @@ } ), ) - for rule in RULES + for tier_list in RULES.values() + for rule in tier_list } ) } @@ -1275,6 +1287,9 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: """Validate quality scale file for integration.""" if not integration.core: return + + declared_quality_scale = QUALITY_SCALE_TIERS.get(integration.quality_scale) + iqs_file = integration.path / "quality_scale.yaml" has_file = iqs_file.is_file() if not has_file: @@ -1288,6 +1303,12 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: "Quality scale definition not found. New integrations are required to at least reach the Bronze tier.", ) return + if declared_quality_scale is not None: + integration.add_error( + "quality_scale", + "Quality scale definition not found. Integrations that set a manifest quality scale must have a quality scale definition.", + ) + return return if integration.integration_type == "virtual": integration.add_error( @@ -1322,6 +1343,28 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: "quality_scale", f"Invalid {name}: {humanize_error(data, err)}" ) + if declared_quality_scale is None: + return + + rules_met = set() + for rule_name, rule_value in data.get("rules", {}).items(): + status = rule_value["status"] if isinstance(rule_value, dict) else rule_value + if status in {"done", "exempt"}: + rules_met.add(rule_name) + + # An integration must have all the necessary rules for the declared + # quality scale, and all the rules below. + for scale in ScaledQualityScaleTiers: + if scale > declared_quality_scale: + break + required_rules = set(RULES[scale]) + if missing_rules := (required_rules - rules_met): + friendly_rule_str = "\n".join(f" {rule}: todo" for rule in missing_rules) + integration.add_error( + "quality_scale", + f"Quality scale tier {scale.name.lower()} requires quality scale rules to be met:\n{friendly_rule_str}", + ) + def validate(integrations: dict[str, Integration], config: Config) -> None: """Handle YAML files inside integrations.""" From c61e94dac26519e9b76481e586d727534a7b2df0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 22 Nov 2024 19:27:49 +0100 Subject: [PATCH 0739/1070] Remove wrong periods from action names (#131290) --- homeassistant/components/hassio/strings.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index de42a317cc7d1..556a5a13f9568 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -279,7 +279,7 @@ } }, "addon_restart": { - "name": "Restart add-on.", + "name": "Restart add-on", "description": "Restarts an add-on.", "fields": { "addon": { @@ -289,7 +289,7 @@ } }, "addon_stdin": { - "name": "Write data to add-on stdin.", + "name": "Write data to add-on stdin", "description": "Writes data to the add-on's standard input.", "fields": { "addon": { @@ -299,7 +299,7 @@ } }, "addon_stop": { - "name": "Stop add-on.", + "name": "Stop add-on", "description": "Stops an add-on.", "fields": { "addon": { @@ -309,7 +309,7 @@ } }, "addon_update": { - "name": "Update add-on.", + "name": "Update add-on", "description": "Updates an add-on. This action should be used with caution since add-on updates can contain breaking changes. It is highly recommended that you review release notes/change logs before updating an add-on.", "fields": { "addon": { @@ -319,15 +319,15 @@ } }, "host_reboot": { - "name": "Reboot the host system.", + "name": "Reboot the host system", "description": "Reboots the host system." }, "host_shutdown": { - "name": "Power off the host system.", + "name": "Power off the host system", "description": "Powers off the host system." }, "backup_full": { - "name": "Create a full backup.", + "name": "Create a full backup", "description": "Creates a full backup.", "fields": { "name": { @@ -353,7 +353,7 @@ } }, "backup_partial": { - "name": "Create a partial backup.", + "name": "Create a partial backup", "description": "Creates a partial backup.", "fields": { "homeassistant": { @@ -391,7 +391,7 @@ } }, "restore_full": { - "name": "Restore from full backup.", + "name": "Restore from full backup", "description": "Restores from full backup.", "fields": { "slug": { @@ -405,7 +405,7 @@ } }, "restore_partial": { - "name": "Restore from partial backup.", + "name": "Restore from partial backup", "description": "Restores from a partial backup.", "fields": { "slug": { From 53b87f47fb2dfa27e7cf80cb8daf879a084a3245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 22 Nov 2024 19:28:08 +0100 Subject: [PATCH 0740/1070] Fix Home Connect service validation error placeholders (#131294) --- homeassistant/components/home_connect/__init__.py | 10 +++++----- homeassistant/components/home_connect/strings.json | 6 +++--- tests/components/home_connect/test_switch.py | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 47a5aa99edbb6..2c351f4dfa1b9 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -329,10 +329,10 @@ def update_unique_id( def get_dict_from_home_connect_error(err: api.HomeConnectError) -> dict[str, Any]: """Return a dict from a Home Connect error.""" - return ( - err.args[0] + return { + "description": cast(dict[str, Any], err.args[0]).get("description", "?") if len(err.args) > 0 and isinstance(err.args[0], dict) - else {"description": err.args[0]} + else err.args[0] if len(err.args) > 0 and isinstance(err.args[0], str) - else {} - ) + else "?", + } diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index eb57d822b155a..934aed5b7d5f5 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -38,13 +38,13 @@ "message": "Error while trying to set color of {entity_id}: {description}" }, "set_setting": { - "message": "Error while trying to assign the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}" + "message": "Error while trying to assign the value \"{value}\" to the setting \"{setting_key}\" for {entity_id}: {description}" }, "turn_on": { - "message": "Error while trying to turn on {entity_id} ({key}): {description}" + "message": "Error while trying to turn on {entity_id} ({setting_key}): {description}" }, "turn_off": { - "message": "Error while trying to turn off {entity_id} ({key}): {description}" + "message": "Error while trying to turn off {entity_id} ({setting_key}): {description}" }, "start_program": { "message": "Error while trying to start program {program}: {description}" diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 06201ffd58c9a..e4f45fbcdf9cb 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -162,7 +162,7 @@ async def test_switch_functionality( SERVICE_TURN_OFF, "set_setting", "Dishwasher", - r"Error.*turn.*off.*appliance.*value", + r"Error.*turn.*off.*", ), ( "switch.dishwasher_power", @@ -170,7 +170,7 @@ async def test_switch_functionality( SERVICE_TURN_ON, "set_setting", "Dishwasher", - r"Error.*turn.*on.*appliance.*", + r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", @@ -178,7 +178,7 @@ async def test_switch_functionality( SERVICE_TURN_ON, "set_setting", "Dishwasher", - r"Error.*turn.*on.*key.*", + r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", @@ -186,7 +186,7 @@ async def test_switch_functionality( SERVICE_TURN_OFF, "set_setting", "Dishwasher", - r"Error.*turn.*off.*key.*", + r"Error.*turn.*off.*", ), ], indirect=["problematic_appliance"], @@ -297,7 +297,7 @@ async def test_ent_desc_switch_functionality( SERVICE_TURN_ON, "set_setting", "FridgeFreezer", - r"Error.*turn.*on.*key.*", + r"Error.*turn.*on.*", ), ( "switch.fridgefreezer_freezer_super_mode", @@ -305,7 +305,7 @@ async def test_ent_desc_switch_functionality( SERVICE_TURN_OFF, "set_setting", "FridgeFreezer", - r"Error.*turn.*off.*key.*", + r"Error.*turn.*off.*", ), ], indirect=["problematic_appliance"], From 11f00895f7c77a3cdf68808838b01b0528b07972 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 22 Nov 2024 19:33:50 +0100 Subject: [PATCH 0741/1070] Add legacy to integrations that have not moved to the UI (#131171) --- homeassistant/components/acer_projector/manifest.json | 1 + homeassistant/components/actiontec/manifest.json | 3 ++- homeassistant/components/ads/manifest.json | 1 + homeassistant/components/alpha_vantage/manifest.json | 1 + homeassistant/components/amazon_polly/manifest.json | 1 + homeassistant/components/amcrest/manifest.json | 1 + homeassistant/components/ampio/manifest.json | 1 + homeassistant/components/anel_pwrctrl/manifest.json | 1 + homeassistant/components/apache_kafka/manifest.json | 1 + homeassistant/components/apprise/manifest.json | 1 + homeassistant/components/aprs/manifest.json | 1 + homeassistant/components/aqualogic/manifest.json | 1 + homeassistant/components/aquostv/manifest.json | 1 + homeassistant/components/arest/manifest.json | 3 ++- homeassistant/components/arris_tg2492lg/manifest.json | 1 + homeassistant/components/aruba/manifest.json | 1 + homeassistant/components/arwn/manifest.json | 3 ++- homeassistant/components/aten_pe/manifest.json | 1 + homeassistant/components/atome/manifest.json | 1 + homeassistant/components/avea/manifest.json | 1 + homeassistant/components/avion/manifest.json | 1 + homeassistant/components/aws/manifest.json | 1 + homeassistant/components/azure_service_bus/manifest.json | 1 + homeassistant/components/baidu/manifest.json | 1 + homeassistant/components/bbox/manifest.json | 1 + homeassistant/components/beewi_smartclim/manifest.json | 1 + homeassistant/components/bitcoin/manifest.json | 1 + homeassistant/components/bizkaibus/manifest.json | 1 + homeassistant/components/blackbird/manifest.json | 1 + homeassistant/components/blinksticklight/manifest.json | 1 + homeassistant/components/blockchain/manifest.json | 1 + homeassistant/components/bluetooth_le_tracker/manifest.json | 3 ++- homeassistant/components/bluetooth_tracker/manifest.json | 1 + homeassistant/components/bt_home_hub_5/manifest.json | 1 + homeassistant/components/bt_smarthub/manifest.json | 1 + homeassistant/components/channels/manifest.json | 1 + homeassistant/components/cisco_ios/manifest.json | 1 + homeassistant/components/cisco_mobility_express/manifest.json | 1 + homeassistant/components/cisco_webex_teams/manifest.json | 1 + homeassistant/components/citybikes/manifest.json | 3 ++- homeassistant/components/clementine/manifest.json | 1 + homeassistant/components/clickatell/manifest.json | 3 ++- homeassistant/components/clicksend/manifest.json | 3 ++- homeassistant/components/clicksend_tts/manifest.json | 3 ++- homeassistant/components/cmus/manifest.json | 1 + homeassistant/components/comed_hourly_pricing/manifest.json | 3 ++- homeassistant/components/comfoconnect/manifest.json | 1 + homeassistant/components/compensation/manifest.json | 1 + homeassistant/components/concord232/manifest.json | 1 + homeassistant/components/cppm_tracker/manifest.json | 1 + homeassistant/components/cups/manifest.json | 1 + homeassistant/components/currencylayer/manifest.json | 3 ++- homeassistant/components/danfoss_air/manifest.json | 1 + homeassistant/components/datadog/manifest.json | 1 + homeassistant/components/ddwrt/manifest.json | 3 ++- homeassistant/components/decora/manifest.json | 1 + homeassistant/components/decora_wifi/manifest.json | 1 + homeassistant/components/delijn/manifest.json | 1 + homeassistant/components/denon/manifest.json | 3 ++- homeassistant/components/digital_ocean/manifest.json | 1 + homeassistant/components/discogs/manifest.json | 1 + homeassistant/components/dlib_face_detect/manifest.json | 1 + homeassistant/components/dlib_face_identify/manifest.json | 1 + homeassistant/components/dominos/manifest.json | 1 + homeassistant/components/doods/manifest.json | 1 + homeassistant/components/dovado/manifest.json | 1 + homeassistant/components/dte_energy_bridge/manifest.json | 3 ++- homeassistant/components/dublin_bus_transport/manifest.json | 3 ++- homeassistant/components/duckdns/manifest.json | 3 ++- homeassistant/components/dweet/manifest.json | 1 + homeassistant/components/ebox/manifest.json | 1 + homeassistant/components/ebusd/manifest.json | 1 + homeassistant/components/ecoal_boiler/manifest.json | 1 + homeassistant/components/eddystone_temperature/manifest.json | 1 + homeassistant/components/edimax/manifest.json | 1 + homeassistant/components/egardia/manifest.json | 1 + homeassistant/components/eight_sleep/manifest.json | 1 + homeassistant/components/eliqonline/manifest.json | 1 + homeassistant/components/elv/manifest.json | 1 + homeassistant/components/emby/manifest.json | 1 + homeassistant/components/emoncms_history/manifest.json | 3 ++- homeassistant/components/entur_public_transport/manifest.json | 1 + homeassistant/components/envisalink/manifest.json | 1 + homeassistant/components/ephember/manifest.json | 1 + homeassistant/components/etherscan/manifest.json | 1 + homeassistant/components/eufy/manifest.json | 1 + homeassistant/components/everlights/manifest.json | 1 + homeassistant/components/evohome/manifest.json | 1 + homeassistant/components/facebook/manifest.json | 3 ++- homeassistant/components/fail2ban/manifest.json | 3 ++- homeassistant/components/familyhub/manifest.json | 1 + homeassistant/components/ffmpeg_motion/manifest.json | 3 ++- homeassistant/components/ffmpeg_noise/manifest.json | 3 ++- homeassistant/components/fido/manifest.json | 1 + homeassistant/components/fints/manifest.json | 1 + homeassistant/components/firmata/manifest.json | 1 + homeassistant/components/fixer/manifest.json | 1 + homeassistant/components/fleetgo/manifest.json | 1 + homeassistant/components/flexit/manifest.json | 3 ++- homeassistant/components/flic/manifest.json | 1 + homeassistant/components/flock/manifest.json | 3 ++- homeassistant/components/folder/manifest.json | 3 ++- homeassistant/components/foobot/manifest.json | 1 + homeassistant/components/fortios/manifest.json | 1 + homeassistant/components/foursquare/manifest.json | 3 ++- homeassistant/components/free_mobile/manifest.json | 1 + homeassistant/components/freedns/manifest.json | 3 ++- homeassistant/components/futurenow/manifest.json | 1 + homeassistant/components/garadget/manifest.json | 3 ++- homeassistant/components/gc100/manifest.json | 1 + homeassistant/components/geo_rss_events/manifest.json | 1 + homeassistant/components/gitlab_ci/manifest.json | 1 + homeassistant/components/gitter/manifest.json | 1 + homeassistant/components/go2rtc/manifest.json | 1 + homeassistant/components/google_maps/manifest.json | 1 + homeassistant/components/google_pubsub/manifest.json | 1 + homeassistant/components/google_wifi/manifest.json | 3 ++- homeassistant/components/graphite/manifest.json | 3 ++- homeassistant/components/greeneye_monitor/manifest.json | 1 + homeassistant/components/greenwave/manifest.json | 1 + homeassistant/components/gstreamer/manifest.json | 1 + homeassistant/components/gtfs/manifest.json | 1 + homeassistant/components/harman_kardon_avr/manifest.json | 1 + homeassistant/components/haveibeenpwned/manifest.json | 3 ++- homeassistant/components/hddtemp/manifest.json | 3 ++- homeassistant/components/hdmi_cec/manifest.json | 1 + homeassistant/components/heatmiser/manifest.json | 1 + homeassistant/components/hikvision/manifest.json | 1 + homeassistant/components/hikvisioncam/manifest.json | 1 + homeassistant/components/hitron_coda/manifest.json | 3 ++- homeassistant/components/homematic/manifest.json | 1 + homeassistant/components/horizon/manifest.json | 1 + homeassistant/components/hp_ilo/manifest.json | 1 + homeassistant/components/iammeter/manifest.json | 1 + homeassistant/components/idteck_prox/manifest.json | 1 + homeassistant/components/iglo/manifest.json | 1 + homeassistant/components/ign_sismologia/manifest.json | 1 + homeassistant/components/ihc/manifest.json | 1 + homeassistant/components/influxdb/manifest.json | 1 + homeassistant/components/intesishome/manifest.json | 1 + homeassistant/components/iperf3/manifest.json | 1 + homeassistant/components/irish_rail_transport/manifest.json | 1 + homeassistant/components/itach/manifest.json | 1 + homeassistant/components/itunes/manifest.json | 3 ++- homeassistant/components/joaoapps_join/manifest.json | 1 + homeassistant/components/kaiterra/manifest.json | 1 + homeassistant/components/kankun/manifest.json | 3 ++- homeassistant/components/keba/manifest.json | 1 + homeassistant/components/kef/manifest.json | 1 + homeassistant/components/keyboard/manifest.json | 1 + homeassistant/components/keyboard_remote/manifest.json | 1 + homeassistant/components/kira/manifest.json | 1 + homeassistant/components/kiwi/manifest.json | 1 + homeassistant/components/kwb/manifest.json | 1 + homeassistant/components/lacrosse/manifest.json | 1 + homeassistant/components/lannouncer/manifest.json | 3 ++- homeassistant/components/lifx_cloud/manifest.json | 3 ++- homeassistant/components/lightwave/manifest.json | 1 + homeassistant/components/limitlessled/manifest.json | 1 + homeassistant/components/linksys_smart/manifest.json | 3 ++- homeassistant/components/linode/manifest.json | 1 + homeassistant/components/linux_battery/manifest.json | 1 + homeassistant/components/lirc/manifest.json | 1 + homeassistant/components/llamalab_automate/manifest.json | 3 ++- homeassistant/components/logentries/manifest.json | 3 ++- homeassistant/components/london_air/manifest.json | 3 ++- homeassistant/components/london_underground/manifest.json | 1 + homeassistant/components/luci/manifest.json | 1 + homeassistant/components/lw12wifi/manifest.json | 1 + homeassistant/components/manual_mqtt/manifest.json | 3 ++- homeassistant/components/marytts/manifest.json | 1 + homeassistant/components/matrix/manifest.json | 1 + homeassistant/components/maxcube/manifest.json | 1 + homeassistant/components/mazda/manifest.json | 1 + homeassistant/components/mediaroom/manifest.json | 1 + homeassistant/components/melissa/manifest.json | 1 + homeassistant/components/meraki/manifest.json | 3 ++- homeassistant/components/message_bird/manifest.json | 1 + homeassistant/components/meteoalarm/manifest.json | 1 + homeassistant/components/mfi/manifest.json | 1 + homeassistant/components/microsoft/manifest.json | 1 + homeassistant/components/microsoft_face/manifest.json | 3 ++- homeassistant/components/microsoft_face_detect/manifest.json | 3 ++- homeassistant/components/microsoft_face_identify/manifest.json | 3 ++- homeassistant/components/minio/manifest.json | 1 + homeassistant/components/mochad/manifest.json | 1 + homeassistant/components/mqtt_eventstream/manifest.json | 3 ++- homeassistant/components/mqtt_json/manifest.json | 3 ++- homeassistant/components/mqtt_room/manifest.json | 3 ++- homeassistant/components/mqtt_statestream/manifest.json | 3 ++- homeassistant/components/msteams/manifest.json | 1 + homeassistant/components/mvglive/manifest.json | 1 + homeassistant/components/mycroft/manifest.json | 1 + homeassistant/components/mythicbeastsdns/manifest.json | 1 + homeassistant/components/nad/manifest.json | 1 + homeassistant/components/namecheapdns/manifest.json | 1 + homeassistant/components/nederlandse_spoorwegen/manifest.json | 1 + homeassistant/components/ness_alarm/manifest.json | 1 + homeassistant/components/netdata/manifest.json | 1 + homeassistant/components/netio/manifest.json | 1 + homeassistant/components/neurio_energy/manifest.json | 1 + homeassistant/components/niko_home_control/manifest.json | 1 + homeassistant/components/nilu/manifest.json | 1 + homeassistant/components/nissan_leaf/manifest.json | 1 + homeassistant/components/nmbs/manifest.json | 1 + homeassistant/components/no_ip/manifest.json | 3 ++- homeassistant/components/noaa_tides/manifest.json | 1 + homeassistant/components/norway_air/manifest.json | 1 + homeassistant/components/notify_events/manifest.json | 1 + homeassistant/components/nsw_fuel_station/manifest.json | 1 + .../components/nsw_rural_fire_service_feed/manifest.json | 1 + homeassistant/components/numato/manifest.json | 1 + homeassistant/components/nx584/manifest.json | 1 + homeassistant/components/oasa_telematics/manifest.json | 1 + homeassistant/components/oem/manifest.json | 1 + homeassistant/components/ohmconnect/manifest.json | 1 + homeassistant/components/ombi/manifest.json | 1 + homeassistant/components/openalpr_cloud/manifest.json | 3 ++- homeassistant/components/openerz/manifest.json | 1 + homeassistant/components/openevse/manifest.json | 1 + homeassistant/components/openhardwaremonitor/manifest.json | 3 ++- homeassistant/components/opensensemap/manifest.json | 1 + homeassistant/components/opnsense/manifest.json | 1 + homeassistant/components/opple/manifest.json | 1 + homeassistant/components/oru/manifest.json | 1 + homeassistant/components/orvibo/manifest.json | 1 + homeassistant/components/osramlightify/manifest.json | 1 + homeassistant/components/panasonic_bluray/manifest.json | 1 + homeassistant/components/pandora/manifest.json | 1 + homeassistant/components/pencom/manifest.json | 1 + homeassistant/components/picotts/manifest.json | 3 ++- homeassistant/components/pilight/manifest.json | 1 + homeassistant/components/pioneer/manifest.json | 3 ++- homeassistant/components/pjlink/manifest.json | 1 + homeassistant/components/pocketcasts/manifest.json | 1 + homeassistant/components/proliphix/manifest.json | 1 + homeassistant/components/prometheus/manifest.json | 1 + homeassistant/components/prowl/manifest.json | 3 ++- homeassistant/components/proxmoxve/manifest.json | 1 + homeassistant/components/proxy/manifest.json | 1 + homeassistant/components/pulseaudio_loopback/manifest.json | 1 + homeassistant/components/push/manifest.json | 3 ++- homeassistant/components/pushsafer/manifest.json | 3 ++- homeassistant/components/qld_bushfire/manifest.json | 1 + homeassistant/components/qrcode/manifest.json | 1 + homeassistant/components/quantum_gateway/manifest.json | 1 + homeassistant/components/qvr_pro/manifest.json | 1 + homeassistant/components/qwikswitch/manifest.json | 1 + homeassistant/components/raincloud/manifest.json | 1 + homeassistant/components/raspberry_pi/manifest.json | 3 ++- homeassistant/components/raspyrfm/manifest.json | 1 + homeassistant/components/recswitch/manifest.json | 1 + homeassistant/components/reddit/manifest.json | 1 + homeassistant/components/rejseplanen/manifest.json | 1 + homeassistant/components/remember_the_milk/manifest.json | 1 + homeassistant/components/remote_rpi_gpio/manifest.json | 1 + homeassistant/components/repetier/manifest.json | 1 + homeassistant/components/rflink/manifest.json | 1 + homeassistant/components/ripple/manifest.json | 1 + homeassistant/components/rmvtransport/manifest.json | 1 + homeassistant/components/rocketchat/manifest.json | 1 + homeassistant/components/route53/manifest.json | 1 + homeassistant/components/rpi_camera/manifest.json | 3 ++- homeassistant/components/rtorrent/manifest.json | 3 ++- homeassistant/components/russound_rnet/manifest.json | 1 + homeassistant/components/saj/manifest.json | 1 + homeassistant/components/satel_integra/manifest.json | 1 + homeassistant/components/schluter/manifest.json | 1 + homeassistant/components/scsgate/manifest.json | 1 + homeassistant/components/sendgrid/manifest.json | 1 + homeassistant/components/serial_pm/manifest.json | 1 + homeassistant/components/sesame/manifest.json | 1 + homeassistant/components/seven_segments/manifest.json | 1 + homeassistant/components/shodan/manifest.json | 1 + homeassistant/components/sigfox/manifest.json | 3 ++- homeassistant/components/sighthound/manifest.json | 1 + homeassistant/components/signal_messenger/manifest.json | 1 + homeassistant/components/sinch/manifest.json | 1 + homeassistant/components/sisyphus/manifest.json | 1 + homeassistant/components/sky_hub/manifest.json | 1 + homeassistant/components/skybeacon/manifest.json | 1 + homeassistant/components/slide/manifest.json | 1 + homeassistant/components/smtp/manifest.json | 3 ++- homeassistant/components/snips/manifest.json | 3 ++- homeassistant/components/snmp/manifest.json | 1 + homeassistant/components/solaredge_local/manifest.json | 1 + homeassistant/components/sony_projector/manifest.json | 1 + homeassistant/components/spaceapi/manifest.json | 3 ++- homeassistant/components/spc/manifest.json | 1 + homeassistant/components/splunk/manifest.json | 1 + homeassistant/components/starlingbank/manifest.json | 1 + homeassistant/components/startca/manifest.json | 1 + homeassistant/components/statsd/manifest.json | 1 + homeassistant/components/stiebel_eltron/manifest.json | 1 + homeassistant/components/supervisord/manifest.json | 3 ++- homeassistant/components/supla/manifest.json | 1 + homeassistant/components/swiss_hydrological_data/manifest.json | 1 + homeassistant/components/swisscom/manifest.json | 3 ++- homeassistant/components/switchmate/manifest.json | 1 + homeassistant/components/synology_chat/manifest.json | 3 ++- homeassistant/components/synology_srm/manifest.json | 1 + homeassistant/components/syslog/manifest.json | 3 ++- homeassistant/components/tank_utility/manifest.json | 1 + homeassistant/components/tapsaff/manifest.json | 1 + homeassistant/components/tcp/manifest.json | 3 ++- homeassistant/components/ted5000/manifest.json | 1 + homeassistant/components/telegram/manifest.json | 3 ++- homeassistant/components/telegram_bot/manifest.json | 1 + homeassistant/components/tellstick/manifest.json | 1 + homeassistant/components/telnet/manifest.json | 3 ++- homeassistant/components/temper/manifest.json | 1 + homeassistant/components/tensorflow/manifest.json | 1 + homeassistant/components/tfiac/manifest.json | 1 + homeassistant/components/thermoworks_smoke/manifest.json | 1 + homeassistant/components/thingspeak/manifest.json | 1 + homeassistant/components/thinkingcleaner/manifest.json | 1 + homeassistant/components/thomson/manifest.json | 3 ++- homeassistant/components/tikteck/manifest.json | 1 + homeassistant/components/tmb/manifest.json | 1 + homeassistant/components/tomato/manifest.json | 3 ++- homeassistant/components/torque/manifest.json | 3 ++- homeassistant/components/touchline/manifest.json | 1 + homeassistant/components/tplink_lte/manifest.json | 1 + homeassistant/components/transport_nsw/manifest.json | 1 + homeassistant/components/travisci/manifest.json | 1 + homeassistant/components/twilio_call/manifest.json | 3 ++- homeassistant/components/twilio_sms/manifest.json | 3 ++- homeassistant/components/twitter/manifest.json | 1 + homeassistant/components/ubus/manifest.json | 1 + homeassistant/components/uk_transport/manifest.json | 3 ++- homeassistant/components/unifi_direct/manifest.json | 1 + homeassistant/components/unifiled/manifest.json | 1 + homeassistant/components/upc_connect/manifest.json | 1 + homeassistant/components/usgs_earthquakes_feed/manifest.json | 1 + homeassistant/components/uvc/manifest.json | 1 + homeassistant/components/vasttrafik/manifest.json | 1 + homeassistant/components/versasense/manifest.json | 1 + homeassistant/components/viaggiatreno/manifest.json | 3 ++- homeassistant/components/vivotek/manifest.json | 1 + homeassistant/components/vlc/manifest.json | 1 + homeassistant/components/voicerss/manifest.json | 3 ++- homeassistant/components/volkszaehler/manifest.json | 1 + homeassistant/components/vultr/manifest.json | 1 + homeassistant/components/w800rf32/manifest.json | 1 + homeassistant/components/waterfurnace/manifest.json | 1 + homeassistant/components/watson_iot/manifest.json | 1 + homeassistant/components/watson_tts/manifest.json | 1 + homeassistant/components/wirelesstag/manifest.json | 1 + homeassistant/components/worldtidesinfo/manifest.json | 3 ++- homeassistant/components/worxlandroid/manifest.json | 3 ++- homeassistant/components/wsdot/manifest.json | 3 ++- homeassistant/components/x10/manifest.json | 3 ++- homeassistant/components/xeoma/manifest.json | 1 + homeassistant/components/xiaomi/manifest.json | 3 ++- homeassistant/components/xiaomi_tv/manifest.json | 1 + homeassistant/components/xmpp/manifest.json | 1 + homeassistant/components/xs1/manifest.json | 1 + homeassistant/components/yamaha/manifest.json | 1 + homeassistant/components/yandex_transport/manifest.json | 1 + homeassistant/components/yandextts/manifest.json | 3 ++- homeassistant/components/yeelightsunflower/manifest.json | 1 + homeassistant/components/yi/manifest.json | 1 + homeassistant/components/zabbix/manifest.json | 1 + homeassistant/components/zengge/manifest.json | 1 + homeassistant/components/zestimate/manifest.json | 1 + homeassistant/components/zhong_hong/manifest.json | 1 + homeassistant/components/ziggo_mediabox_xl/manifest.json | 1 + homeassistant/components/zoneminder/manifest.json | 1 + 368 files changed, 452 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/acer_projector/manifest.json b/homeassistant/components/acer_projector/manifest.json index 58a2372e42a8f..026374bf53d01 100644 --- a/homeassistant/components/acer_projector/manifest.json +++ b/homeassistant/components/acer_projector/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/acer_projector", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["pyserial==3.5"] } diff --git a/homeassistant/components/actiontec/manifest.json b/homeassistant/components/actiontec/manifest.json index ff9cf85614f57..e7aa33f1baf7c 100644 --- a/homeassistant/components/actiontec/manifest.json +++ b/homeassistant/components/actiontec/manifest.json @@ -3,5 +3,6 @@ "name": "Actiontec", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/actiontec", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/ads/manifest.json b/homeassistant/components/ads/manifest.json index 86fc54ea78485..683c3cb619f02 100644 --- a/homeassistant/components/ads/manifest.json +++ b/homeassistant/components/ads/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/ads", "iot_class": "local_push", "loggers": ["pyads"], + "quality_scale": "legacy", "requirements": ["pyads==3.4.0"] } diff --git a/homeassistant/components/alpha_vantage/manifest.json b/homeassistant/components/alpha_vantage/manifest.json index c94da6bf4874c..cdfa847d1159c 100644 --- a/homeassistant/components/alpha_vantage/manifest.json +++ b/homeassistant/components/alpha_vantage/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/alpha_vantage", "iot_class": "cloud_polling", "loggers": ["alpha_vantage"], + "quality_scale": "legacy", "requirements": ["alpha-vantage==2.3.1"] } diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json index b057967d1e22d..e7fbf8edc7493 100644 --- a/homeassistant/components/amazon_polly/manifest.json +++ b/homeassistant/components/amazon_polly/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/amazon_polly", "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], + "quality_scale": "legacy", "requirements": ["boto3==1.34.131"] } diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 8b8d87092c487..7d8f8f9e6c892 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/amcrest", "iot_class": "local_polling", "loggers": ["amcrest"], + "quality_scale": "legacy", "requirements": ["amcrest==1.9.8"] } diff --git a/homeassistant/components/ampio/manifest.json b/homeassistant/components/ampio/manifest.json index bc9c09d817ae3..17fc3eb3d964e 100644 --- a/homeassistant/components/ampio/manifest.json +++ b/homeassistant/components/ampio/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/ampio", "iot_class": "cloud_polling", "loggers": ["asmog"], + "quality_scale": "legacy", "requirements": ["asmog==0.0.6"] } diff --git a/homeassistant/components/anel_pwrctrl/manifest.json b/homeassistant/components/anel_pwrctrl/manifest.json index 48cc3b96ec088..67c881a3db22c 100644 --- a/homeassistant/components/anel_pwrctrl/manifest.json +++ b/homeassistant/components/anel_pwrctrl/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/anel_pwrctrl", "iot_class": "local_polling", "loggers": ["anel_pwrctrl"], + "quality_scale": "legacy", "requirements": ["anel-pwrctrl-homeassistant==0.0.1.dev2"] } diff --git a/homeassistant/components/apache_kafka/manifest.json b/homeassistant/components/apache_kafka/manifest.json index f6593631bc0b7..05baaac32a2b0 100644 --- a/homeassistant/components/apache_kafka/manifest.json +++ b/homeassistant/components/apache_kafka/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/apache_kafka", "iot_class": "local_push", "loggers": ["aiokafka", "kafka_python"], + "quality_scale": "legacy", "requirements": ["aiokafka==0.10.0"] } diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 838611e47986f..4f3c4d7ef4e02 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], + "quality_scale": "legacy", "requirements": ["apprise==1.9.0"] } diff --git a/homeassistant/components/aprs/manifest.json b/homeassistant/components/aprs/manifest.json index 63826f5a3857b..7518405f1eccb 100644 --- a/homeassistant/components/aprs/manifest.json +++ b/homeassistant/components/aprs/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/aprs", "iot_class": "cloud_push", "loggers": ["aprslib", "geographiclib", "geopy"], + "quality_scale": "legacy", "requirements": ["aprslib==0.7.2", "geopy==2.3.0"] } diff --git a/homeassistant/components/aqualogic/manifest.json b/homeassistant/components/aqualogic/manifest.json index 783e4c8c2042a..cc807e4bb1983 100644 --- a/homeassistant/components/aqualogic/manifest.json +++ b/homeassistant/components/aqualogic/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/aqualogic", "iot_class": "local_push", "loggers": ["aqualogic"], + "quality_scale": "legacy", "requirements": ["aqualogic==2.6"] } diff --git a/homeassistant/components/aquostv/manifest.json b/homeassistant/components/aquostv/manifest.json index 1bac2bdfb5ff8..6fc1092d33cdf 100644 --- a/homeassistant/components/aquostv/manifest.json +++ b/homeassistant/components/aquostv/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/aquostv", "iot_class": "local_polling", "loggers": ["sharp_aquos_rc"], + "quality_scale": "legacy", "requirements": ["sharp_aquos_rc==0.3.2"] } diff --git a/homeassistant/components/arest/manifest.json b/homeassistant/components/arest/manifest.json index 53732d15064e5..be43b3aafc9e1 100644 --- a/homeassistant/components/arest/manifest.json +++ b/homeassistant/components/arest/manifest.json @@ -3,5 +3,6 @@ "name": "aREST", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/arest", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/arris_tg2492lg/manifest.json b/homeassistant/components/arris_tg2492lg/manifest.json index c36423d287a56..98778de5f2a3a 100644 --- a/homeassistant/components/arris_tg2492lg/manifest.json +++ b/homeassistant/components/arris_tg2492lg/manifest.json @@ -6,5 +6,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["arris_tg2492lg"], + "quality_scale": "legacy", "requirements": ["arris-tg2492lg==2.2.0"] } diff --git a/homeassistant/components/aruba/manifest.json b/homeassistant/components/aruba/manifest.json index 0d1fabf51b883..c98dda754cd5d 100644 --- a/homeassistant/components/aruba/manifest.json +++ b/homeassistant/components/aruba/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/aruba", "iot_class": "local_polling", "loggers": ["pexpect", "ptyprocess"], + "quality_scale": "legacy", "requirements": ["pexpect==4.6.0"] } diff --git a/homeassistant/components/arwn/manifest.json b/homeassistant/components/arwn/manifest.json index 15eb656e9746f..8cabb045b6421 100644 --- a/homeassistant/components/arwn/manifest.json +++ b/homeassistant/components/arwn/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["mqtt"], "documentation": "https://www.home-assistant.io/integrations/arwn", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/aten_pe/manifest.json b/homeassistant/components/aten_pe/manifest.json index 3b4ade637cbdc..1e2c74f263657 100644 --- a/homeassistant/components/aten_pe/manifest.json +++ b/homeassistant/components/aten_pe/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@mtdcr"], "documentation": "https://www.home-assistant.io/integrations/aten_pe", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["atenpdu==0.3.2"] } diff --git a/homeassistant/components/atome/manifest.json b/homeassistant/components/atome/manifest.json index cafe24e2e1349..f00dd5ea757ef 100644 --- a/homeassistant/components/atome/manifest.json +++ b/homeassistant/components/atome/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/atome", "iot_class": "cloud_polling", "loggers": ["pyatome"], + "quality_scale": "legacy", "requirements": ["pyAtome==0.1.1"] } diff --git a/homeassistant/components/avea/manifest.json b/homeassistant/components/avea/manifest.json index 43c46c96e6629..7e6c080481ebc 100644 --- a/homeassistant/components/avea/manifest.json +++ b/homeassistant/components/avea/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/avea", "iot_class": "local_polling", "loggers": ["avea"], + "quality_scale": "legacy", "requirements": ["avea==1.5.1"] } diff --git a/homeassistant/components/avion/manifest.json b/homeassistant/components/avion/manifest.json index 505dca870a715..8488e949af35a 100644 --- a/homeassistant/components/avion/manifest.json +++ b/homeassistant/components/avion/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/avion", "iot_class": "assumed_state", + "quality_scale": "legacy", "requirements": ["avion==0.10"] } diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json index 6238bffce365e..12149e4388ad1 100644 --- a/homeassistant/components/aws/manifest.json +++ b/homeassistant/components/aws/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/aws", "iot_class": "cloud_push", "loggers": ["aiobotocore", "botocore"], + "quality_scale": "legacy", "requirements": ["aiobotocore==2.13.1", "botocore==1.34.131"] } diff --git a/homeassistant/components/azure_service_bus/manifest.json b/homeassistant/components/azure_service_bus/manifest.json index 059f6300aec1e..31c1edac68692 100644 --- a/homeassistant/components/azure_service_bus/manifest.json +++ b/homeassistant/components/azure_service_bus/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/azure_service_bus", "iot_class": "cloud_push", "loggers": ["azure"], + "quality_scale": "legacy", "requirements": ["azure-servicebus==7.10.0"] } diff --git a/homeassistant/components/baidu/manifest.json b/homeassistant/components/baidu/manifest.json index 8213b7cbe5eba..32f14100b81fa 100644 --- a/homeassistant/components/baidu/manifest.json +++ b/homeassistant/components/baidu/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/baidu", "iot_class": "cloud_push", "loggers": ["aip"], + "quality_scale": "legacy", "requirements": ["baidu-aip==1.6.6"] } diff --git a/homeassistant/components/bbox/manifest.json b/homeassistant/components/bbox/manifest.json index 9035bea74bc8c..67e54ae235920 100644 --- a/homeassistant/components/bbox/manifest.json +++ b/homeassistant/components/bbox/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/bbox", "iot_class": "local_polling", "loggers": ["pybbox"], + "quality_scale": "legacy", "requirements": ["pybbox==0.0.5-alpha"] } diff --git a/homeassistant/components/beewi_smartclim/manifest.json b/homeassistant/components/beewi_smartclim/manifest.json index 3555f9181bb6c..baf41be434536 100644 --- a/homeassistant/components/beewi_smartclim/manifest.json +++ b/homeassistant/components/beewi_smartclim/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/beewi_smartclim", "iot_class": "local_polling", "loggers": ["beewi_smartclim"], + "quality_scale": "legacy", "requirements": ["beewi-smartclim==0.0.10"] } diff --git a/homeassistant/components/bitcoin/manifest.json b/homeassistant/components/bitcoin/manifest.json index 6f5fd678009c8..b208e904cab8a 100644 --- a/homeassistant/components/bitcoin/manifest.json +++ b/homeassistant/components/bitcoin/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/bitcoin", "iot_class": "cloud_polling", "loggers": ["blockchain"], + "quality_scale": "legacy", "requirements": ["blockchain==1.4.4"] } diff --git a/homeassistant/components/bizkaibus/manifest.json b/homeassistant/components/bizkaibus/manifest.json index b47df75bbe57e..5a33354640116 100644 --- a/homeassistant/components/bizkaibus/manifest.json +++ b/homeassistant/components/bizkaibus/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/bizkaibus", "iot_class": "cloud_polling", "loggers": ["bizkaibus"], + "quality_scale": "legacy", "requirements": ["bizkaibus==0.1.1"] } diff --git a/homeassistant/components/blackbird/manifest.json b/homeassistant/components/blackbird/manifest.json index d75b69dfaf898..a0f4b0c383cf3 100644 --- a/homeassistant/components/blackbird/manifest.json +++ b/homeassistant/components/blackbird/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/blackbird", "iot_class": "local_polling", "loggers": ["pyblackbird"], + "quality_scale": "legacy", "requirements": ["pyblackbird==0.6"] } diff --git a/homeassistant/components/blinksticklight/manifest.json b/homeassistant/components/blinksticklight/manifest.json index 70fac896ff208..d3592b6af6e2e 100644 --- a/homeassistant/components/blinksticklight/manifest.json +++ b/homeassistant/components/blinksticklight/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/blinksticklight", "iot_class": "local_polling", "loggers": ["blinkstick"], + "quality_scale": "legacy", "requirements": ["BlinkStick==1.2.0"] } diff --git a/homeassistant/components/blockchain/manifest.json b/homeassistant/components/blockchain/manifest.json index 2e58dc5aa03c9..6c9182ee0c422 100644 --- a/homeassistant/components/blockchain/manifest.json +++ b/homeassistant/components/blockchain/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/blockchain", "iot_class": "cloud_polling", "loggers": ["pyblockchain"], + "quality_scale": "legacy", "requirements": ["python-blockchain-api==0.0.2"] } diff --git a/homeassistant/components/bluetooth_le_tracker/manifest.json b/homeassistant/components/bluetooth_le_tracker/manifest.json index 79f885cad1895..4abf5f7607e07 100644 --- a/homeassistant/components/bluetooth_le_tracker/manifest.json +++ b/homeassistant/components/bluetooth_le_tracker/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bluetooth_le_tracker", - "iot_class": "local_push" + "iot_class": "local_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/bluetooth_tracker/manifest.json b/homeassistant/components/bluetooth_tracker/manifest.json index 0a0356e666964..8fb35b311c91a 100644 --- a/homeassistant/components/bluetooth_tracker/manifest.json +++ b/homeassistant/components/bluetooth_tracker/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/bluetooth_tracker", "iot_class": "local_polling", "loggers": ["bluetooth", "bt_proximity"], + "quality_scale": "legacy", "requirements": ["bt-proximity==0.2.1", "PyBluez==0.22"] } diff --git a/homeassistant/components/bt_home_hub_5/manifest.json b/homeassistant/components/bt_home_hub_5/manifest.json index c2d708d9a027b..e260d443dc752 100644 --- a/homeassistant/components/bt_home_hub_5/manifest.json +++ b/homeassistant/components/bt_home_hub_5/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/bt_home_hub_5", "iot_class": "local_polling", "loggers": ["bthomehub5_devicelist"], + "quality_scale": "legacy", "requirements": ["bthomehub5-devicelist==0.1.1"] } diff --git a/homeassistant/components/bt_smarthub/manifest.json b/homeassistant/components/bt_smarthub/manifest.json index 8f2dc631e8061..31dd99a493f9e 100644 --- a/homeassistant/components/bt_smarthub/manifest.json +++ b/homeassistant/components/bt_smarthub/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/bt_smarthub", "iot_class": "local_polling", "loggers": ["btsmarthub_devicelist"], + "quality_scale": "legacy", "requirements": ["btsmarthub-devicelist==0.2.3"] } diff --git a/homeassistant/components/channels/manifest.json b/homeassistant/components/channels/manifest.json index 0455ca2e8adc1..9476e006eda57 100644 --- a/homeassistant/components/channels/manifest.json +++ b/homeassistant/components/channels/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/channels", "iot_class": "local_polling", "loggers": ["pychannels"], + "quality_scale": "legacy", "requirements": ["pychannels==1.2.3"] } diff --git a/homeassistant/components/cisco_ios/manifest.json b/homeassistant/components/cisco_ios/manifest.json index dd0d42139737a..ba0678c167f9d 100644 --- a/homeassistant/components/cisco_ios/manifest.json +++ b/homeassistant/components/cisco_ios/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/cisco_ios", "iot_class": "local_polling", "loggers": ["pexpect", "ptyprocess"], + "quality_scale": "legacy", "requirements": ["pexpect==4.6.0"] } diff --git a/homeassistant/components/cisco_mobility_express/manifest.json b/homeassistant/components/cisco_mobility_express/manifest.json index 02786e80cd89f..f9ee1c92ed14c 100644 --- a/homeassistant/components/cisco_mobility_express/manifest.json +++ b/homeassistant/components/cisco_mobility_express/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/cisco_mobility_express", "iot_class": "local_polling", "loggers": ["ciscomobilityexpress"], + "quality_scale": "legacy", "requirements": ["ciscomobilityexpress==0.3.9"] } diff --git a/homeassistant/components/cisco_webex_teams/manifest.json b/homeassistant/components/cisco_webex_teams/manifest.json index 3da31a0b453a0..85cfeb7eddf8f 100644 --- a/homeassistant/components/cisco_webex_teams/manifest.json +++ b/homeassistant/components/cisco_webex_teams/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/cisco_webex_teams", "iot_class": "cloud_push", "loggers": ["webexpythonsdk"], + "quality_scale": "legacy", "requirements": ["webexpythonsdk==2.0.1"] } diff --git a/homeassistant/components/citybikes/manifest.json b/homeassistant/components/citybikes/manifest.json index e163b85ec0801..8dac7def832cf 100644 --- a/homeassistant/components/citybikes/manifest.json +++ b/homeassistant/components/citybikes/manifest.json @@ -3,5 +3,6 @@ "name": "CityBikes", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/citybikes", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/clementine/manifest.json b/homeassistant/components/clementine/manifest.json index 88e7f35f49a52..42fe81d0e9b97 100644 --- a/homeassistant/components/clementine/manifest.json +++ b/homeassistant/components/clementine/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/clementine", "iot_class": "local_polling", "loggers": ["clementineremote"], + "quality_scale": "legacy", "requirements": ["python-clementine-remote==1.0.1"] } diff --git a/homeassistant/components/clickatell/manifest.json b/homeassistant/components/clickatell/manifest.json index 31456b25c641a..3c5ee8b0053ce 100644 --- a/homeassistant/components/clickatell/manifest.json +++ b/homeassistant/components/clickatell/manifest.json @@ -3,5 +3,6 @@ "name": "Clickatell", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/clickatell", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/clicksend/manifest.json b/homeassistant/components/clicksend/manifest.json index 41bd10108f443..8a43428026bcf 100644 --- a/homeassistant/components/clicksend/manifest.json +++ b/homeassistant/components/clicksend/manifest.json @@ -3,5 +3,6 @@ "name": "ClickSend SMS", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/clicksend", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/clicksend_tts/manifest.json b/homeassistant/components/clicksend_tts/manifest.json index ffa35fd070fd9..eb884e4120389 100644 --- a/homeassistant/components/clicksend_tts/manifest.json +++ b/homeassistant/components/clicksend_tts/manifest.json @@ -3,5 +3,6 @@ "name": "ClickSend TTS", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/clicksend_tts", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/cmus/manifest.json b/homeassistant/components/cmus/manifest.json index f759159902258..9678dc52a6874 100644 --- a/homeassistant/components/cmus/manifest.json +++ b/homeassistant/components/cmus/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/cmus", "iot_class": "local_polling", "loggers": ["pbr", "pycmus"], + "quality_scale": "legacy", "requirements": ["pycmus==0.1.1"] } diff --git a/homeassistant/components/comed_hourly_pricing/manifest.json b/homeassistant/components/comed_hourly_pricing/manifest.json index 791a824af8fdb..a3a29903ac784 100644 --- a/homeassistant/components/comed_hourly_pricing/manifest.json +++ b/homeassistant/components/comed_hourly_pricing/manifest.json @@ -3,5 +3,6 @@ "name": "ComEd Hourly Pricing", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/comed_hourly_pricing", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/comfoconnect/manifest.json b/homeassistant/components/comfoconnect/manifest.json index ae9a092f5d99a..4157cb6c311ea 100644 --- a/homeassistant/components/comfoconnect/manifest.json +++ b/homeassistant/components/comfoconnect/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/comfoconnect", "iot_class": "local_push", "loggers": ["pycomfoconnect"], + "quality_scale": "legacy", "requirements": ["pycomfoconnect==0.5.1"] } diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 775bde3c8591e..5b3cc5ac2acee 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@Petro31"], "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", + "quality_scale": "legacy", "requirements": ["numpy==2.1.3"] } diff --git a/homeassistant/components/concord232/manifest.json b/homeassistant/components/concord232/manifest.json index e0aea5d64d948..ebd1d68064b47 100644 --- a/homeassistant/components/concord232/manifest.json +++ b/homeassistant/components/concord232/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/concord232", "iot_class": "local_polling", "loggers": ["concord232", "stevedore"], + "quality_scale": "legacy", "requirements": ["concord232==0.15.1"] } diff --git a/homeassistant/components/cppm_tracker/manifest.json b/homeassistant/components/cppm_tracker/manifest.json index d8c387cdbf47c..ca2fdf71a45dc 100644 --- a/homeassistant/components/cppm_tracker/manifest.json +++ b/homeassistant/components/cppm_tracker/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/cppm_tracker", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["clearpasspy==1.0.2"] } diff --git a/homeassistant/components/cups/manifest.json b/homeassistant/components/cups/manifest.json index 3e5b46770fb3a..c4aa596f01e04 100644 --- a/homeassistant/components/cups/manifest.json +++ b/homeassistant/components/cups/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/cups", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["pycups==1.9.73"] } diff --git a/homeassistant/components/currencylayer/manifest.json b/homeassistant/components/currencylayer/manifest.json index d66331c4ab033..82d9d4050d455 100644 --- a/homeassistant/components/currencylayer/manifest.json +++ b/homeassistant/components/currencylayer/manifest.json @@ -3,5 +3,6 @@ "name": "currencylayer", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/currencylayer", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/danfoss_air/manifest.json b/homeassistant/components/danfoss_air/manifest.json index 9eea3221bbe29..57cb1aa7218bc 100644 --- a/homeassistant/components/danfoss_air/manifest.json +++ b/homeassistant/components/danfoss_air/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/danfoss_air", "iot_class": "local_polling", "loggers": ["pydanfossair"], + "quality_scale": "legacy", "requirements": ["pydanfossair==0.1.0"] } diff --git a/homeassistant/components/datadog/manifest.json b/homeassistant/components/datadog/manifest.json index 4ae24a80c6c7c..ca9681effcaa9 100644 --- a/homeassistant/components/datadog/manifest.json +++ b/homeassistant/components/datadog/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/datadog", "iot_class": "local_push", "loggers": ["datadog"], + "quality_scale": "legacy", "requirements": ["datadog==0.15.0"] } diff --git a/homeassistant/components/ddwrt/manifest.json b/homeassistant/components/ddwrt/manifest.json index 98ea17b06590a..9a2b2470131c7 100644 --- a/homeassistant/components/ddwrt/manifest.json +++ b/homeassistant/components/ddwrt/manifest.json @@ -3,5 +3,6 @@ "name": "DD-WRT", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/ddwrt", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/decora/manifest.json b/homeassistant/components/decora/manifest.json index bef42f8b4ab01..64dc01d09a1fb 100644 --- a/homeassistant/components/decora/manifest.json +++ b/homeassistant/components/decora/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/decora", "iot_class": "local_polling", "loggers": ["bluepy", "decora"], + "quality_scale": "legacy", "requirements": ["bluepy==1.3.0", "decora==0.6"] } diff --git a/homeassistant/components/decora_wifi/manifest.json b/homeassistant/components/decora_wifi/manifest.json index 0bead527e78e7..25892dc3e643d 100644 --- a/homeassistant/components/decora_wifi/manifest.json +++ b/homeassistant/components/decora_wifi/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/decora_wifi", "iot_class": "cloud_polling", "loggers": ["decora_wifi"], + "quality_scale": "legacy", "requirements": ["decora-wifi==1.4"] } diff --git a/homeassistant/components/delijn/manifest.json b/homeassistant/components/delijn/manifest.json index d25dab4234ec0..b87242d6e9474 100644 --- a/homeassistant/components/delijn/manifest.json +++ b/homeassistant/components/delijn/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/delijn", "iot_class": "cloud_polling", "loggers": ["pydelijn"], + "quality_scale": "legacy", "requirements": ["pydelijn==1.1.0"] } diff --git a/homeassistant/components/denon/manifest.json b/homeassistant/components/denon/manifest.json index d94e8a264e3cc..9e840b43fcf6d 100644 --- a/homeassistant/components/denon/manifest.json +++ b/homeassistant/components/denon/manifest.json @@ -3,5 +3,6 @@ "name": "Denon Network Receivers", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/denon", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/digital_ocean/manifest.json b/homeassistant/components/digital_ocean/manifest.json index 7fee8ca5b2b86..819a557491a44 100644 --- a/homeassistant/components/digital_ocean/manifest.json +++ b/homeassistant/components/digital_ocean/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/digital_ocean", "iot_class": "local_polling", "loggers": ["digitalocean"], + "quality_scale": "legacy", "requirements": ["python-digitalocean==1.13.2"] } diff --git a/homeassistant/components/discogs/manifest.json b/homeassistant/components/discogs/manifest.json index fceb214aded2a..f724b4bc6fd13 100644 --- a/homeassistant/components/discogs/manifest.json +++ b/homeassistant/components/discogs/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/discogs", "iot_class": "cloud_polling", "loggers": ["discogs_client"], + "quality_scale": "legacy", "requirements": ["discogs-client==2.3.0"] } diff --git a/homeassistant/components/dlib_face_detect/manifest.json b/homeassistant/components/dlib_face_detect/manifest.json index e395a84f2061d..e8476583081b4 100644 --- a/homeassistant/components/dlib_face_detect/manifest.json +++ b/homeassistant/components/dlib_face_detect/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/dlib_face_detect", "iot_class": "local_push", "loggers": ["face_recognition"], + "quality_scale": "legacy", "requirements": ["face-recognition==1.2.3"] } diff --git a/homeassistant/components/dlib_face_identify/manifest.json b/homeassistant/components/dlib_face_identify/manifest.json index 60c0ef3c76692..2a764e4a3e841 100644 --- a/homeassistant/components/dlib_face_identify/manifest.json +++ b/homeassistant/components/dlib_face_identify/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/dlib_face_identify", "iot_class": "local_push", "loggers": ["face_recognition"], + "quality_scale": "legacy", "requirements": ["face-recognition==1.2.3"] } diff --git a/homeassistant/components/dominos/manifest.json b/homeassistant/components/dominos/manifest.json index 442f433db7c80..5618c6f0d8768 100644 --- a/homeassistant/components/dominos/manifest.json +++ b/homeassistant/components/dominos/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/dominos", "iot_class": "cloud_polling", "loggers": ["pizzapi"], + "quality_scale": "legacy", "requirements": ["pizzapi==0.0.6"] } diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 7c85ca634670a..ae307bb4962ca 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], + "quality_scale": "legacy", "requirements": ["pydoods==1.0.2", "Pillow==11.0.0"] } diff --git a/homeassistant/components/dovado/manifest.json b/homeassistant/components/dovado/manifest.json index 9a0fc46ad160d..78b1e0c671962 100644 --- a/homeassistant/components/dovado/manifest.json +++ b/homeassistant/components/dovado/manifest.json @@ -5,5 +5,6 @@ "disabled": "This integration is disabled because it uses non-open source code to operate.", "documentation": "https://www.home-assistant.io/integrations/dovado", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["dovado==0.4.1"] } diff --git a/homeassistant/components/dte_energy_bridge/manifest.json b/homeassistant/components/dte_energy_bridge/manifest.json index f5b57d8286912..8285469a74536 100644 --- a/homeassistant/components/dte_energy_bridge/manifest.json +++ b/homeassistant/components/dte_energy_bridge/manifest.json @@ -3,5 +3,6 @@ "name": "DTE Energy Bridge", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/dte_energy_bridge", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/dublin_bus_transport/manifest.json b/homeassistant/components/dublin_bus_transport/manifest.json index 1866da8ed8d24..3df22b0da00ae 100644 --- a/homeassistant/components/dublin_bus_transport/manifest.json +++ b/homeassistant/components/dublin_bus_transport/manifest.json @@ -3,5 +3,6 @@ "name": "Dublin Bus", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/dublin_bus_transport", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/duckdns/manifest.json b/homeassistant/components/duckdns/manifest.json index b14da0534507b..b48ed0b2394dd 100644 --- a/homeassistant/components/duckdns/manifest.json +++ b/homeassistant/components/duckdns/manifest.json @@ -3,5 +3,6 @@ "name": "Duck DNS", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/duckdns", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/dweet/manifest.json b/homeassistant/components/dweet/manifest.json index 4badf76f2e9dd..b4efd0744fba5 100644 --- a/homeassistant/components/dweet/manifest.json +++ b/homeassistant/components/dweet/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/dweet", "iot_class": "cloud_polling", "loggers": ["dweepy"], + "quality_scale": "legacy", "requirements": ["dweepy==0.3.0"] } diff --git a/homeassistant/components/ebox/manifest.json b/homeassistant/components/ebox/manifest.json index 952f9dc133d91..d87c85b6612e4 100644 --- a/homeassistant/components/ebox/manifest.json +++ b/homeassistant/components/ebox/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/ebox", "iot_class": "cloud_polling", "loggers": ["pyebox"], + "quality_scale": "legacy", "requirements": ["pyebox==1.1.4"] } diff --git a/homeassistant/components/ebusd/manifest.json b/homeassistant/components/ebusd/manifest.json index 3ce18d6e8d3ae..b82e8f1b9101e 100644 --- a/homeassistant/components/ebusd/manifest.json +++ b/homeassistant/components/ebusd/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/ebusd", "iot_class": "local_polling", "loggers": ["ebusdpy"], + "quality_scale": "legacy", "requirements": ["ebusdpy==0.0.17"] } diff --git a/homeassistant/components/ecoal_boiler/manifest.json b/homeassistant/components/ecoal_boiler/manifest.json index 75dc95ae121f5..4d8202f8fdecf 100644 --- a/homeassistant/components/ecoal_boiler/manifest.json +++ b/homeassistant/components/ecoal_boiler/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/ecoal_boiler", "iot_class": "local_polling", "loggers": ["ecoaliface"], + "quality_scale": "legacy", "requirements": ["ecoaliface==0.4.0"] } diff --git a/homeassistant/components/eddystone_temperature/manifest.json b/homeassistant/components/eddystone_temperature/manifest.json index b15a88d099f1e..18e67f5566700 100644 --- a/homeassistant/components/eddystone_temperature/manifest.json +++ b/homeassistant/components/eddystone_temperature/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/eddystone_temperature", "iot_class": "local_polling", "loggers": ["beacontools"], + "quality_scale": "legacy", "requirements": ["beacontools[scan]==2.1.0"] } diff --git a/homeassistant/components/edimax/manifest.json b/homeassistant/components/edimax/manifest.json index f104ec40e64b9..a226ef3bbe8ca 100644 --- a/homeassistant/components/edimax/manifest.json +++ b/homeassistant/components/edimax/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/edimax", "iot_class": "local_polling", "loggers": ["pyedimax"], + "quality_scale": "legacy", "requirements": ["pyedimax==0.2.1"] } diff --git a/homeassistant/components/egardia/manifest.json b/homeassistant/components/egardia/manifest.json index 99f39c99cbc23..08eb82df0e708 100644 --- a/homeassistant/components/egardia/manifest.json +++ b/homeassistant/components/egardia/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/egardia", "iot_class": "local_polling", "loggers": ["pythonegardia"], + "quality_scale": "legacy", "requirements": ["pythonegardia==1.0.52"] } diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index a4f7482c920c9..59de546824f87 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/eight_sleep", "integration_type": "system", "iot_class": "cloud_polling", + "quality_scale": "legacy", "requirements": [] } diff --git a/homeassistant/components/eliqonline/manifest.json b/homeassistant/components/eliqonline/manifest.json index 78fd62fbd337e..70f2cd8a6752a 100644 --- a/homeassistant/components/eliqonline/manifest.json +++ b/homeassistant/components/eliqonline/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/eliqonline", "iot_class": "cloud_polling", + "quality_scale": "legacy", "requirements": ["eliqonline==1.2.2"] } diff --git a/homeassistant/components/elv/manifest.json b/homeassistant/components/elv/manifest.json index 9b71595e58f8f..5757aeb5e524a 100644 --- a/homeassistant/components/elv/manifest.json +++ b/homeassistant/components/elv/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/elv", "iot_class": "local_polling", "loggers": ["pypca"], + "quality_scale": "legacy", "requirements": ["pypca==0.0.7"] } diff --git a/homeassistant/components/emby/manifest.json b/homeassistant/components/emby/manifest.json index 3f57f62eb0b94..856cdaf189f10 100644 --- a/homeassistant/components/emby/manifest.json +++ b/homeassistant/components/emby/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/emby", "iot_class": "local_push", "loggers": ["pyemby"], + "quality_scale": "legacy", "requirements": ["pyEmby==1.10"] } diff --git a/homeassistant/components/emoncms_history/manifest.json b/homeassistant/components/emoncms_history/manifest.json index faa91e640171d..e73f76f752862 100644 --- a/homeassistant/components/emoncms_history/manifest.json +++ b/homeassistant/components/emoncms_history/manifest.json @@ -3,5 +3,6 @@ "name": "Emoncms History", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/emoncms_history", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/entur_public_transport/manifest.json b/homeassistant/components/entur_public_transport/manifest.json index f75099c2c2790..5e25eb4b4a71f 100644 --- a/homeassistant/components/entur_public_transport/manifest.json +++ b/homeassistant/components/entur_public_transport/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/entur_public_transport", "iot_class": "cloud_polling", "loggers": ["enturclient"], + "quality_scale": "legacy", "requirements": ["enturclient==0.2.4"] } diff --git a/homeassistant/components/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json index 0cf9f165aa2d4..42587aa7c2f15 100644 --- a/homeassistant/components/envisalink/manifest.json +++ b/homeassistant/components/envisalink/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/envisalink", "iot_class": "local_push", "loggers": ["pyenvisalink"], + "quality_scale": "legacy", "requirements": ["pyenvisalink==4.7"] } diff --git a/homeassistant/components/ephember/manifest.json b/homeassistant/components/ephember/manifest.json index dd7938ccbd2ce..547ab2918f5f5 100644 --- a/homeassistant/components/ephember/manifest.json +++ b/homeassistant/components/ephember/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/ephember", "iot_class": "local_polling", "loggers": ["pyephember"], + "quality_scale": "legacy", "requirements": ["pyephember==0.3.1"] } diff --git a/homeassistant/components/etherscan/manifest.json b/homeassistant/components/etherscan/manifest.json index 1b296e4e4be16..e5099ffaf9c04 100644 --- a/homeassistant/components/etherscan/manifest.json +++ b/homeassistant/components/etherscan/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/etherscan", "iot_class": "cloud_polling", "loggers": ["pyetherscan"], + "quality_scale": "legacy", "requirements": ["python-etherscan-api==0.0.3"] } diff --git a/homeassistant/components/eufy/manifest.json b/homeassistant/components/eufy/manifest.json index ccf15144f9e55..6ad1b7de81bea 100644 --- a/homeassistant/components/eufy/manifest.json +++ b/homeassistant/components/eufy/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/eufy", "iot_class": "local_polling", "loggers": ["lakeside"], + "quality_scale": "legacy", "requirements": ["lakeside==0.13"] } diff --git a/homeassistant/components/everlights/manifest.json b/homeassistant/components/everlights/manifest.json index 6f856b26087d2..a2deeab26662f 100644 --- a/homeassistant/components/everlights/manifest.json +++ b/homeassistant/components/everlights/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/everlights", "iot_class": "local_polling", "loggers": ["pyeverlights"], + "quality_scale": "legacy", "requirements": ["pyeverlights==0.1.0"] } diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index e81e71c5b07ea..da3d197f6aa32 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], + "quality_scale": "legacy", "requirements": ["evohome-async==0.4.20"] } diff --git a/homeassistant/components/facebook/manifest.json b/homeassistant/components/facebook/manifest.json index 5074489852e92..5a7eb216ccc0f 100644 --- a/homeassistant/components/facebook/manifest.json +++ b/homeassistant/components/facebook/manifest.json @@ -3,5 +3,6 @@ "name": "Facebook Messenger", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/facebook", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/fail2ban/manifest.json b/homeassistant/components/fail2ban/manifest.json index e348db1c6959b..1570afda6eb10 100644 --- a/homeassistant/components/fail2ban/manifest.json +++ b/homeassistant/components/fail2ban/manifest.json @@ -3,5 +3,6 @@ "name": "Fail2Ban", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/fail2ban", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/familyhub/manifest.json b/homeassistant/components/familyhub/manifest.json index f57030efb275b..cf4bf0ba68f62 100644 --- a/homeassistant/components/familyhub/manifest.json +++ b/homeassistant/components/familyhub/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/familyhub", "iot_class": "local_polling", "loggers": ["pyfamilyhublocal"], + "quality_scale": "legacy", "requirements": ["python-family-hub-local==0.0.2"] } diff --git a/homeassistant/components/ffmpeg_motion/manifest.json b/homeassistant/components/ffmpeg_motion/manifest.json index 0115ed712e3d1..f51a6206e2b22 100644 --- a/homeassistant/components/ffmpeg_motion/manifest.json +++ b/homeassistant/components/ffmpeg_motion/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/ffmpeg_motion", - "iot_class": "calculated" + "iot_class": "calculated", + "quality_scale": "legacy" } diff --git a/homeassistant/components/ffmpeg_noise/manifest.json b/homeassistant/components/ffmpeg_noise/manifest.json index 6352fed88c406..f1c0cc9f673d8 100644 --- a/homeassistant/components/ffmpeg_noise/manifest.json +++ b/homeassistant/components/ffmpeg_noise/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/ffmpeg_noise", - "iot_class": "calculated" + "iot_class": "calculated", + "quality_scale": "legacy" } diff --git a/homeassistant/components/fido/manifest.json b/homeassistant/components/fido/manifest.json index dc4403046462d..23949a56ee220 100644 --- a/homeassistant/components/fido/manifest.json +++ b/homeassistant/components/fido/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/fido", "iot_class": "cloud_polling", "loggers": ["pyfido"], + "quality_scale": "legacy", "requirements": ["pyfido==2.1.2"] } diff --git a/homeassistant/components/fints/manifest.json b/homeassistant/components/fints/manifest.json index 063e612d35d38..0a9c5389cd9d7 100644 --- a/homeassistant/components/fints/manifest.json +++ b/homeassistant/components/fints/manifest.json @@ -6,5 +6,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["fints", "mt_940", "sepaxml"], + "quality_scale": "legacy", "requirements": ["fints==3.1.0"] } diff --git a/homeassistant/components/firmata/manifest.json b/homeassistant/components/firmata/manifest.json index a35b6f179ce2d..363b5bd60c67f 100644 --- a/homeassistant/components/firmata/manifest.json +++ b/homeassistant/components/firmata/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/firmata", "iot_class": "local_push", "loggers": ["pymata_express"], + "quality_scale": "legacy", "requirements": ["pymata-express==1.19"] } diff --git a/homeassistant/components/fixer/manifest.json b/homeassistant/components/fixer/manifest.json index 052a594b74535..3c457919ac35e 100644 --- a/homeassistant/components/fixer/manifest.json +++ b/homeassistant/components/fixer/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/fixer", "iot_class": "cloud_polling", "loggers": ["fixerio"], + "quality_scale": "legacy", "requirements": ["fixerio==1.0.0a0"] } diff --git a/homeassistant/components/fleetgo/manifest.json b/homeassistant/components/fleetgo/manifest.json index 9e916bd7fcd72..ad00ca3b7b110 100644 --- a/homeassistant/components/fleetgo/manifest.json +++ b/homeassistant/components/fleetgo/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/fleetgo", "iot_class": "cloud_polling", "loggers": ["geopy", "ritassist"], + "quality_scale": "legacy", "requirements": ["ritassist==0.9.2"] } diff --git a/homeassistant/components/flexit/manifest.json b/homeassistant/components/flexit/manifest.json index 98e5a3734a8d3..b3b66fb871e61 100644 --- a/homeassistant/components/flexit/manifest.json +++ b/homeassistant/components/flexit/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["modbus"], "documentation": "https://www.home-assistant.io/integrations/flexit", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/flic/manifest.json b/homeassistant/components/flic/manifest.json index 0442e4a7b7bdd..67a9a2e901cd8 100644 --- a/homeassistant/components/flic/manifest.json +++ b/homeassistant/components/flic/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/flic", "iot_class": "local_push", "loggers": ["pyflic"], + "quality_scale": "legacy", "requirements": ["pyflic==2.0.4"] } diff --git a/homeassistant/components/flock/manifest.json b/homeassistant/components/flock/manifest.json index 29c3e1c881ff0..c4cd5cdadb38f 100644 --- a/homeassistant/components/flock/manifest.json +++ b/homeassistant/components/flock/manifest.json @@ -3,5 +3,6 @@ "name": "Flock", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/flock", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/folder/manifest.json b/homeassistant/components/folder/manifest.json index 2436d5dbe9a1b..984b287c2c061 100644 --- a/homeassistant/components/folder/manifest.json +++ b/homeassistant/components/folder/manifest.json @@ -3,5 +3,6 @@ "name": "Folder", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/folder", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/foobot/manifest.json b/homeassistant/components/foobot/manifest.json index a517f1fea6fa0..147a0037a1895 100644 --- a/homeassistant/components/foobot/manifest.json +++ b/homeassistant/components/foobot/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/foobot", "iot_class": "cloud_polling", "loggers": ["foobot_async"], + "quality_scale": "legacy", "requirements": ["foobot_async==1.0.0"] } diff --git a/homeassistant/components/fortios/manifest.json b/homeassistant/components/fortios/manifest.json index 93e5507117822..22c44acfd82dd 100644 --- a/homeassistant/components/fortios/manifest.json +++ b/homeassistant/components/fortios/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/fortios", "iot_class": "local_polling", "loggers": ["fortiosapi", "paramiko"], + "quality_scale": "legacy", "requirements": ["fortiosapi==1.0.5"] } diff --git a/homeassistant/components/foursquare/manifest.json b/homeassistant/components/foursquare/manifest.json index ce1c87814d7a2..0503ea4abb5b2 100644 --- a/homeassistant/components/foursquare/manifest.json +++ b/homeassistant/components/foursquare/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/foursquare", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/free_mobile/manifest.json b/homeassistant/components/free_mobile/manifest.json index 61a1f94c19dc4..9ce9bc72c760f 100644 --- a/homeassistant/components/free_mobile/manifest.json +++ b/homeassistant/components/free_mobile/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/free_mobile", "iot_class": "cloud_push", "loggers": ["freesms"], + "quality_scale": "legacy", "requirements": ["freesms==0.2.0"] } diff --git a/homeassistant/components/freedns/manifest.json b/homeassistant/components/freedns/manifest.json index ac320a51d9336..7c6bceb11a636 100644 --- a/homeassistant/components/freedns/manifest.json +++ b/homeassistant/components/freedns/manifest.json @@ -3,5 +3,6 @@ "name": "FreeDNS", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/freedns", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/futurenow/manifest.json b/homeassistant/components/futurenow/manifest.json index dbe1b2d06fbde..32a8761b1db19 100644 --- a/homeassistant/components/futurenow/manifest.json +++ b/homeassistant/components/futurenow/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/futurenow", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["pyfnip==0.2"] } diff --git a/homeassistant/components/garadget/manifest.json b/homeassistant/components/garadget/manifest.json index c7a30a465d2bc..bd1920a7c4cb7 100644 --- a/homeassistant/components/garadget/manifest.json +++ b/homeassistant/components/garadget/manifest.json @@ -3,5 +3,6 @@ "name": "Garadget", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/garadget", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/gc100/manifest.json b/homeassistant/components/gc100/manifest.json index b4af14a323b42..687e09f5c890f 100644 --- a/homeassistant/components/gc100/manifest.json +++ b/homeassistant/components/gc100/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/gc100", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["python-gc100==1.0.3a0"] } diff --git a/homeassistant/components/geo_rss_events/manifest.json b/homeassistant/components/geo_rss_events/manifest.json index 17640e3727800..7c089bfa4e998 100644 --- a/homeassistant/components/geo_rss_events/manifest.json +++ b/homeassistant/components/geo_rss_events/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/geo_rss_events", "iot_class": "cloud_polling", "loggers": ["georss_client", "georss_generic_client"], + "quality_scale": "legacy", "requirements": ["georss-generic-client==0.8"] } diff --git a/homeassistant/components/gitlab_ci/manifest.json b/homeassistant/components/gitlab_ci/manifest.json index 36fb356dae4ff..58fd827ff31e7 100644 --- a/homeassistant/components/gitlab_ci/manifest.json +++ b/homeassistant/components/gitlab_ci/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/gitlab_ci", "iot_class": "cloud_polling", "loggers": ["gitlab"], + "quality_scale": "legacy", "requirements": ["python-gitlab==1.6.0"] } diff --git a/homeassistant/components/gitter/manifest.json b/homeassistant/components/gitter/manifest.json index 009746a06c64d..c578f7c2242f5 100644 --- a/homeassistant/components/gitter/manifest.json +++ b/homeassistant/components/gitter/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/gitter", "iot_class": "cloud_polling", "loggers": ["gitterpy"], + "quality_scale": "legacy", "requirements": ["gitterpy==0.1.7"] } diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index 201b7168847ae..bedee99f93005 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,6 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["go2rtc-client==0.1.1"], "single_config_entry": true } diff --git a/homeassistant/components/google_maps/manifest.json b/homeassistant/components/google_maps/manifest.json index d7364e834a3e2..8311f75b73276 100644 --- a/homeassistant/components/google_maps/manifest.json +++ b/homeassistant/components/google_maps/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/google_maps", "iot_class": "cloud_polling", "loggers": ["locationsharinglib"], + "quality_scale": "legacy", "requirements": ["locationsharinglib==5.0.1"] } diff --git a/homeassistant/components/google_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json index aa13f1808c42f..9ea747898b21c 100644 --- a/homeassistant/components/google_pubsub/manifest.json +++ b/homeassistant/components/google_pubsub/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/google_pubsub", "iot_class": "cloud_push", + "quality_scale": "legacy", "requirements": ["google-cloud-pubsub==2.23.0"] } diff --git a/homeassistant/components/google_wifi/manifest.json b/homeassistant/components/google_wifi/manifest.json index 200684b2e1cad..a71558a7d6f3f 100644 --- a/homeassistant/components/google_wifi/manifest.json +++ b/homeassistant/components/google_wifi/manifest.json @@ -3,5 +3,6 @@ "name": "Google Wifi", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/google_wifi", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/graphite/manifest.json b/homeassistant/components/graphite/manifest.json index da249a228291e..cd50a5933f10e 100644 --- a/homeassistant/components/graphite/manifest.json +++ b/homeassistant/components/graphite/manifest.json @@ -3,5 +3,6 @@ "name": "Graphite", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/graphite", - "iot_class": "local_push" + "iot_class": "local_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/greeneye_monitor/manifest.json b/homeassistant/components/greeneye_monitor/manifest.json index fcf4d004d2656..15c4c2123e333 100644 --- a/homeassistant/components/greeneye_monitor/manifest.json +++ b/homeassistant/components/greeneye_monitor/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/greeneye_monitor", "iot_class": "local_push", "loggers": ["greeneye"], + "quality_scale": "legacy", "requirements": ["greeneye_monitor==3.0.3"] } diff --git a/homeassistant/components/greenwave/manifest.json b/homeassistant/components/greenwave/manifest.json index 5cb3255192feb..422d3bc512e8f 100644 --- a/homeassistant/components/greenwave/manifest.json +++ b/homeassistant/components/greenwave/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/greenwave", "iot_class": "local_polling", "loggers": ["greenwavereality"], + "quality_scale": "legacy", "requirements": ["greenwavereality==0.5.1"] } diff --git a/homeassistant/components/gstreamer/manifest.json b/homeassistant/components/gstreamer/manifest.json index 95df94ef834f7..3ea9010a9d72e 100644 --- a/homeassistant/components/gstreamer/manifest.json +++ b/homeassistant/components/gstreamer/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/gstreamer", "iot_class": "local_push", "loggers": ["gsp"], + "quality_scale": "legacy", "requirements": ["gstreamer-player==1.1.2"] } diff --git a/homeassistant/components/gtfs/manifest.json b/homeassistant/components/gtfs/manifest.json index 73a5998ea92be..3bf41a1c7639d 100644 --- a/homeassistant/components/gtfs/manifest.json +++ b/homeassistant/components/gtfs/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/gtfs", "iot_class": "local_polling", "loggers": ["pygtfs"], + "quality_scale": "legacy", "requirements": ["pygtfs==0.1.9"] } diff --git a/homeassistant/components/harman_kardon_avr/manifest.json b/homeassistant/components/harman_kardon_avr/manifest.json index c28504cf2d839..e56aeebafe450 100644 --- a/homeassistant/components/harman_kardon_avr/manifest.json +++ b/homeassistant/components/harman_kardon_avr/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/harman_kardon_avr", "iot_class": "local_polling", "loggers": ["hkavr"], + "quality_scale": "legacy", "requirements": ["hkavr==0.0.5"] } diff --git a/homeassistant/components/haveibeenpwned/manifest.json b/homeassistant/components/haveibeenpwned/manifest.json index 2451871f0c805..eb9ad4c356f47 100644 --- a/homeassistant/components/haveibeenpwned/manifest.json +++ b/homeassistant/components/haveibeenpwned/manifest.json @@ -3,5 +3,6 @@ "name": "HaveIBeenPwned", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/haveibeenpwned", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/hddtemp/manifest.json b/homeassistant/components/hddtemp/manifest.json index 8dd2676596cdc..4fe232338707e 100644 --- a/homeassistant/components/hddtemp/manifest.json +++ b/homeassistant/components/hddtemp/manifest.json @@ -3,5 +3,6 @@ "name": "hddtemp", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/hddtemp", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/hdmi_cec/manifest.json b/homeassistant/components/hdmi_cec/manifest.json index fbd9e2304d98d..2e37e908e1670 100644 --- a/homeassistant/components/hdmi_cec/manifest.json +++ b/homeassistant/components/hdmi_cec/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/hdmi_cec", "iot_class": "local_push", "loggers": ["pycec"], + "quality_scale": "legacy", "requirements": ["pyCEC==0.5.2"] } diff --git a/homeassistant/components/heatmiser/manifest.json b/homeassistant/components/heatmiser/manifest.json index f3f33f79b04ce..c7ffeb237ed64 100644 --- a/homeassistant/components/heatmiser/manifest.json +++ b/homeassistant/components/heatmiser/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/heatmiser", "iot_class": "local_polling", "loggers": ["heatmiserV3"], + "quality_scale": "legacy", "requirements": ["heatmiserV3==2.0.3"] } diff --git a/homeassistant/components/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json index e37e149ccdab7..a083273210584 100644 --- a/homeassistant/components/hikvision/manifest.json +++ b/homeassistant/components/hikvision/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/hikvision", "iot_class": "local_push", "loggers": ["pyhik"], + "quality_scale": "legacy", "requirements": ["pyHik==0.3.2"] } diff --git a/homeassistant/components/hikvisioncam/manifest.json b/homeassistant/components/hikvisioncam/manifest.json index 28f677512b78f..badb38a52d59f 100644 --- a/homeassistant/components/hikvisioncam/manifest.json +++ b/homeassistant/components/hikvisioncam/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/hikvisioncam", "iot_class": "local_polling", "loggers": ["hikvision"], + "quality_scale": "legacy", "requirements": ["hikvision==0.4"] } diff --git a/homeassistant/components/hitron_coda/manifest.json b/homeassistant/components/hitron_coda/manifest.json index 2f18707c95ece..15f71b62cf36c 100644 --- a/homeassistant/components/hitron_coda/manifest.json +++ b/homeassistant/components/hitron_coda/manifest.json @@ -3,5 +3,6 @@ "name": "Rogers Hitron CODA", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/hitron_coda", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json index 9c67a5da0b22f..749bd7b44e88b 100644 --- a/homeassistant/components/homematic/manifest.json +++ b/homeassistant/components/homematic/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/homematic", "iot_class": "local_push", "loggers": ["pyhomematic"], + "quality_scale": "legacy", "requirements": ["pyhomematic==0.1.77"] } diff --git a/homeassistant/components/horizon/manifest.json b/homeassistant/components/horizon/manifest.json index d1280a6fe6524..d30e2f39e34c0 100644 --- a/homeassistant/components/horizon/manifest.json +++ b/homeassistant/components/horizon/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/horizon", "iot_class": "local_polling", "loggers": ["horimote"], + "quality_scale": "legacy", "requirements": ["horimote==0.4.1"] } diff --git a/homeassistant/components/hp_ilo/manifest.json b/homeassistant/components/hp_ilo/manifest.json index 378a9ac186569..9f2dfb21783d6 100644 --- a/homeassistant/components/hp_ilo/manifest.json +++ b/homeassistant/components/hp_ilo/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/hp_ilo", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["python-hpilo==4.4.3"] } diff --git a/homeassistant/components/iammeter/manifest.json b/homeassistant/components/iammeter/manifest.json index f1ebecab00d61..22831767e622c 100644 --- a/homeassistant/components/iammeter/manifest.json +++ b/homeassistant/components/iammeter/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/iammeter", "iot_class": "local_polling", "loggers": ["iammeter"], + "quality_scale": "legacy", "requirements": ["iammeter==0.2.1"] } diff --git a/homeassistant/components/idteck_prox/manifest.json b/homeassistant/components/idteck_prox/manifest.json index e1d9b8a7ba865..920559085912d 100644 --- a/homeassistant/components/idteck_prox/manifest.json +++ b/homeassistant/components/idteck_prox/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/idteck_prox", "iot_class": "local_push", "loggers": ["rfk101py"], + "quality_scale": "legacy", "requirements": ["rfk101py==0.0.1"] } diff --git a/homeassistant/components/iglo/manifest.json b/homeassistant/components/iglo/manifest.json index f270d06bcaede..7ce4804a5165a 100644 --- a/homeassistant/components/iglo/manifest.json +++ b/homeassistant/components/iglo/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/iglo", "iot_class": "local_polling", "loggers": ["iglo"], + "quality_scale": "legacy", "requirements": ["iglo==1.2.7"] } diff --git a/homeassistant/components/ign_sismologia/manifest.json b/homeassistant/components/ign_sismologia/manifest.json index c76013f682120..d371f0d361477 100644 --- a/homeassistant/components/ign_sismologia/manifest.json +++ b/homeassistant/components/ign_sismologia/manifest.json @@ -6,5 +6,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["georss_ign_sismologia_client"], + "quality_scale": "legacy", "requirements": ["georss-ign-sismologia-client==0.8"] } diff --git a/homeassistant/components/ihc/manifest.json b/homeassistant/components/ihc/manifest.json index 2400206c3a06b..68cc1b2c7541c 100644 --- a/homeassistant/components/ihc/manifest.json +++ b/homeassistant/components/ihc/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/ihc", "iot_class": "local_push", "loggers": ["ihcsdk"], + "quality_scale": "legacy", "requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.5"] } diff --git a/homeassistant/components/influxdb/manifest.json b/homeassistant/components/influxdb/manifest.json index ad3f282eff727..55af2b37fb7f8 100644 --- a/homeassistant/components/influxdb/manifest.json +++ b/homeassistant/components/influxdb/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/influxdb", "iot_class": "local_push", "loggers": ["influxdb", "influxdb_client"], + "quality_scale": "legacy", "requirements": ["influxdb==5.3.1", "influxdb-client==1.24.0"] } diff --git a/homeassistant/components/intesishome/manifest.json b/homeassistant/components/intesishome/manifest.json index 6b7a579d99fb7..ab306fb4773f1 100644 --- a/homeassistant/components/intesishome/manifest.json +++ b/homeassistant/components/intesishome/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/intesishome", "iot_class": "cloud_push", "loggers": ["pyintesishome"], + "quality_scale": "legacy", "requirements": ["pyintesishome==1.8.0"] } diff --git a/homeassistant/components/iperf3/manifest.json b/homeassistant/components/iperf3/manifest.json index a1bb26ddc1a7c..16e33e47331e0 100644 --- a/homeassistant/components/iperf3/manifest.json +++ b/homeassistant/components/iperf3/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/iperf3", "iot_class": "local_polling", "loggers": ["iperf3"], + "quality_scale": "legacy", "requirements": ["iperf3==0.1.11"] } diff --git a/homeassistant/components/irish_rail_transport/manifest.json b/homeassistant/components/irish_rail_transport/manifest.json index bb9b0d59ef005..2a118f17e2a16 100644 --- a/homeassistant/components/irish_rail_transport/manifest.json +++ b/homeassistant/components/irish_rail_transport/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/irish_rail_transport", "iot_class": "cloud_polling", "loggers": ["pyirishrail"], + "quality_scale": "legacy", "requirements": ["pyirishrail==0.0.2"] } diff --git a/homeassistant/components/itach/manifest.json b/homeassistant/components/itach/manifest.json index 2928620b95216..68b34b4321ee0 100644 --- a/homeassistant/components/itach/manifest.json +++ b/homeassistant/components/itach/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/itach", "iot_class": "assumed_state", + "quality_scale": "legacy", "requirements": ["pyitachip2ir==0.0.7"] } diff --git a/homeassistant/components/itunes/manifest.json b/homeassistant/components/itunes/manifest.json index f1135dbf8478a..a12271d04d7c0 100644 --- a/homeassistant/components/itunes/manifest.json +++ b/homeassistant/components/itunes/manifest.json @@ -3,5 +3,6 @@ "name": "Apple iTunes", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/itunes", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/joaoapps_join/manifest.json b/homeassistant/components/joaoapps_join/manifest.json index 36d54ec6d5538..55a908bf090ac 100644 --- a/homeassistant/components/joaoapps_join/manifest.json +++ b/homeassistant/components/joaoapps_join/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/joaoapps_join", "iot_class": "cloud_push", "loggers": ["pyjoin"], + "quality_scale": "legacy", "requirements": ["python-join-api==0.0.9"] } diff --git a/homeassistant/components/kaiterra/manifest.json b/homeassistant/components/kaiterra/manifest.json index 12ac1559fd75e..88651565cd003 100644 --- a/homeassistant/components/kaiterra/manifest.json +++ b/homeassistant/components/kaiterra/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/kaiterra", "iot_class": "cloud_polling", "loggers": ["kaiterra_async_client"], + "quality_scale": "legacy", "requirements": ["kaiterra-async-client==1.0.0"] } diff --git a/homeassistant/components/kankun/manifest.json b/homeassistant/components/kankun/manifest.json index c15a87eacaa52..473209508ac26 100644 --- a/homeassistant/components/kankun/manifest.json +++ b/homeassistant/components/kankun/manifest.json @@ -3,5 +3,6 @@ "name": "Kankun", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/kankun", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/keba/manifest.json b/homeassistant/components/keba/manifest.json index 42f2762ef3d5c..d86ce05318723 100644 --- a/homeassistant/components/keba/manifest.json +++ b/homeassistant/components/keba/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/keba", "iot_class": "local_polling", "loggers": ["keba_kecontact"], + "quality_scale": "legacy", "requirements": ["keba-kecontact==1.1.0"] } diff --git a/homeassistant/components/kef/manifest.json b/homeassistant/components/kef/manifest.json index 29e398994f435..1bbce2ff35dae 100644 --- a/homeassistant/components/kef/manifest.json +++ b/homeassistant/components/kef/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/kef", "iot_class": "local_polling", "loggers": ["aiokef", "tenacity"], + "quality_scale": "legacy", "requirements": ["aiokef==0.2.16", "getmac==0.9.4"] } diff --git a/homeassistant/components/keyboard/manifest.json b/homeassistant/components/keyboard/manifest.json index ea6d0aa20c267..e4a6606fb80a7 100644 --- a/homeassistant/components/keyboard/manifest.json +++ b/homeassistant/components/keyboard/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/keyboard", "iot_class": "local_push", "loggers": ["pykeyboard"], + "quality_scale": "legacy", "requirements": ["pyuserinput==0.1.11"] } diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index bb84b32defc81..b405f36bb2380 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -6,5 +6,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aionotify", "evdev"], + "quality_scale": "legacy", "requirements": ["evdev==1.6.1", "asyncinotify==4.0.2"] } diff --git a/homeassistant/components/kira/manifest.json b/homeassistant/components/kira/manifest.json index c8a476b07c90c..60901d13f4e3d 100644 --- a/homeassistant/components/kira/manifest.json +++ b/homeassistant/components/kira/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/kira", "iot_class": "local_push", "loggers": ["pykira"], + "quality_scale": "legacy", "requirements": ["pykira==0.1.1"] } diff --git a/homeassistant/components/kiwi/manifest.json b/homeassistant/components/kiwi/manifest.json index 60b0d1fd28b85..74a27776128c2 100644 --- a/homeassistant/components/kiwi/manifest.json +++ b/homeassistant/components/kiwi/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/kiwi", "iot_class": "cloud_polling", "loggers": ["kiwiki"], + "quality_scale": "legacy", "requirements": ["kiwiki-client==0.1.1"] } diff --git a/homeassistant/components/kwb/manifest.json b/homeassistant/components/kwb/manifest.json index 36d3a0af2d701..6a11e08555f19 100644 --- a/homeassistant/components/kwb/manifest.json +++ b/homeassistant/components/kwb/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/kwb", "iot_class": "local_polling", "loggers": ["pykwb"], + "quality_scale": "legacy", "requirements": ["pykwb==0.0.8"] } diff --git a/homeassistant/components/lacrosse/manifest.json b/homeassistant/components/lacrosse/manifest.json index 0c7cf8b6dc6bc..b4023b533ca27 100644 --- a/homeassistant/components/lacrosse/manifest.json +++ b/homeassistant/components/lacrosse/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/lacrosse", "iot_class": "local_polling", "loggers": ["pylacrosse"], + "quality_scale": "legacy", "requirements": ["pylacrosse==0.4"] } diff --git a/homeassistant/components/lannouncer/manifest.json b/homeassistant/components/lannouncer/manifest.json index c04d9e8765541..9d0942bd14f54 100644 --- a/homeassistant/components/lannouncer/manifest.json +++ b/homeassistant/components/lannouncer/manifest.json @@ -3,5 +3,6 @@ "name": "LANnouncer", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/lannouncer", - "iot_class": "local_push" + "iot_class": "local_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/lifx_cloud/manifest.json b/homeassistant/components/lifx_cloud/manifest.json index 7799de85b8da2..61e5d66c821ed 100644 --- a/homeassistant/components/lifx_cloud/manifest.json +++ b/homeassistant/components/lifx_cloud/manifest.json @@ -3,5 +3,6 @@ "name": "LIFX Cloud", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/lifx_cloud", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/lightwave/manifest.json b/homeassistant/components/lightwave/manifest.json index d242195a71c0c..75b39b18c26e7 100644 --- a/homeassistant/components/lightwave/manifest.json +++ b/homeassistant/components/lightwave/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/lightwave", "iot_class": "assumed_state", "loggers": ["lightwave"], + "quality_scale": "legacy", "requirements": ["lightwave==0.24"] } diff --git a/homeassistant/components/limitlessled/manifest.json b/homeassistant/components/limitlessled/manifest.json index 3495ac2c981d7..c2a921c6e242a 100644 --- a/homeassistant/components/limitlessled/manifest.json +++ b/homeassistant/components/limitlessled/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/limitlessled", "iot_class": "assumed_state", "loggers": ["limitlessled"], + "quality_scale": "legacy", "requirements": ["limitlessled==1.1.3"] } diff --git a/homeassistant/components/linksys_smart/manifest.json b/homeassistant/components/linksys_smart/manifest.json index 6200da5866d37..4f099f812775e 100644 --- a/homeassistant/components/linksys_smart/manifest.json +++ b/homeassistant/components/linksys_smart/manifest.json @@ -3,5 +3,6 @@ "name": "Linksys Smart Wi-Fi", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/linksys_smart", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/linode/manifest.json b/homeassistant/components/linode/manifest.json index bedd6c2d17223..975747de86de3 100644 --- a/homeassistant/components/linode/manifest.json +++ b/homeassistant/components/linode/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/linode", "iot_class": "cloud_polling", "loggers": ["linode"], + "quality_scale": "legacy", "requirements": ["linode-api==4.1.9b1"] } diff --git a/homeassistant/components/linux_battery/manifest.json b/homeassistant/components/linux_battery/manifest.json index 12b49c18aee0e..39bd331e3a4d4 100644 --- a/homeassistant/components/linux_battery/manifest.json +++ b/homeassistant/components/linux_battery/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/linux_battery", "iot_class": "local_polling", "loggers": ["batinfo"], + "quality_scale": "legacy", "requirements": ["batinfo==0.4.2"] } diff --git a/homeassistant/components/lirc/manifest.json b/homeassistant/components/lirc/manifest.json index 3cc5d453721a8..64dbee06390fb 100644 --- a/homeassistant/components/lirc/manifest.json +++ b/homeassistant/components/lirc/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/lirc", "iot_class": "local_push", "loggers": ["lirc"], + "quality_scale": "legacy", "requirements": ["python-lirc==1.2.3"] } diff --git a/homeassistant/components/llamalab_automate/manifest.json b/homeassistant/components/llamalab_automate/manifest.json index 861b919f24b2c..4343d617e93ea 100644 --- a/homeassistant/components/llamalab_automate/manifest.json +++ b/homeassistant/components/llamalab_automate/manifest.json @@ -3,5 +3,6 @@ "name": "LlamaLab Automate", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/llamalab_automate", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/logentries/manifest.json b/homeassistant/components/logentries/manifest.json index ecf2d8a227cbd..e63e83aff0077 100644 --- a/homeassistant/components/logentries/manifest.json +++ b/homeassistant/components/logentries/manifest.json @@ -3,5 +3,6 @@ "name": "Logentries", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/logentries", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/london_air/manifest.json b/homeassistant/components/london_air/manifest.json index 60eed8d83bde7..653a951ae566d 100644 --- a/homeassistant/components/london_air/manifest.json +++ b/homeassistant/components/london_air/manifest.json @@ -3,5 +3,6 @@ "name": "London Air", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/london_air", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json index eafc63c6ae738..94b993097c022 100644 --- a/homeassistant/components/london_underground/manifest.json +++ b/homeassistant/components/london_underground/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/london_underground", "iot_class": "cloud_polling", "loggers": ["london_tube_status"], + "quality_scale": "legacy", "requirements": ["london-tube-status==0.5"] } diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json index 597aad30648cd..a8df2c63df4b2 100644 --- a/homeassistant/components/luci/manifest.json +++ b/homeassistant/components/luci/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/luci", "iot_class": "local_polling", "loggers": ["openwrt_luci_rpc"], + "quality_scale": "legacy", "requirements": ["openwrt-luci-rpc==1.1.17"] } diff --git a/homeassistant/components/lw12wifi/manifest.json b/homeassistant/components/lw12wifi/manifest.json index d8b2290b234e6..683498f2056f2 100644 --- a/homeassistant/components/lw12wifi/manifest.json +++ b/homeassistant/components/lw12wifi/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/lw12wifi", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["lw12==0.9.2"] } diff --git a/homeassistant/components/manual_mqtt/manifest.json b/homeassistant/components/manual_mqtt/manifest.json index d4adcaf3bc97f..bf2fccb62ae70 100644 --- a/homeassistant/components/manual_mqtt/manifest.json +++ b/homeassistant/components/manual_mqtt/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["mqtt"], "documentation": "https://www.home-assistant.io/integrations/manual_mqtt", - "iot_class": "local_push" + "iot_class": "local_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/marytts/manifest.json b/homeassistant/components/marytts/manifest.json index bbf23327547f0..814d3c64925b7 100644 --- a/homeassistant/components/marytts/manifest.json +++ b/homeassistant/components/marytts/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/marytts", "iot_class": "local_push", "loggers": ["speak2mary"], + "quality_scale": "legacy", "requirements": ["speak2mary==1.4.0"] } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 43c151c7c2368..e06eed1176f17 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], + "quality_scale": "legacy", "requirements": ["matrix-nio==0.25.2", "Pillow==11.0.0"] } diff --git a/homeassistant/components/maxcube/manifest.json b/homeassistant/components/maxcube/manifest.json index 6421686d2cf24..d57ccacc5b16d 100644 --- a/homeassistant/components/maxcube/manifest.json +++ b/homeassistant/components/maxcube/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/maxcube", "iot_class": "local_polling", "loggers": ["maxcube"], + "quality_scale": "legacy", "requirements": ["maxcube-api==0.4.3"] } diff --git a/homeassistant/components/mazda/manifest.json b/homeassistant/components/mazda/manifest.json index 75a83a9f468d0..fcd39e11a1036 100644 --- a/homeassistant/components/mazda/manifest.json +++ b/homeassistant/components/mazda/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/mazda", "integration_type": "system", "iot_class": "cloud_polling", + "quality_scale": "legacy", "requirements": [] } diff --git a/homeassistant/components/mediaroom/manifest.json b/homeassistant/components/mediaroom/manifest.json index 4cd7b11c22f9d..060a40b036a32 100644 --- a/homeassistant/components/mediaroom/manifest.json +++ b/homeassistant/components/mediaroom/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/mediaroom", "iot_class": "local_polling", "loggers": ["pymediaroom"], + "quality_scale": "legacy", "requirements": ["pymediaroom==0.6.5.4"] } diff --git a/homeassistant/components/melissa/manifest.json b/homeassistant/components/melissa/manifest.json index 60d1d7f145f81..a583c3b88fad9 100644 --- a/homeassistant/components/melissa/manifest.json +++ b/homeassistant/components/melissa/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/melissa", "iot_class": "cloud_polling", "loggers": ["melissa"], + "quality_scale": "legacy", "requirements": ["py-melissa-climate==2.1.4"] } diff --git a/homeassistant/components/meraki/manifest.json b/homeassistant/components/meraki/manifest.json index 4fb7d27d4bb03..5b8690ae52dca 100644 --- a/homeassistant/components/meraki/manifest.json +++ b/homeassistant/components/meraki/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/meraki", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/message_bird/manifest.json b/homeassistant/components/message_bird/manifest.json index d5118dc3486ad..3b3c56029c579 100644 --- a/homeassistant/components/message_bird/manifest.json +++ b/homeassistant/components/message_bird/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/message_bird", "iot_class": "cloud_push", "loggers": ["messagebird"], + "quality_scale": "legacy", "requirements": ["messagebird==1.2.0"] } diff --git a/homeassistant/components/meteoalarm/manifest.json b/homeassistant/components/meteoalarm/manifest.json index 4de91f6a43119..58b6a63ed1dc3 100644 --- a/homeassistant/components/meteoalarm/manifest.json +++ b/homeassistant/components/meteoalarm/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/meteoalarm", "iot_class": "cloud_polling", "loggers": ["meteoalertapi"], + "quality_scale": "legacy", "requirements": ["meteoalertapi==0.3.1"] } diff --git a/homeassistant/components/mfi/manifest.json b/homeassistant/components/mfi/manifest.json index b569009d40023..3024fe145c55e 100644 --- a/homeassistant/components/mfi/manifest.json +++ b/homeassistant/components/mfi/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/mfi", "iot_class": "local_polling", "loggers": ["mficlient"], + "quality_scale": "legacy", "requirements": ["mficlient==0.5.0"] } diff --git a/homeassistant/components/microsoft/manifest.json b/homeassistant/components/microsoft/manifest.json index dba2f58ba9809..3d8f0629cec44 100644 --- a/homeassistant/components/microsoft/manifest.json +++ b/homeassistant/components/microsoft/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/microsoft", "iot_class": "cloud_push", "loggers": ["pycsspeechtts"], + "quality_scale": "legacy", "requirements": ["pycsspeechtts==1.0.8"] } diff --git a/homeassistant/components/microsoft_face/manifest.json b/homeassistant/components/microsoft_face/manifest.json index 0ef18a12271f2..e13d1c76ccbcb 100644 --- a/homeassistant/components/microsoft_face/manifest.json +++ b/homeassistant/components/microsoft_face/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["camera"], "documentation": "https://www.home-assistant.io/integrations/microsoft_face", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/microsoft_face_detect/manifest.json b/homeassistant/components/microsoft_face_detect/manifest.json index 1b72ce92c9524..f3f9f0fa09572 100644 --- a/homeassistant/components/microsoft_face_detect/manifest.json +++ b/homeassistant/components/microsoft_face_detect/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["microsoft_face"], "documentation": "https://www.home-assistant.io/integrations/microsoft_face_detect", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/microsoft_face_identify/manifest.json b/homeassistant/components/microsoft_face_identify/manifest.json index 63418ac2a0b3a..b3964ee12545e 100644 --- a/homeassistant/components/microsoft_face_identify/manifest.json +++ b/homeassistant/components/microsoft_face_identify/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["microsoft_face"], "documentation": "https://www.home-assistant.io/integrations/microsoft_face_identify", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/minio/manifest.json b/homeassistant/components/minio/manifest.json index 5fee789384160..3ab6b82bb86d3 100644 --- a/homeassistant/components/minio/manifest.json +++ b/homeassistant/components/minio/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/minio", "iot_class": "cloud_push", "loggers": ["minio"], + "quality_scale": "legacy", "requirements": ["minio==7.1.12"] } diff --git a/homeassistant/components/mochad/manifest.json b/homeassistant/components/mochad/manifest.json index e4680cc6ff579..96795789c8c2d 100644 --- a/homeassistant/components/mochad/manifest.json +++ b/homeassistant/components/mochad/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/mochad", "iot_class": "local_polling", "loggers": ["pbr", "pymochad"], + "quality_scale": "legacy", "requirements": ["pymochad==0.2.0"] } diff --git a/homeassistant/components/mqtt_eventstream/manifest.json b/homeassistant/components/mqtt_eventstream/manifest.json index 978b11de99435..95e97ebb5fa63 100644 --- a/homeassistant/components/mqtt_eventstream/manifest.json +++ b/homeassistant/components/mqtt_eventstream/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["mqtt"], "documentation": "https://www.home-assistant.io/integrations/mqtt_eventstream", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/mqtt_json/manifest.json b/homeassistant/components/mqtt_json/manifest.json index 24ed99979cc19..ccaa4996feab1 100644 --- a/homeassistant/components/mqtt_json/manifest.json +++ b/homeassistant/components/mqtt_json/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["mqtt"], "documentation": "https://www.home-assistant.io/integrations/mqtt_json", - "iot_class": "local_push" + "iot_class": "local_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/mqtt_room/manifest.json b/homeassistant/components/mqtt_room/manifest.json index efc5e375cfd11..858a1cbb98c7c 100644 --- a/homeassistant/components/mqtt_room/manifest.json +++ b/homeassistant/components/mqtt_room/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["mqtt"], "documentation": "https://www.home-assistant.io/integrations/mqtt_room", - "iot_class": "local_push" + "iot_class": "local_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/mqtt_statestream/manifest.json b/homeassistant/components/mqtt_statestream/manifest.json index 134cd80d38392..c3c278a08bbc2 100644 --- a/homeassistant/components/mqtt_statestream/manifest.json +++ b/homeassistant/components/mqtt_statestream/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["mqtt"], "documentation": "https://www.home-assistant.io/integrations/mqtt_statestream", - "iot_class": "local_push" + "iot_class": "local_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/msteams/manifest.json b/homeassistant/components/msteams/manifest.json index e4b40140441d3..3ded77c2176b8 100644 --- a/homeassistant/components/msteams/manifest.json +++ b/homeassistant/components/msteams/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/msteams", "iot_class": "cloud_push", "loggers": ["pymsteams"], + "quality_scale": "legacy", "requirements": ["pymsteams==0.1.12"] } diff --git a/homeassistant/components/mvglive/manifest.json b/homeassistant/components/mvglive/manifest.json index f73d4612c2e23..2c4e6a7e735ad 100644 --- a/homeassistant/components/mvglive/manifest.json +++ b/homeassistant/components/mvglive/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/mvglive", "iot_class": "cloud_polling", "loggers": ["MVGLive"], + "quality_scale": "legacy", "requirements": ["PyMVGLive==1.1.4"] } diff --git a/homeassistant/components/mycroft/manifest.json b/homeassistant/components/mycroft/manifest.json index 9b8731f0701bc..568bb8b17842c 100644 --- a/homeassistant/components/mycroft/manifest.json +++ b/homeassistant/components/mycroft/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/mycroft", "iot_class": "local_push", "loggers": ["mycroftapi"], + "quality_scale": "legacy", "requirements": ["mycroftapi==2.0"] } diff --git a/homeassistant/components/mythicbeastsdns/manifest.json b/homeassistant/components/mythicbeastsdns/manifest.json index ed0b96575c9a8..a4381c312bc39 100644 --- a/homeassistant/components/mythicbeastsdns/manifest.json +++ b/homeassistant/components/mythicbeastsdns/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/mythicbeastsdns", "iot_class": "cloud_push", "loggers": ["mbddns"], + "quality_scale": "legacy", "requirements": ["mbddns==0.1.2"] } diff --git a/homeassistant/components/nad/manifest.json b/homeassistant/components/nad/manifest.json index 2e2d44341afe7..64c7855af2d46 100644 --- a/homeassistant/components/nad/manifest.json +++ b/homeassistant/components/nad/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/nad", "iot_class": "local_polling", "loggers": ["nad_receiver"], + "quality_scale": "legacy", "requirements": ["nad-receiver==0.3.0"] } diff --git a/homeassistant/components/namecheapdns/manifest.json b/homeassistant/components/namecheapdns/manifest.json index fc9aa3cc03374..f97f656819288 100644 --- a/homeassistant/components/namecheapdns/manifest.json +++ b/homeassistant/components/namecheapdns/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/namecheapdns", "iot_class": "cloud_push", + "quality_scale": "legacy", "requirements": ["defusedxml==0.7.1"] } diff --git a/homeassistant/components/nederlandse_spoorwegen/manifest.json b/homeassistant/components/nederlandse_spoorwegen/manifest.json index aa8d0f4adf4ce..8a8a20c453b72 100644 --- a/homeassistant/components/nederlandse_spoorwegen/manifest.json +++ b/homeassistant/components/nederlandse_spoorwegen/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@YarmoM"], "documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen", "iot_class": "cloud_polling", + "quality_scale": "legacy", "requirements": ["nsapi==3.0.5"] } diff --git a/homeassistant/components/ness_alarm/manifest.json b/homeassistant/components/ness_alarm/manifest.json index c3bb4239048b9..3d97e3290e0c9 100644 --- a/homeassistant/components/ness_alarm/manifest.json +++ b/homeassistant/components/ness_alarm/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/ness_alarm", "iot_class": "local_push", "loggers": ["nessclient"], + "quality_scale": "legacy", "requirements": ["nessclient==1.1.2"] } diff --git a/homeassistant/components/netdata/manifest.json b/homeassistant/components/netdata/manifest.json index 99410ce033d37..199073298ab88 100644 --- a/homeassistant/components/netdata/manifest.json +++ b/homeassistant/components/netdata/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/netdata", "iot_class": "local_polling", "loggers": ["netdata"], + "quality_scale": "legacy", "requirements": ["netdata==1.1.0"] } diff --git a/homeassistant/components/netio/manifest.json b/homeassistant/components/netio/manifest.json index 683df22e1ffc6..f2914b17dec83 100644 --- a/homeassistant/components/netio/manifest.json +++ b/homeassistant/components/netio/manifest.json @@ -5,5 +5,6 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/netio", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["pynetio==0.1.9.1"] } diff --git a/homeassistant/components/neurio_energy/manifest.json b/homeassistant/components/neurio_energy/manifest.json index 467825da012b3..3a524ac4b5f9b 100644 --- a/homeassistant/components/neurio_energy/manifest.json +++ b/homeassistant/components/neurio_energy/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/neurio_energy", "iot_class": "cloud_polling", "loggers": ["neurio"], + "quality_scale": "legacy", "requirements": ["neurio==0.3.1"] } diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index 72f9dd2f6b3a6..316dc1dc95800 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_polling", "loggers": ["nikohomecontrol"], + "quality_scale": "legacy", "requirements": ["niko-home-control==0.2.1"] } diff --git a/homeassistant/components/nilu/manifest.json b/homeassistant/components/nilu/manifest.json index 1eabf9e726e6f..d99a918ef4f07 100644 --- a/homeassistant/components/nilu/manifest.json +++ b/homeassistant/components/nilu/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/nilu", "iot_class": "cloud_polling", "loggers": ["niluclient"], + "quality_scale": "legacy", "requirements": ["niluclient==0.1.2"] } diff --git a/homeassistant/components/nissan_leaf/manifest.json b/homeassistant/components/nissan_leaf/manifest.json index 9c3df39c69f11..9ad8773ee441c 100644 --- a/homeassistant/components/nissan_leaf/manifest.json +++ b/homeassistant/components/nissan_leaf/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/nissan_leaf", "iot_class": "cloud_polling", "loggers": ["pycarwings2"], + "quality_scale": "legacy", "requirements": ["pycarwings2==2.14"] } diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json index 24aadb6b4f0cb..e17d1227bede2 100644 --- a/homeassistant/components/nmbs/manifest.json +++ b/homeassistant/components/nmbs/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/nmbs", "iot_class": "cloud_polling", "loggers": ["pyrail"], + "quality_scale": "legacy", "requirements": ["pyrail==0.0.3"] } diff --git a/homeassistant/components/no_ip/manifest.json b/homeassistant/components/no_ip/manifest.json index cf995e34b4716..8e1e247143ec4 100644 --- a/homeassistant/components/no_ip/manifest.json +++ b/homeassistant/components/no_ip/manifest.json @@ -3,5 +3,6 @@ "name": "No-IP.com", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/no_ip", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/noaa_tides/manifest.json b/homeassistant/components/noaa_tides/manifest.json index 85c6fbcb78844..8cc81857770b4 100644 --- a/homeassistant/components/noaa_tides/manifest.json +++ b/homeassistant/components/noaa_tides/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/noaa_tides", "iot_class": "cloud_polling", "loggers": ["noaa_coops"], + "quality_scale": "legacy", "requirements": ["noaa-coops==0.1.9"] } diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json index 0c8f15b9b789a..5ce6efd944ca9 100644 --- a/homeassistant/components/norway_air/manifest.json +++ b/homeassistant/components/norway_air/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/norway_air", "iot_class": "cloud_polling", "loggers": ["metno"], + "quality_scale": "legacy", "requirements": ["PyMetno==0.13.0"] } diff --git a/homeassistant/components/notify_events/manifest.json b/homeassistant/components/notify_events/manifest.json index a2c01e1d718bd..e154ab85cae93 100644 --- a/homeassistant/components/notify_events/manifest.json +++ b/homeassistant/components/notify_events/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/notify_events", "iot_class": "cloud_push", "loggers": ["notify_events"], + "quality_scale": "legacy", "requirements": ["notify-events==1.0.4"] } diff --git a/homeassistant/components/nsw_fuel_station/manifest.json b/homeassistant/components/nsw_fuel_station/manifest.json index 5c105fd02817c..3fccab3918969 100644 --- a/homeassistant/components/nsw_fuel_station/manifest.json +++ b/homeassistant/components/nsw_fuel_station/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/nsw_fuel_station", "iot_class": "cloud_polling", "loggers": ["nsw_fuel"], + "quality_scale": "legacy", "requirements": ["nsw-fuel-api-client==1.1.0"] } diff --git a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json index 9d1f60e33d15e..802f4c89b7267 100644 --- a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json +++ b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json @@ -6,5 +6,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_nsw_rfs_incidents"], + "quality_scale": "legacy", "requirements": ["aio-geojson-nsw-rfs-incidents==0.7"] } diff --git a/homeassistant/components/numato/manifest.json b/homeassistant/components/numato/manifest.json index f7bcf0527c2bb..81f3793fa6c99 100644 --- a/homeassistant/components/numato/manifest.json +++ b/homeassistant/components/numato/manifest.json @@ -6,5 +6,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["numato_gpio"], + "quality_scale": "legacy", "requirements": ["numato-gpio==0.13.0"] } diff --git a/homeassistant/components/nx584/manifest.json b/homeassistant/components/nx584/manifest.json index 84ead05d0835a..9ac469224d028 100644 --- a/homeassistant/components/nx584/manifest.json +++ b/homeassistant/components/nx584/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/nx584", "iot_class": "local_push", "loggers": ["nx584"], + "quality_scale": "legacy", "requirements": ["pynx584==0.8.2"] } diff --git a/homeassistant/components/oasa_telematics/manifest.json b/homeassistant/components/oasa_telematics/manifest.json index d3dbaad98e36d..7365081a95913 100644 --- a/homeassistant/components/oasa_telematics/manifest.json +++ b/homeassistant/components/oasa_telematics/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/oasa_telematics", "iot_class": "cloud_polling", "loggers": ["oasatelematics"], + "quality_scale": "legacy", "requirements": ["oasatelematics==0.3"] } diff --git a/homeassistant/components/oem/manifest.json b/homeassistant/components/oem/manifest.json index a8ce99b93720d..f7ab34adbd932 100644 --- a/homeassistant/components/oem/manifest.json +++ b/homeassistant/components/oem/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/oem", "iot_class": "local_polling", "loggers": ["oemthermostat"], + "quality_scale": "legacy", "requirements": ["oemthermostat==1.1.1"] } diff --git a/homeassistant/components/ohmconnect/manifest.json b/homeassistant/components/ohmconnect/manifest.json index 74754485ea017..e2f02add22da2 100644 --- a/homeassistant/components/ohmconnect/manifest.json +++ b/homeassistant/components/ohmconnect/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@robbiet480"], "documentation": "https://www.home-assistant.io/integrations/ohmconnect", "iot_class": "cloud_polling", + "quality_scale": "legacy", "requirements": ["defusedxml==0.7.1"] } diff --git a/homeassistant/components/ombi/manifest.json b/homeassistant/components/ombi/manifest.json index d9da13d238184..1afc385a5a7ee 100644 --- a/homeassistant/components/ombi/manifest.json +++ b/homeassistant/components/ombi/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@larssont"], "documentation": "https://www.home-assistant.io/integrations/ombi", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["pyombi==0.1.10"] } diff --git a/homeassistant/components/openalpr_cloud/manifest.json b/homeassistant/components/openalpr_cloud/manifest.json index 45bce5c7345ad..5148cb396b613 100644 --- a/homeassistant/components/openalpr_cloud/manifest.json +++ b/homeassistant/components/openalpr_cloud/manifest.json @@ -3,5 +3,6 @@ "name": "OpenALPR Cloud", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/openalpr_cloud", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/openerz/manifest.json b/homeassistant/components/openerz/manifest.json index c7a5a202568bb..f75e3e492a886 100644 --- a/homeassistant/components/openerz/manifest.json +++ b/homeassistant/components/openerz/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/openerz", "iot_class": "cloud_polling", "loggers": ["openerz_api"], + "quality_scale": "legacy", "requirements": ["openerz-api==0.3.0"] } diff --git a/homeassistant/components/openevse/manifest.json b/homeassistant/components/openevse/manifest.json index 066eb5ee384e5..45452fe325be1 100644 --- a/homeassistant/components/openevse/manifest.json +++ b/homeassistant/components/openevse/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/openevse", "iot_class": "local_polling", "loggers": ["openevsewifi"], + "quality_scale": "legacy", "requirements": ["openevsewifi==1.1.2"] } diff --git a/homeassistant/components/openhardwaremonitor/manifest.json b/homeassistant/components/openhardwaremonitor/manifest.json index 562a2433eabba..901424eebc170 100644 --- a/homeassistant/components/openhardwaremonitor/manifest.json +++ b/homeassistant/components/openhardwaremonitor/manifest.json @@ -3,5 +3,6 @@ "name": "Open Hardware Monitor", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/openhardwaremonitor", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/opensensemap/manifest.json b/homeassistant/components/opensensemap/manifest.json index 8fed7ec906ed6..0256ae42a3ae6 100644 --- a/homeassistant/components/opensensemap/manifest.json +++ b/homeassistant/components/opensensemap/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/opensensemap", "iot_class": "cloud_polling", "loggers": ["opensensemap_api"], + "quality_scale": "legacy", "requirements": ["opensensemap-api==0.2.0"] } diff --git a/homeassistant/components/opnsense/manifest.json b/homeassistant/components/opnsense/manifest.json index bf8a41d17855d..4dd82216f1af5 100644 --- a/homeassistant/components/opnsense/manifest.json +++ b/homeassistant/components/opnsense/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/opnsense", "iot_class": "local_polling", "loggers": ["pbr", "pyopnsense"], + "quality_scale": "legacy", "requirements": ["pyopnsense==0.4.0"] } diff --git a/homeassistant/components/opple/manifest.json b/homeassistant/components/opple/manifest.json index 174907dfd0faa..dc28d1f0f33d3 100644 --- a/homeassistant/components/opple/manifest.json +++ b/homeassistant/components/opple/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/opple", "iot_class": "local_polling", "loggers": ["pyoppleio"], + "quality_scale": "legacy", "requirements": ["pyoppleio-legacy==1.0.8"] } diff --git a/homeassistant/components/oru/manifest.json b/homeassistant/components/oru/manifest.json index 23c43e323067b..347388b6f1596 100644 --- a/homeassistant/components/oru/manifest.json +++ b/homeassistant/components/oru/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/oru", "iot_class": "cloud_polling", "loggers": ["oru"], + "quality_scale": "legacy", "requirements": ["oru==0.1.11"] } diff --git a/homeassistant/components/orvibo/manifest.json b/homeassistant/components/orvibo/manifest.json index 05ce5edd8bd31..e3a6676b2f2f8 100644 --- a/homeassistant/components/orvibo/manifest.json +++ b/homeassistant/components/orvibo/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/orvibo", "iot_class": "local_push", "loggers": ["orvibo"], + "quality_scale": "legacy", "requirements": ["orvibo==1.1.2"] } diff --git a/homeassistant/components/osramlightify/manifest.json b/homeassistant/components/osramlightify/manifest.json index f6a922a09ec21..3b11200f1e59a 100644 --- a/homeassistant/components/osramlightify/manifest.json +++ b/homeassistant/components/osramlightify/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/osramlightify", "iot_class": "local_polling", "loggers": ["lightify"], + "quality_scale": "legacy", "requirements": ["lightify==1.0.7.3"] } diff --git a/homeassistant/components/panasonic_bluray/manifest.json b/homeassistant/components/panasonic_bluray/manifest.json index fa0202c087182..3de12b051e5b3 100644 --- a/homeassistant/components/panasonic_bluray/manifest.json +++ b/homeassistant/components/panasonic_bluray/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/panasonic_bluray", "iot_class": "local_polling", "loggers": ["panacotta"], + "quality_scale": "legacy", "requirements": ["panacotta==0.2"] } diff --git a/homeassistant/components/pandora/manifest.json b/homeassistant/components/pandora/manifest.json index b86f0754af3d2..e7d8946fb384f 100644 --- a/homeassistant/components/pandora/manifest.json +++ b/homeassistant/components/pandora/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/pandora", "iot_class": "local_polling", "loggers": ["pexpect", "ptyprocess"], + "quality_scale": "legacy", "requirements": ["pexpect==4.6.0"] } diff --git a/homeassistant/components/pencom/manifest.json b/homeassistant/components/pencom/manifest.json index 34ebe31597213..306b2e7be49d4 100644 --- a/homeassistant/components/pencom/manifest.json +++ b/homeassistant/components/pencom/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/pencom", "iot_class": "local_polling", "loggers": ["pencompy"], + "quality_scale": "legacy", "requirements": ["pencompy==0.0.3"] } diff --git a/homeassistant/components/picotts/manifest.json b/homeassistant/components/picotts/manifest.json index 74b91e187ba53..6e8c346a3c930 100644 --- a/homeassistant/components/picotts/manifest.json +++ b/homeassistant/components/picotts/manifest.json @@ -3,5 +3,6 @@ "name": "Pico TTS", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/picotts", - "iot_class": "local_push" + "iot_class": "local_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/pilight/manifest.json b/homeassistant/components/pilight/manifest.json index 341d0abdf6737..da07c4ee645c0 100644 --- a/homeassistant/components/pilight/manifest.json +++ b/homeassistant/components/pilight/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/pilight", "iot_class": "local_push", "loggers": ["pilight"], + "quality_scale": "legacy", "requirements": ["pilight==0.1.1"] } diff --git a/homeassistant/components/pioneer/manifest.json b/homeassistant/components/pioneer/manifest.json index c8aa3a7978991..019b7680e09eb 100644 --- a/homeassistant/components/pioneer/manifest.json +++ b/homeassistant/components/pioneer/manifest.json @@ -3,5 +3,6 @@ "name": "Pioneer", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/pioneer", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/pjlink/manifest.json b/homeassistant/components/pjlink/manifest.json index 553ed18524182..787311b250a58 100644 --- a/homeassistant/components/pjlink/manifest.json +++ b/homeassistant/components/pjlink/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/pjlink", "iot_class": "local_polling", "loggers": ["pypjlink"], + "quality_scale": "legacy", "requirements": ["pypjlink2==1.2.1"] } diff --git a/homeassistant/components/pocketcasts/manifest.json b/homeassistant/components/pocketcasts/manifest.json index 3cb6f52995e9e..f2a85ecac0d4e 100644 --- a/homeassistant/components/pocketcasts/manifest.json +++ b/homeassistant/components/pocketcasts/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/pocketcasts", "iot_class": "cloud_polling", "loggers": ["pycketcasts"], + "quality_scale": "legacy", "requirements": ["pycketcasts==1.0.1"] } diff --git a/homeassistant/components/proliphix/manifest.json b/homeassistant/components/proliphix/manifest.json index 2b01d5deb46a6..9cf0b9b095075 100644 --- a/homeassistant/components/proliphix/manifest.json +++ b/homeassistant/components/proliphix/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/proliphix", "iot_class": "local_polling", "loggers": ["proliphix"], + "quality_scale": "legacy", "requirements": ["proliphix==0.4.1"] } diff --git a/homeassistant/components/prometheus/manifest.json b/homeassistant/components/prometheus/manifest.json index 8c43be8539d22..e747226074cd8 100644 --- a/homeassistant/components/prometheus/manifest.json +++ b/homeassistant/components/prometheus/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/prometheus", "iot_class": "assumed_state", "loggers": ["prometheus_client"], + "quality_scale": "legacy", "requirements": ["prometheus-client==0.21.0"] } diff --git a/homeassistant/components/prowl/manifest.json b/homeassistant/components/prowl/manifest.json index 50decb3f0467e..049d95fb94c2f 100644 --- a/homeassistant/components/prowl/manifest.json +++ b/homeassistant/components/prowl/manifest.json @@ -3,5 +3,6 @@ "name": "Prowl", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/prowl", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/proxmoxve/manifest.json b/homeassistant/components/proxmoxve/manifest.json index 8cf3bc7932d45..45ead1330e236 100644 --- a/homeassistant/components/proxmoxve/manifest.json +++ b/homeassistant/components/proxmoxve/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/proxmoxve", "iot_class": "local_polling", "loggers": ["proxmoxer"], + "quality_scale": "legacy", "requirements": ["proxmoxer==2.0.1"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index f13799422dfb4..e73eddf3cdde6 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,6 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", + "quality_scale": "legacy", "requirements": ["Pillow==11.0.0"] } diff --git a/homeassistant/components/pulseaudio_loopback/manifest.json b/homeassistant/components/pulseaudio_loopback/manifest.json index a67dc614c50b2..90666d189976c 100644 --- a/homeassistant/components/pulseaudio_loopback/manifest.json +++ b/homeassistant/components/pulseaudio_loopback/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/pulseaudio_loopback", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["pulsectl==23.5.2"] } diff --git a/homeassistant/components/push/manifest.json b/homeassistant/components/push/manifest.json index 900ac25edbf85..81cb2dce00cc5 100644 --- a/homeassistant/components/push/manifest.json +++ b/homeassistant/components/push/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@dgomes"], "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/push", - "iot_class": "local_push" + "iot_class": "local_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/pushsafer/manifest.json b/homeassistant/components/pushsafer/manifest.json index e9018e2a2babe..8b4ec94b9a566 100644 --- a/homeassistant/components/pushsafer/manifest.json +++ b/homeassistant/components/pushsafer/manifest.json @@ -3,5 +3,6 @@ "name": "Pushsafer", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/pushsafer", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/qld_bushfire/manifest.json b/homeassistant/components/qld_bushfire/manifest.json index 282a931bf0555..79a29e6fddb39 100644 --- a/homeassistant/components/qld_bushfire/manifest.json +++ b/homeassistant/components/qld_bushfire/manifest.json @@ -6,5 +6,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["georss_qld_bushfire_alert_client"], + "quality_scale": "legacy", "requirements": ["georss-qld-bushfire-alert-client==0.8"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 3fcc895c2b9cc..9634d45b069c3 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], + "quality_scale": "legacy", "requirements": ["Pillow==11.0.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/quantum_gateway/manifest.json b/homeassistant/components/quantum_gateway/manifest.json index 4494e5a2576e0..98c6c7154171a 100644 --- a/homeassistant/components/quantum_gateway/manifest.json +++ b/homeassistant/components/quantum_gateway/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@cisasteelersfan"], "documentation": "https://www.home-assistant.io/integrations/quantum_gateway", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["quantum-gateway==0.0.8"] } diff --git a/homeassistant/components/qvr_pro/manifest.json b/homeassistant/components/qvr_pro/manifest.json index 9c0e92698dffc..2553e1d27c4be 100644 --- a/homeassistant/components/qvr_pro/manifest.json +++ b/homeassistant/components/qvr_pro/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/qvr_pro", "iot_class": "local_polling", "loggers": ["pyqvrpro"], + "quality_scale": "legacy", "requirements": ["pyqvrpro==0.52"] } diff --git a/homeassistant/components/qwikswitch/manifest.json b/homeassistant/components/qwikswitch/manifest.json index e30ebffbf2fe9..750e104d1a3bf 100644 --- a/homeassistant/components/qwikswitch/manifest.json +++ b/homeassistant/components/qwikswitch/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/qwikswitch", "iot_class": "local_push", "loggers": ["pyqwikswitch"], + "quality_scale": "legacy", "requirements": ["pyqwikswitch==0.93"] } diff --git a/homeassistant/components/raincloud/manifest.json b/homeassistant/components/raincloud/manifest.json index 70f62d2beee4a..b5179622441b1 100644 --- a/homeassistant/components/raincloud/manifest.json +++ b/homeassistant/components/raincloud/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/raincloud", "iot_class": "cloud_polling", "loggers": ["raincloudy"], + "quality_scale": "legacy", "requirements": ["raincloudy==0.0.7"] } diff --git a/homeassistant/components/raspberry_pi/manifest.json b/homeassistant/components/raspberry_pi/manifest.json index 5ed68154ce16b..c8317f7ef1e21 100644 --- a/homeassistant/components/raspberry_pi/manifest.json +++ b/homeassistant/components/raspberry_pi/manifest.json @@ -6,5 +6,6 @@ "config_flow": false, "dependencies": ["hardware"], "documentation": "https://www.home-assistant.io/integrations/raspberry_pi", - "integration_type": "hardware" + "integration_type": "hardware", + "quality_scale": "legacy" } diff --git a/homeassistant/components/raspyrfm/manifest.json b/homeassistant/components/raspyrfm/manifest.json index 0fa4ce772009f..d001e2b11187e 100644 --- a/homeassistant/components/raspyrfm/manifest.json +++ b/homeassistant/components/raspyrfm/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/raspyrfm", "iot_class": "assumed_state", "loggers": ["raspyrfm_client"], + "quality_scale": "legacy", "requirements": ["raspyrfm-client==1.2.8"] } diff --git a/homeassistant/components/recswitch/manifest.json b/homeassistant/components/recswitch/manifest.json index 3e243d8f0d246..1273d498efd43 100644 --- a/homeassistant/components/recswitch/manifest.json +++ b/homeassistant/components/recswitch/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/recswitch", "iot_class": "local_polling", "loggers": ["pyrecswitch"], + "quality_scale": "legacy", "requirements": ["pyrecswitch==1.0.2"] } diff --git a/homeassistant/components/reddit/manifest.json b/homeassistant/components/reddit/manifest.json index beb2b168e8872..a2e20329be075 100644 --- a/homeassistant/components/reddit/manifest.json +++ b/homeassistant/components/reddit/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/reddit", "iot_class": "cloud_polling", "loggers": ["praw", "prawcore"], + "quality_scale": "legacy", "requirements": ["praw==7.5.0"] } diff --git a/homeassistant/components/rejseplanen/manifest.json b/homeassistant/components/rejseplanen/manifest.json index 72da7a65f4547..6d0642cc99652 100644 --- a/homeassistant/components/rejseplanen/manifest.json +++ b/homeassistant/components/rejseplanen/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/rejseplanen", "iot_class": "cloud_polling", "loggers": ["rjpl"], + "quality_scale": "legacy", "requirements": ["rjpl==0.3.6"] } diff --git a/homeassistant/components/remember_the_milk/manifest.json b/homeassistant/components/remember_the_milk/manifest.json index ab309c765fcca..13c37d56dba0e 100644 --- a/homeassistant/components/remember_the_milk/manifest.json +++ b/homeassistant/components/remember_the_milk/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/remember_the_milk", "iot_class": "cloud_push", "loggers": ["rtmapi"], + "quality_scale": "legacy", "requirements": ["RtmAPI==0.7.2", "httplib2==0.20.4"] } diff --git a/homeassistant/components/remote_rpi_gpio/manifest.json b/homeassistant/components/remote_rpi_gpio/manifest.json index 3a369d859f8ab..b7e3b55d564f2 100644 --- a/homeassistant/components/remote_rpi_gpio/manifest.json +++ b/homeassistant/components/remote_rpi_gpio/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/remote_rpi_gpio", "iot_class": "local_push", "loggers": ["gpiozero", "pigpio"], + "quality_scale": "legacy", "requirements": ["gpiozero==1.6.2", "pigpio==1.78"] } diff --git a/homeassistant/components/repetier/manifest.json b/homeassistant/components/repetier/manifest.json index dfddb29828493..7392ae0b23eee 100644 --- a/homeassistant/components/repetier/manifest.json +++ b/homeassistant/components/repetier/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/repetier", "iot_class": "local_polling", "loggers": ["pyrepetierng"], + "quality_scale": "legacy", "requirements": ["pyrepetierng==0.1.0"] } diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index 7917fa0bdedfe..f5f372d2d3358 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/rflink", "iot_class": "assumed_state", "loggers": ["rflink"], + "quality_scale": "legacy", "requirements": ["rflink==0.0.66"] } diff --git a/homeassistant/components/ripple/manifest.json b/homeassistant/components/ripple/manifest.json index 72df64ac850f4..17ff6b34f3824 100644 --- a/homeassistant/components/ripple/manifest.json +++ b/homeassistant/components/ripple/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/ripple", "iot_class": "cloud_polling", "loggers": ["pyripple"], + "quality_scale": "legacy", "requirements": ["python-ripple-api==0.0.3"] } diff --git a/homeassistant/components/rmvtransport/manifest.json b/homeassistant/components/rmvtransport/manifest.json index 81b650bcdc09e..30be5417ff606 100644 --- a/homeassistant/components/rmvtransport/manifest.json +++ b/homeassistant/components/rmvtransport/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/rmvtransport", "iot_class": "cloud_polling", "loggers": ["RMVtransport"], + "quality_scale": "legacy", "requirements": ["PyRMVtransport==0.3.3"] } diff --git a/homeassistant/components/rocketchat/manifest.json b/homeassistant/components/rocketchat/manifest.json index 50d7579df028a..f4f72f02a1092 100644 --- a/homeassistant/components/rocketchat/manifest.json +++ b/homeassistant/components/rocketchat/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/rocketchat", "iot_class": "cloud_push", "loggers": ["rocketchat_API"], + "quality_scale": "legacy", "requirements": ["rocketchat-API==0.6.1"] } diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json index 6db240bdcabd3..978c916e3ee83 100644 --- a/homeassistant/components/route53/manifest.json +++ b/homeassistant/components/route53/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/route53", "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], + "quality_scale": "legacy", "requirements": ["boto3==1.34.131"] } diff --git a/homeassistant/components/rpi_camera/manifest.json b/homeassistant/components/rpi_camera/manifest.json index 9f7346ea353f1..aab16b1c462b2 100644 --- a/homeassistant/components/rpi_camera/manifest.json +++ b/homeassistant/components/rpi_camera/manifest.json @@ -3,5 +3,6 @@ "name": "Raspberry Pi Camera", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/rpi_camera", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/rtorrent/manifest.json b/homeassistant/components/rtorrent/manifest.json index 96b079c436309..bcd39a03aa34c 100644 --- a/homeassistant/components/rtorrent/manifest.json +++ b/homeassistant/components/rtorrent/manifest.json @@ -3,5 +3,6 @@ "name": "rTorrent", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/rtorrent", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/russound_rnet/manifest.json b/homeassistant/components/russound_rnet/manifest.json index 90bf5d5a7f359..27fbfbca57fec 100644 --- a/homeassistant/components/russound_rnet/manifest.json +++ b/homeassistant/components/russound_rnet/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/russound_rnet", "iot_class": "local_polling", "loggers": ["russound"], + "quality_scale": "legacy", "requirements": ["russound==0.2.0"] } diff --git a/homeassistant/components/saj/manifest.json b/homeassistant/components/saj/manifest.json index e882c9f0d0273..2a4243f74890c 100644 --- a/homeassistant/components/saj/manifest.json +++ b/homeassistant/components/saj/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/saj", "iot_class": "local_polling", "loggers": ["pysaj"], + "quality_scale": "legacy", "requirements": ["pysaj==0.0.16"] } diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json index 828261aa466ee..a90ea1db5a59a 100644 --- a/homeassistant/components/satel_integra/manifest.json +++ b/homeassistant/components/satel_integra/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/satel_integra", "iot_class": "local_push", "loggers": ["satel_integra"], + "quality_scale": "legacy", "requirements": ["satel-integra==0.3.7"] } diff --git a/homeassistant/components/schluter/manifest.json b/homeassistant/components/schluter/manifest.json index e96058cc14650..0302ce0944004 100644 --- a/homeassistant/components/schluter/manifest.json +++ b/homeassistant/components/schluter/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/schluter", "iot_class": "cloud_polling", "loggers": ["schluter"], + "quality_scale": "legacy", "requirements": ["py-schluter==0.1.7"] } diff --git a/homeassistant/components/scsgate/manifest.json b/homeassistant/components/scsgate/manifest.json index 3f20762cf7315..a3b08f8671906 100644 --- a/homeassistant/components/scsgate/manifest.json +++ b/homeassistant/components/scsgate/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/scsgate", "iot_class": "local_polling", "loggers": ["scsgate"], + "quality_scale": "legacy", "requirements": ["scsgate==0.1.0"] } diff --git a/homeassistant/components/sendgrid/manifest.json b/homeassistant/components/sendgrid/manifest.json index c38952e1a045c..ec89ae0a363dd 100644 --- a/homeassistant/components/sendgrid/manifest.json +++ b/homeassistant/components/sendgrid/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/sendgrid", "iot_class": "cloud_push", "loggers": ["sendgrid"], + "quality_scale": "legacy", "requirements": ["sendgrid==6.8.2"] } diff --git a/homeassistant/components/serial_pm/manifest.json b/homeassistant/components/serial_pm/manifest.json index 9b61cb3d20b50..25b3e61f93d3d 100644 --- a/homeassistant/components/serial_pm/manifest.json +++ b/homeassistant/components/serial_pm/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/serial_pm", "iot_class": "local_polling", "loggers": ["pmsensor"], + "quality_scale": "legacy", "requirements": ["pmsensor==0.4"] } diff --git a/homeassistant/components/sesame/manifest.json b/homeassistant/components/sesame/manifest.json index d2204629cde65..7ed370db082fa 100644 --- a/homeassistant/components/sesame/manifest.json +++ b/homeassistant/components/sesame/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/sesame", "iot_class": "cloud_polling", "loggers": ["pysesame2"], + "quality_scale": "legacy", "requirements": ["pysesame2==1.0.1"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index af00a1fdfed6b..bf98140a4d61c 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["Pillow==11.0.0"] } diff --git a/homeassistant/components/shodan/manifest.json b/homeassistant/components/shodan/manifest.json index 9155311a2ade7..afd75e3fed554 100644 --- a/homeassistant/components/shodan/manifest.json +++ b/homeassistant/components/shodan/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/shodan", "iot_class": "cloud_polling", "loggers": ["shodan"], + "quality_scale": "legacy", "requirements": ["shodan==1.28.0"] } diff --git a/homeassistant/components/sigfox/manifest.json b/homeassistant/components/sigfox/manifest.json index 3b581e4a0819c..f3f44bf8979b9 100644 --- a/homeassistant/components/sigfox/manifest.json +++ b/homeassistant/components/sigfox/manifest.json @@ -3,5 +3,6 @@ "name": "Sigfox", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/sigfox", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 7d08367cf7d19..1efd572425b80 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], + "quality_scale": "legacy", "requirements": ["Pillow==11.0.0", "simplehound==0.3"] } diff --git a/homeassistant/components/signal_messenger/manifest.json b/homeassistant/components/signal_messenger/manifest.json index 217109bfa2ca4..5ff63052691f7 100644 --- a/homeassistant/components/signal_messenger/manifest.json +++ b/homeassistant/components/signal_messenger/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/signal_messenger", "iot_class": "cloud_push", "loggers": ["pysignalclirestapi"], + "quality_scale": "legacy", "requirements": ["pysignalclirestapi==0.3.24"] } diff --git a/homeassistant/components/sinch/manifest.json b/homeassistant/components/sinch/manifest.json index 21a80f63b1f88..4af90b759ee4a 100644 --- a/homeassistant/components/sinch/manifest.json +++ b/homeassistant/components/sinch/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/sinch", "iot_class": "cloud_push", "loggers": ["clx"], + "quality_scale": "legacy", "requirements": ["clx-sdk-xms==1.0.0"] } diff --git a/homeassistant/components/sisyphus/manifest.json b/homeassistant/components/sisyphus/manifest.json index 4e344c0b25e83..f62d19b77c131 100644 --- a/homeassistant/components/sisyphus/manifest.json +++ b/homeassistant/components/sisyphus/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/sisyphus", "iot_class": "local_push", "loggers": ["sisyphus_control"], + "quality_scale": "legacy", "requirements": ["sisyphus-control==3.1.4"] } diff --git a/homeassistant/components/sky_hub/manifest.json b/homeassistant/components/sky_hub/manifest.json index 541cc6e0b033c..1030da4d0ffb6 100644 --- a/homeassistant/components/sky_hub/manifest.json +++ b/homeassistant/components/sky_hub/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/sky_hub", "iot_class": "local_polling", "loggers": ["pyskyqhub"], + "quality_scale": "legacy", "requirements": ["pyskyqhub==0.1.4"] } diff --git a/homeassistant/components/skybeacon/manifest.json b/homeassistant/components/skybeacon/manifest.json index deda02f64f7d7..379f10e8873b4 100644 --- a/homeassistant/components/skybeacon/manifest.json +++ b/homeassistant/components/skybeacon/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/skybeacon", "iot_class": "local_polling", "loggers": ["pygatt"], + "quality_scale": "legacy", "requirements": ["pygatt[GATTTOOL]==4.0.5"] } diff --git a/homeassistant/components/slide/manifest.json b/homeassistant/components/slide/manifest.json index 111bc9bd7a93d..2b56185efa1f5 100644 --- a/homeassistant/components/slide/manifest.json +++ b/homeassistant/components/slide/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/slide", "iot_class": "cloud_polling", "loggers": ["goslideapi"], + "quality_scale": "legacy", "requirements": ["goslide-api==0.7.0"] } diff --git a/homeassistant/components/smtp/manifest.json b/homeassistant/components/smtp/manifest.json index 0e0bba707ac0d..66954eebcccf8 100644 --- a/homeassistant/components/smtp/manifest.json +++ b/homeassistant/components/smtp/manifest.json @@ -3,5 +3,6 @@ "name": "SMTP", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/smtp", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/snips/manifest.json b/homeassistant/components/snips/manifest.json index 16620eb4bfb2e..ec768b2b3d40c 100644 --- a/homeassistant/components/snips/manifest.json +++ b/homeassistant/components/snips/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["mqtt"], "documentation": "https://www.home-assistant.io/integrations/snips", - "iot_class": "local_push" + "iot_class": "local_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index 0b8863c8e589d..a2a4405a1b59b 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/snmp", "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"], + "quality_scale": "legacy", "requirements": ["pysnmp==6.2.6"] } diff --git a/homeassistant/components/solaredge_local/manifest.json b/homeassistant/components/solaredge_local/manifest.json index d65aa06ea0ab8..61c08b3b152a8 100644 --- a/homeassistant/components/solaredge_local/manifest.json +++ b/homeassistant/components/solaredge_local/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/solaredge_local", "iot_class": "local_polling", "loggers": ["solaredge_local"], + "quality_scale": "legacy", "requirements": ["solaredge-local==0.2.3"] } diff --git a/homeassistant/components/sony_projector/manifest.json b/homeassistant/components/sony_projector/manifest.json index 5cf5df4c96f87..f674f6fa56b1f 100644 --- a/homeassistant/components/sony_projector/manifest.json +++ b/homeassistant/components/sony_projector/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/sony_projector", "iot_class": "local_polling", "loggers": ["pysdcp"], + "quality_scale": "legacy", "requirements": ["pySDCP==1"] } diff --git a/homeassistant/components/spaceapi/manifest.json b/homeassistant/components/spaceapi/manifest.json index 84add9bb4eda2..798930bbef50c 100644 --- a/homeassistant/components/spaceapi/manifest.json +++ b/homeassistant/components/spaceapi/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@fabaff"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/spaceapi", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/spc/manifest.json b/homeassistant/components/spc/manifest.json index a707e1a780408..b3c37ce2e2bb3 100644 --- a/homeassistant/components/spc/manifest.json +++ b/homeassistant/components/spc/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/spc", "iot_class": "local_push", "loggers": ["pyspcwebgw"], + "quality_scale": "legacy", "requirements": ["pyspcwebgw==0.7.0"] } diff --git a/homeassistant/components/splunk/manifest.json b/homeassistant/components/splunk/manifest.json index 947af317b35fb..4b287c8950c2e 100644 --- a/homeassistant/components/splunk/manifest.json +++ b/homeassistant/components/splunk/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/splunk", "iot_class": "local_push", "loggers": ["hass_splunk"], + "quality_scale": "legacy", "requirements": ["hass-splunk==0.1.1"] } diff --git a/homeassistant/components/starlingbank/manifest.json b/homeassistant/components/starlingbank/manifest.json index ef9be6d6da8ed..f7ab72c4379ea 100644 --- a/homeassistant/components/starlingbank/manifest.json +++ b/homeassistant/components/starlingbank/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/starlingbank", "iot_class": "cloud_polling", "loggers": ["starlingbank"], + "quality_scale": "legacy", "requirements": ["starlingbank==3.2"] } diff --git a/homeassistant/components/startca/manifest.json b/homeassistant/components/startca/manifest.json index 8c74a655ce3a2..958477c193b8a 100644 --- a/homeassistant/components/startca/manifest.json +++ b/homeassistant/components/startca/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/startca", "iot_class": "cloud_polling", + "quality_scale": "legacy", "requirements": ["xmltodict==0.13.0"] } diff --git a/homeassistant/components/statsd/manifest.json b/homeassistant/components/statsd/manifest.json index 73296a23dd9cf..4f0ea93eb9881 100644 --- a/homeassistant/components/statsd/manifest.json +++ b/homeassistant/components/statsd/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/statsd", "iot_class": "local_push", "loggers": ["statsd"], + "quality_scale": "legacy", "requirements": ["statsd==3.2.1"] } diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index 6592851d64161..9580cd4d4ca70 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/stiebel_eltron", "iot_class": "local_polling", "loggers": ["pymodbus", "pystiebeleltron"], + "quality_scale": "legacy", "requirements": ["pystiebeleltron==0.0.1.dev2"] } diff --git a/homeassistant/components/supervisord/manifest.json b/homeassistant/components/supervisord/manifest.json index 7586a435ed7cb..3cdbdd230aa4d 100644 --- a/homeassistant/components/supervisord/manifest.json +++ b/homeassistant/components/supervisord/manifest.json @@ -3,5 +3,6 @@ "name": "Supervisord", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/supervisord", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/supla/manifest.json b/homeassistant/components/supla/manifest.json index 6927c92c6e1c3..803a321c0d642 100644 --- a/homeassistant/components/supla/manifest.json +++ b/homeassistant/components/supla/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/supla", "iot_class": "cloud_polling", "loggers": ["asyncpysupla"], + "quality_scale": "legacy", "requirements": ["asyncpysupla==0.0.5"] } diff --git a/homeassistant/components/swiss_hydrological_data/manifest.json b/homeassistant/components/swiss_hydrological_data/manifest.json index 14e2882804e77..11b49a42e3f11 100644 --- a/homeassistant/components/swiss_hydrological_data/manifest.json +++ b/homeassistant/components/swiss_hydrological_data/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/swiss_hydrological_data", "iot_class": "cloud_polling", "loggers": ["swisshydrodata"], + "quality_scale": "legacy", "requirements": ["swisshydrodata==0.1.0"] } diff --git a/homeassistant/components/swisscom/manifest.json b/homeassistant/components/swisscom/manifest.json index cb0e674570e64..cf1ea01ea9c04 100644 --- a/homeassistant/components/swisscom/manifest.json +++ b/homeassistant/components/swisscom/manifest.json @@ -3,5 +3,6 @@ "name": "Swisscom Internet-Box", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/swisscom", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/switchmate/manifest.json b/homeassistant/components/switchmate/manifest.json index 5467dc512c3bc..f21819e1bc025 100644 --- a/homeassistant/components/switchmate/manifest.json +++ b/homeassistant/components/switchmate/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/switchmate", "iot_class": "local_polling", "loggers": ["switchmate"], + "quality_scale": "legacy", "requirements": ["PySwitchmate==0.5.1"] } diff --git a/homeassistant/components/synology_chat/manifest.json b/homeassistant/components/synology_chat/manifest.json index 3ac663ff91eb7..c9bd339609702 100644 --- a/homeassistant/components/synology_chat/manifest.json +++ b/homeassistant/components/synology_chat/manifest.json @@ -3,5 +3,6 @@ "name": "Synology Chat", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/synology_chat", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/synology_srm/manifest.json b/homeassistant/components/synology_srm/manifest.json index 9980f37969e5d..0d712b6742b7f 100644 --- a/homeassistant/components/synology_srm/manifest.json +++ b/homeassistant/components/synology_srm/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/synology_srm", "iot_class": "local_polling", "loggers": ["synology_srm"], + "quality_scale": "legacy", "requirements": ["synology-srm==0.2.0"] } diff --git a/homeassistant/components/syslog/manifest.json b/homeassistant/components/syslog/manifest.json index 380628ffa66af..bf327baec10e2 100644 --- a/homeassistant/components/syslog/manifest.json +++ b/homeassistant/components/syslog/manifest.json @@ -3,5 +3,6 @@ "name": "Syslog", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/syslog", - "iot_class": "local_push" + "iot_class": "local_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/tank_utility/manifest.json b/homeassistant/components/tank_utility/manifest.json index d73c62fa5ec3e..76240252696b5 100644 --- a/homeassistant/components/tank_utility/manifest.json +++ b/homeassistant/components/tank_utility/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/tank_utility", "iot_class": "cloud_polling", "loggers": ["tank_utility"], + "quality_scale": "legacy", "requirements": ["tank-utility==1.5.0"] } diff --git a/homeassistant/components/tapsaff/manifest.json b/homeassistant/components/tapsaff/manifest.json index 861329827d791..c4853ca1c8dbd 100644 --- a/homeassistant/components/tapsaff/manifest.json +++ b/homeassistant/components/tapsaff/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/tapsaff", "iot_class": "local_polling", "loggers": ["tapsaff"], + "quality_scale": "legacy", "requirements": ["tapsaff==0.2.1"] } diff --git a/homeassistant/components/tcp/manifest.json b/homeassistant/components/tcp/manifest.json index e15200f49f8a4..7eacff6c50aad 100644 --- a/homeassistant/components/tcp/manifest.json +++ b/homeassistant/components/tcp/manifest.json @@ -3,5 +3,6 @@ "name": "TCP", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/tcp", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/ted5000/manifest.json b/homeassistant/components/ted5000/manifest.json index b2aa68f884bfe..3e28d963957ca 100644 --- a/homeassistant/components/ted5000/manifest.json +++ b/homeassistant/components/ted5000/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/ted5000", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["xmltodict==0.13.0"] } diff --git a/homeassistant/components/telegram/manifest.json b/homeassistant/components/telegram/manifest.json index ce4457b31299e..9022f3579701b 100644 --- a/homeassistant/components/telegram/manifest.json +++ b/homeassistant/components/telegram/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["telegram_bot"], "documentation": "https://www.home-assistant.io/integrations/telegram", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/telegram_bot/manifest.json b/homeassistant/components/telegram_bot/manifest.json index b432c88762fb4..3474d39b1d613 100644 --- a/homeassistant/components/telegram_bot/manifest.json +++ b/homeassistant/components/telegram_bot/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/telegram_bot", "iot_class": "cloud_push", "loggers": ["telegram"], + "quality_scale": "legacy", "requirements": ["python-telegram-bot[socks]==21.5"] } diff --git a/homeassistant/components/tellstick/manifest.json b/homeassistant/components/tellstick/manifest.json index c64a51b09e461..40956b06ac61c 100644 --- a/homeassistant/components/tellstick/manifest.json +++ b/homeassistant/components/tellstick/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/tellstick", "iot_class": "assumed_state", "loggers": ["tellcore"], + "quality_scale": "legacy", "requirements": ["tellcore-net==0.4", "tellcore-py==1.1.2"] } diff --git a/homeassistant/components/telnet/manifest.json b/homeassistant/components/telnet/manifest.json index 48a79afc528ec..6835310483938 100644 --- a/homeassistant/components/telnet/manifest.json +++ b/homeassistant/components/telnet/manifest.json @@ -3,5 +3,6 @@ "name": "Telnet", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/telnet", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/temper/manifest.json b/homeassistant/components/temper/manifest.json index dbad882787792..ad1fcd40525cd 100644 --- a/homeassistant/components/temper/manifest.json +++ b/homeassistant/components/temper/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/temper", "iot_class": "local_polling", "loggers": ["pyusb", "temperusb"], + "quality_scale": "legacy", "requirements": ["temperusb==1.6.1"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 86fd83ad0881e..1ddfa188c0a6f 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -5,6 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/tensorflow", "iot_class": "local_polling", "loggers": ["tensorflow"], + "quality_scale": "legacy", "requirements": [ "tensorflow==2.5.0", "tf-models-official==2.5.0", diff --git a/homeassistant/components/tfiac/manifest.json b/homeassistant/components/tfiac/manifest.json index 243710241a230..94f82c99d2192 100644 --- a/homeassistant/components/tfiac/manifest.json +++ b/homeassistant/components/tfiac/manifest.json @@ -5,5 +5,6 @@ "disabled": "This integration is disabled because we cannot build a valid wheel.", "documentation": "https://www.home-assistant.io/integrations/tfiac", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["pytfiac==0.4"] } diff --git a/homeassistant/components/thermoworks_smoke/manifest.json b/homeassistant/components/thermoworks_smoke/manifest.json index 7baec9cdb748d..f67b041b1e576 100644 --- a/homeassistant/components/thermoworks_smoke/manifest.json +++ b/homeassistant/components/thermoworks_smoke/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/thermoworks_smoke", "iot_class": "cloud_polling", "loggers": ["thermoworks_smoke"], + "quality_scale": "legacy", "requirements": ["stringcase==1.2.0", "thermoworks-smoke==0.1.8"] } diff --git a/homeassistant/components/thingspeak/manifest.json b/homeassistant/components/thingspeak/manifest.json index ffdc11d921468..aac0ca06426cb 100644 --- a/homeassistant/components/thingspeak/manifest.json +++ b/homeassistant/components/thingspeak/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/thingspeak", "iot_class": "cloud_push", "loggers": ["thingspeak"], + "quality_scale": "legacy", "requirements": ["thingspeak==1.0.0"] } diff --git a/homeassistant/components/thinkingcleaner/manifest.json b/homeassistant/components/thinkingcleaner/manifest.json index f480340fcf84c..048fcfffa05bf 100644 --- a/homeassistant/components/thinkingcleaner/manifest.json +++ b/homeassistant/components/thinkingcleaner/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/thinkingcleaner", "iot_class": "local_polling", "loggers": ["pythinkingcleaner"], + "quality_scale": "legacy", "requirements": ["pythinkingcleaner==0.0.3"] } diff --git a/homeassistant/components/thomson/manifest.json b/homeassistant/components/thomson/manifest.json index 08961cb27463c..7f49b57d7242d 100644 --- a/homeassistant/components/thomson/manifest.json +++ b/homeassistant/components/thomson/manifest.json @@ -3,5 +3,6 @@ "name": "Thomson", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/thomson", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/tikteck/manifest.json b/homeassistant/components/tikteck/manifest.json index 067dd6f92cfec..57e5269d3b08c 100644 --- a/homeassistant/components/tikteck/manifest.json +++ b/homeassistant/components/tikteck/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/tikteck", "iot_class": "local_polling", "loggers": ["tikteck"], + "quality_scale": "legacy", "requirements": ["tikteck==0.4"] } diff --git a/homeassistant/components/tmb/manifest.json b/homeassistant/components/tmb/manifest.json index 16efc87050411..0e0324a62f443 100644 --- a/homeassistant/components/tmb/manifest.json +++ b/homeassistant/components/tmb/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/tmb", "iot_class": "local_polling", "loggers": ["tmb"], + "quality_scale": "legacy", "requirements": ["tmb==0.0.4"] } diff --git a/homeassistant/components/tomato/manifest.json b/homeassistant/components/tomato/manifest.json index 6db69d50d82b4..081d55bc46dcd 100644 --- a/homeassistant/components/tomato/manifest.json +++ b/homeassistant/components/tomato/manifest.json @@ -3,5 +3,6 @@ "name": "Tomato", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/tomato", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/torque/manifest.json b/homeassistant/components/torque/manifest.json index b966365bdd4cb..44047c67dd246 100644 --- a/homeassistant/components/torque/manifest.json +++ b/homeassistant/components/torque/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/torque", - "iot_class": "local_push" + "iot_class": "local_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/touchline/manifest.json b/homeassistant/components/touchline/manifest.json index 340edb8381a73..c003cca97a404 100644 --- a/homeassistant/components/touchline/manifest.json +++ b/homeassistant/components/touchline/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/touchline", "iot_class": "local_polling", "loggers": ["pytouchline"], + "quality_scale": "legacy", "requirements": ["pytouchline==0.7"] } diff --git a/homeassistant/components/tplink_lte/manifest.json b/homeassistant/components/tplink_lte/manifest.json index 63640628e3533..a880594e683e4 100644 --- a/homeassistant/components/tplink_lte/manifest.json +++ b/homeassistant/components/tplink_lte/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_lte", "iot_class": "local_polling", "loggers": ["tp_connected"], + "quality_scale": "legacy", "requirements": ["tp-connected==0.0.4"] } diff --git a/homeassistant/components/transport_nsw/manifest.json b/homeassistant/components/transport_nsw/manifest.json index 9d535b99aa1b0..83c138a4f9171 100644 --- a/homeassistant/components/transport_nsw/manifest.json +++ b/homeassistant/components/transport_nsw/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/transport_nsw", "iot_class": "cloud_polling", "loggers": ["TransportNSW"], + "quality_scale": "legacy", "requirements": ["PyTransportNSW==0.1.1"] } diff --git a/homeassistant/components/travisci/manifest.json b/homeassistant/components/travisci/manifest.json index e61a987c86fa0..be30cf8e1f9d8 100644 --- a/homeassistant/components/travisci/manifest.json +++ b/homeassistant/components/travisci/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/travisci", "iot_class": "cloud_polling", "loggers": ["travispy"], + "quality_scale": "legacy", "requirements": ["TravisPy==0.3.5"] } diff --git a/homeassistant/components/twilio_call/manifest.json b/homeassistant/components/twilio_call/manifest.json index 88f09efdeed6f..f4389e1c7d765 100644 --- a/homeassistant/components/twilio_call/manifest.json +++ b/homeassistant/components/twilio_call/manifest.json @@ -5,5 +5,6 @@ "dependencies": ["twilio"], "documentation": "https://www.home-assistant.io/integrations/twilio_call", "iot_class": "cloud_push", - "loggers": ["twilio"] + "loggers": ["twilio"], + "quality_scale": "legacy" } diff --git a/homeassistant/components/twilio_sms/manifest.json b/homeassistant/components/twilio_sms/manifest.json index 8736d58c0da58..eed5a1113c62e 100644 --- a/homeassistant/components/twilio_sms/manifest.json +++ b/homeassistant/components/twilio_sms/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["twilio"], "documentation": "https://www.home-assistant.io/integrations/twilio_sms", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/twitter/manifest.json b/homeassistant/components/twitter/manifest.json index 44e8712b02930..af4dff4486d83 100644 --- a/homeassistant/components/twitter/manifest.json +++ b/homeassistant/components/twitter/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/twitter", "iot_class": "cloud_push", "loggers": ["TwitterAPI"], + "quality_scale": "legacy", "requirements": ["TwitterAPI==2.7.12"] } diff --git a/homeassistant/components/ubus/manifest.json b/homeassistant/components/ubus/manifest.json index 902b7c9bb8284..6053199b4cedb 100644 --- a/homeassistant/components/ubus/manifest.json +++ b/homeassistant/components/ubus/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/ubus", "iot_class": "local_polling", "loggers": ["openwrt"], + "quality_scale": "legacy", "requirements": ["openwrt-ubus-rpc==0.0.2"] } diff --git a/homeassistant/components/uk_transport/manifest.json b/homeassistant/components/uk_transport/manifest.json index f3511e71bfa35..d855a04ee29b2 100644 --- a/homeassistant/components/uk_transport/manifest.json +++ b/homeassistant/components/uk_transport/manifest.json @@ -3,5 +3,6 @@ "name": "UK Transport", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/uk_transport", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/unifi_direct/manifest.json b/homeassistant/components/unifi_direct/manifest.json index 8ca8ef27bb2db..775279c64e210 100644 --- a/homeassistant/components/unifi_direct/manifest.json +++ b/homeassistant/components/unifi_direct/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/unifi_direct", "iot_class": "local_polling", "loggers": ["unifi_ap"], + "quality_scale": "legacy", "requirements": ["unifi_ap==0.0.1"] } diff --git a/homeassistant/components/unifiled/manifest.json b/homeassistant/components/unifiled/manifest.json index c75efb2053bb2..a2179c76fd930 100644 --- a/homeassistant/components/unifiled/manifest.json +++ b/homeassistant/components/unifiled/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/unifiled", "iot_class": "local_polling", "loggers": ["unifiled"], + "quality_scale": "legacy", "requirements": ["unifiled==0.11"] } diff --git a/homeassistant/components/upc_connect/manifest.json b/homeassistant/components/upc_connect/manifest.json index 02b852ec3a6a6..1874e5db0288f 100644 --- a/homeassistant/components/upc_connect/manifest.json +++ b/homeassistant/components/upc_connect/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/upc_connect", "iot_class": "local_polling", "loggers": ["connect_box"], + "quality_scale": "legacy", "requirements": ["connect-box==0.3.1"] } diff --git a/homeassistant/components/usgs_earthquakes_feed/manifest.json b/homeassistant/components/usgs_earthquakes_feed/manifest.json index ffb9412703f25..ea68d00e2a955 100644 --- a/homeassistant/components/usgs_earthquakes_feed/manifest.json +++ b/homeassistant/components/usgs_earthquakes_feed/manifest.json @@ -6,5 +6,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_usgs_earthquakes"], + "quality_scale": "legacy", "requirements": ["aio-geojson-usgs-earthquakes==0.3"] } diff --git a/homeassistant/components/uvc/manifest.json b/homeassistant/components/uvc/manifest.json index c72b865b5efbf..aeb9b6068ea44 100644 --- a/homeassistant/components/uvc/manifest.json +++ b/homeassistant/components/uvc/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/uvc", "iot_class": "local_polling", "loggers": ["uvcclient"], + "quality_scale": "legacy", "requirements": ["uvcclient==0.12.1"] } diff --git a/homeassistant/components/vasttrafik/manifest.json b/homeassistant/components/vasttrafik/manifest.json index 336d06e182c42..73b773720ade9 100644 --- a/homeassistant/components/vasttrafik/manifest.json +++ b/homeassistant/components/vasttrafik/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/vasttrafik", "iot_class": "cloud_polling", "loggers": ["vasttrafik"], + "quality_scale": "legacy", "requirements": ["vtjp==0.2.1"] } diff --git a/homeassistant/components/versasense/manifest.json b/homeassistant/components/versasense/manifest.json index 421a46bc2f6fd..1f1ee9e6b9c60 100644 --- a/homeassistant/components/versasense/manifest.json +++ b/homeassistant/components/versasense/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/versasense", "iot_class": "local_polling", "loggers": ["pyversasense"], + "quality_scale": "legacy", "requirements": ["pyversasense==0.0.6"] } diff --git a/homeassistant/components/viaggiatreno/manifest.json b/homeassistant/components/viaggiatreno/manifest.json index 904f9c0bebf65..584742c8c59c9 100644 --- a/homeassistant/components/viaggiatreno/manifest.json +++ b/homeassistant/components/viaggiatreno/manifest.json @@ -3,5 +3,6 @@ "name": "Trenitalia ViaggiaTreno", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/viaggiatreno", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/vivotek/manifest.json b/homeassistant/components/vivotek/manifest.json index 5a33ca099081b..f0b622afcadaf 100644 --- a/homeassistant/components/vivotek/manifest.json +++ b/homeassistant/components/vivotek/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/vivotek", "iot_class": "local_polling", "loggers": ["libpyvivotek"], + "quality_scale": "legacy", "requirements": ["libpyvivotek==0.4.0"] } diff --git a/homeassistant/components/vlc/manifest.json b/homeassistant/components/vlc/manifest.json index 7e4fb7b2a4fa6..a31fe49859c47 100644 --- a/homeassistant/components/vlc/manifest.json +++ b/homeassistant/components/vlc/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/vlc", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["python-vlc==3.0.18122"] } diff --git a/homeassistant/components/voicerss/manifest.json b/homeassistant/components/voicerss/manifest.json index bfc61365dc041..1e7da9d220d9d 100644 --- a/homeassistant/components/voicerss/manifest.json +++ b/homeassistant/components/voicerss/manifest.json @@ -3,5 +3,6 @@ "name": "VoiceRSS", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/voicerss", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/volkszaehler/manifest.json b/homeassistant/components/volkszaehler/manifest.json index e9070d0fa87f6..1427f330e77b9 100644 --- a/homeassistant/components/volkszaehler/manifest.json +++ b/homeassistant/components/volkszaehler/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/volkszaehler", "iot_class": "local_polling", "loggers": ["volkszaehler"], + "quality_scale": "legacy", "requirements": ["volkszaehler==0.4.0"] } diff --git a/homeassistant/components/vultr/manifest.json b/homeassistant/components/vultr/manifest.json index dc3cd3571eb1b..713485e79317f 100644 --- a/homeassistant/components/vultr/manifest.json +++ b/homeassistant/components/vultr/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/vultr", "iot_class": "cloud_polling", "loggers": ["vultr"], + "quality_scale": "legacy", "requirements": ["vultr==0.1.2"] } diff --git a/homeassistant/components/w800rf32/manifest.json b/homeassistant/components/w800rf32/manifest.json index 769eb96b3c0a9..4d5074e72c26e 100644 --- a/homeassistant/components/w800rf32/manifest.json +++ b/homeassistant/components/w800rf32/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/w800rf32", "iot_class": "local_push", "loggers": ["W800rf32"], + "quality_scale": "legacy", "requirements": ["pyW800rf32==0.4"] } diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json index 9e01f7e6a05a6..2bf72acb047b7 100644 --- a/homeassistant/components/waterfurnace/manifest.json +++ b/homeassistant/components/waterfurnace/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/waterfurnace", "iot_class": "cloud_polling", "loggers": ["waterfurnace"], + "quality_scale": "legacy", "requirements": ["waterfurnace==1.1.0"] } diff --git a/homeassistant/components/watson_iot/manifest.json b/homeassistant/components/watson_iot/manifest.json index 702c54922460f..a457dcc44b1be 100644 --- a/homeassistant/components/watson_iot/manifest.json +++ b/homeassistant/components/watson_iot/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/watson_iot", "iot_class": "cloud_push", "loggers": ["ibmiotf", "paho_mqtt"], + "quality_scale": "legacy", "requirements": ["ibmiotf==0.3.4"] } diff --git a/homeassistant/components/watson_tts/manifest.json b/homeassistant/components/watson_tts/manifest.json index f26fc0065613c..ecc3d97be460f 100644 --- a/homeassistant/components/watson_tts/manifest.json +++ b/homeassistant/components/watson_tts/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/watson_tts", "iot_class": "cloud_push", "loggers": ["ibm_cloud_sdk_core", "ibm_watson"], + "quality_scale": "legacy", "requirements": ["ibm-watson==5.2.2"] } diff --git a/homeassistant/components/wirelesstag/manifest.json b/homeassistant/components/wirelesstag/manifest.json index 9735c83345326..1ff9403d3bc71 100644 --- a/homeassistant/components/wirelesstag/manifest.json +++ b/homeassistant/components/wirelesstag/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/wirelesstag", "iot_class": "cloud_push", "loggers": ["wirelesstagpy"], + "quality_scale": "legacy", "requirements": ["wirelesstagpy==0.8.1"] } diff --git a/homeassistant/components/worldtidesinfo/manifest.json b/homeassistant/components/worldtidesinfo/manifest.json index 962e63617f455..c873f2f08f300 100644 --- a/homeassistant/components/worldtidesinfo/manifest.json +++ b/homeassistant/components/worldtidesinfo/manifest.json @@ -3,5 +3,6 @@ "name": "World Tides", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/worldtidesinfo", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/worxlandroid/manifest.json b/homeassistant/components/worxlandroid/manifest.json index a74228295c884..7a65b3b91b618 100644 --- a/homeassistant/components/worxlandroid/manifest.json +++ b/homeassistant/components/worxlandroid/manifest.json @@ -3,5 +3,6 @@ "name": "Worx Landroid", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/worxlandroid", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/wsdot/manifest.json b/homeassistant/components/wsdot/manifest.json index 4444cfbac4a1f..9b7746eea74d0 100644 --- a/homeassistant/components/wsdot/manifest.json +++ b/homeassistant/components/wsdot/manifest.json @@ -3,5 +3,6 @@ "name": "Washington State Department of Transportation (WSDOT)", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/wsdot", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/x10/manifest.json b/homeassistant/components/x10/manifest.json index 258080dc3743c..517bab07f6c91 100644 --- a/homeassistant/components/x10/manifest.json +++ b/homeassistant/components/x10/manifest.json @@ -3,5 +3,6 @@ "name": "Heyu X10", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/x10", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/xeoma/manifest.json b/homeassistant/components/xeoma/manifest.json index d66177ca2142d..839724cc781eb 100644 --- a/homeassistant/components/xeoma/manifest.json +++ b/homeassistant/components/xeoma/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/xeoma", "iot_class": "local_polling", "loggers": ["pyxeoma"], + "quality_scale": "legacy", "requirements": ["pyxeoma==1.4.2"] } diff --git a/homeassistant/components/xiaomi/manifest.json b/homeassistant/components/xiaomi/manifest.json index ef7085f2aa4ae..45540db47f33b 100644 --- a/homeassistant/components/xiaomi/manifest.json +++ b/homeassistant/components/xiaomi/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "dependencies": ["ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/xiaomi", - "iot_class": "local_polling" + "iot_class": "local_polling", + "quality_scale": "legacy" } diff --git a/homeassistant/components/xiaomi_tv/manifest.json b/homeassistant/components/xiaomi_tv/manifest.json index 2e913e80fdc53..8335adff33351 100644 --- a/homeassistant/components/xiaomi_tv/manifest.json +++ b/homeassistant/components/xiaomi_tv/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/xiaomi_tv", "iot_class": "assumed_state", "loggers": ["pymitv"], + "quality_scale": "legacy", "requirements": ["pymitv==1.4.3"] } diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json index 308c3d7097818..d77d70ff86c03 100644 --- a/homeassistant/components/xmpp/manifest.json +++ b/homeassistant/components/xmpp/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/xmpp", "iot_class": "cloud_push", "loggers": ["pyasn1", "slixmpp"], + "quality_scale": "legacy", "requirements": ["slixmpp==1.8.5", "emoji==2.8.0"] } diff --git a/homeassistant/components/xs1/manifest.json b/homeassistant/components/xs1/manifest.json index 9f4c921642d5d..88a5e4427aedf 100644 --- a/homeassistant/components/xs1/manifest.json +++ b/homeassistant/components/xs1/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/xs1", "iot_class": "local_polling", "loggers": ["xs1_api_client"], + "quality_scale": "legacy", "requirements": ["xs1-api-client==3.0.0"] } diff --git a/homeassistant/components/yamaha/manifest.json b/homeassistant/components/yamaha/manifest.json index 8e6ba0b8854c0..936028330a5ed 100644 --- a/homeassistant/components/yamaha/manifest.json +++ b/homeassistant/components/yamaha/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/yamaha", "iot_class": "local_polling", "loggers": ["rxv"], + "quality_scale": "legacy", "requirements": ["rxv==0.7.0"] } diff --git a/homeassistant/components/yandex_transport/manifest.json b/homeassistant/components/yandex_transport/manifest.json index 1d1219d5a9560..ad31d49525394 100644 --- a/homeassistant/components/yandex_transport/manifest.json +++ b/homeassistant/components/yandex_transport/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@rishatik92", "@devbis"], "documentation": "https://www.home-assistant.io/integrations/yandex_transport", "iot_class": "cloud_polling", + "quality_scale": "legacy", "requirements": ["aioymaps==1.2.5"] } diff --git a/homeassistant/components/yandextts/manifest.json b/homeassistant/components/yandextts/manifest.json index e1ab27272efed..418516a2d095e 100644 --- a/homeassistant/components/yandextts/manifest.json +++ b/homeassistant/components/yandextts/manifest.json @@ -3,5 +3,6 @@ "name": "Yandex TTS", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/yandextts", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "quality_scale": "legacy" } diff --git a/homeassistant/components/yeelightsunflower/manifest.json b/homeassistant/components/yeelightsunflower/manifest.json index 67746e122cb43..bfd185cfa7234 100644 --- a/homeassistant/components/yeelightsunflower/manifest.json +++ b/homeassistant/components/yeelightsunflower/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/yeelightsunflower", "iot_class": "local_polling", "loggers": ["yeelightsunflower"], + "quality_scale": "legacy", "requirements": ["yeelightsunflower==0.0.10"] } diff --git a/homeassistant/components/yi/manifest.json b/homeassistant/components/yi/manifest.json index d8514b251ccda..24b5aaad758d3 100644 --- a/homeassistant/components/yi/manifest.json +++ b/homeassistant/components/yi/manifest.json @@ -7,5 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["aioftp"], + "quality_scale": "legacy", "requirements": ["aioftp==0.21.3"] } diff --git a/homeassistant/components/zabbix/manifest.json b/homeassistant/components/zabbix/manifest.json index d1823051636b8..9c7171bea4622 100644 --- a/homeassistant/components/zabbix/manifest.json +++ b/homeassistant/components/zabbix/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/zabbix", "iot_class": "local_polling", "loggers": ["pyzabbix"], + "quality_scale": "legacy", "requirements": ["py-zabbix==1.1.7"] } diff --git a/homeassistant/components/zengge/manifest.json b/homeassistant/components/zengge/manifest.json index 5a4525079da3b..03d989c5f3b7a 100644 --- a/homeassistant/components/zengge/manifest.json +++ b/homeassistant/components/zengge/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/zengge", "iot_class": "local_polling", "loggers": ["zengge"], + "quality_scale": "legacy", "requirements": ["bluepy==1.3.0", "zengge==0.2"] } diff --git a/homeassistant/components/zestimate/manifest.json b/homeassistant/components/zestimate/manifest.json index a881adf503da0..a787a9b1099d8 100644 --- a/homeassistant/components/zestimate/manifest.json +++ b/homeassistant/components/zestimate/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/zestimate", "iot_class": "cloud_polling", + "quality_scale": "legacy", "requirements": ["xmltodict==0.13.0"] } diff --git a/homeassistant/components/zhong_hong/manifest.json b/homeassistant/components/zhong_hong/manifest.json index 9da0e9ab72b79..3569466fb0a71 100644 --- a/homeassistant/components/zhong_hong/manifest.json +++ b/homeassistant/components/zhong_hong/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/zhong_hong", "iot_class": "local_push", "loggers": ["zhong_hong_hvac"], + "quality_scale": "legacy", "requirements": ["zhong-hong-hvac==1.0.13"] } diff --git a/homeassistant/components/ziggo_mediabox_xl/manifest.json b/homeassistant/components/ziggo_mediabox_xl/manifest.json index 81aac99e58d1d..1ae09c9927d95 100644 --- a/homeassistant/components/ziggo_mediabox_xl/manifest.json +++ b/homeassistant/components/ziggo_mediabox_xl/manifest.json @@ -4,5 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/ziggo_mediabox_xl", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["ziggo-mediabox-xl==1.1.0"] } diff --git a/homeassistant/components/zoneminder/manifest.json b/homeassistant/components/zoneminder/manifest.json index f441a8005550a..2501aba2cf13a 100644 --- a/homeassistant/components/zoneminder/manifest.json +++ b/homeassistant/components/zoneminder/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/zoneminder", "iot_class": "local_polling", "loggers": ["zoneminder"], + "quality_scale": "legacy", "requirements": ["zm-py==0.5.4"] } From 36bc564862b9dce67d3d6b181ff8c534738f9480 Mon Sep 17 00:00:00 2001 From: Federico D'Amico <48856240+FedDam@users.noreply.github.com> Date: Fri, 22 Nov 2024 19:35:31 +0100 Subject: [PATCH 0742/1070] Bump microBeesPy to 0.3.5 (#131034) --- homeassistant/components/microbees/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/microbees/manifest.json b/homeassistant/components/microbees/manifest.json index 7188a3496d231..be28bf881d2dc 100644 --- a/homeassistant/components/microbees/manifest.json +++ b/homeassistant/components/microbees/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/microbees", "iot_class": "cloud_polling", - "requirements": ["microBeesPy==0.3.3"] + "requirements": ["microBeesPy==0.3.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 75a2b0fee7e99..7d607c9f616ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1370,7 +1370,7 @@ mficlient==0.5.0 micloud==0.5 # homeassistant.components.microbees -microBeesPy==0.3.3 +microBeesPy==0.3.5 # homeassistant.components.mill mill-local==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 93ed9f5cea948..20da630f37c04 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1139,7 +1139,7 @@ mficlient==0.5.0 micloud==0.5 # homeassistant.components.microbees -microBeesPy==0.3.3 +microBeesPy==0.3.5 # homeassistant.components.mill mill-local==0.3.0 From 19183fcc6c864f68f500ba12ab3351c80740b779 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 22 Nov 2024 19:48:36 +0100 Subject: [PATCH 0743/1070] Record current IQS state for lamarzocco (#131084) Co-authored-by: Franck Nijhof --- .../components/lamarzocco/quality_scale.yaml | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 homeassistant/components/lamarzocco/quality_scale.yaml diff --git a/homeassistant/components/lamarzocco/quality_scale.yaml b/homeassistant/components/lamarzocco/quality_scale.yaml new file mode 100644 index 0000000000000..59417143c21f3 --- /dev/null +++ b/homeassistant/components/lamarzocco/quality_scale.yaml @@ -0,0 +1,87 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No explicit event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: | + Handled by coordinator. + parallel-updates: todo + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: todo + discovery: + status: done + comment: | + DHCP & Bluetooth discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Device type integration. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: exempt + comment: | + Device type integration. + + # Platinum + async-dependency: done + inject-websession: + status: done + comment: | + Uses `httpx` session. + strict-typing: done From e690c1026ce1802cf765de2ea1e9aeaa8663d517 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 22 Nov 2024 19:49:04 +0100 Subject: [PATCH 0744/1070] Record current IQS state for HomeWizard Energy (#131082) --- .../components/homewizard/quality_scale.yaml | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 homeassistant/components/homewizard/quality_scale.yaml diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml new file mode 100644 index 0000000000000..c7bac5434deb5 --- /dev/null +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -0,0 +1,93 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: todo + comment: | + The data_descriptions are missing. + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have an options flow. + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: todo + comment: | + The integration doesn't update the device info based on DHCP discovery + of known existing devices. + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + The integration connects to a single device per configuration entry. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: todo + comment: | + While the integration provides some of the exception translations, the + translation for the error raised in the update error of the coordinator + is missing. + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not raise any repairable issues. + stale-devices: + status: exempt + comment: | + This integration connect to a single device per configuration entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done From ecb945e08cd7d74845f497ec2b3bfc8cdfb51e1c Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Fri, 22 Nov 2024 20:07:11 +0100 Subject: [PATCH 0745/1070] Bump mozart-api to 4.1.1.116.3 (#131269) --- .../components/bang_olufsen/manifest.json | 2 +- .../components/bang_olufsen/websocket.py | 17 ++++++++++------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/bang_olufsen/manifest.json b/homeassistant/components/bang_olufsen/manifest.json index b4a92d4da250f..1565c98e979ac 100644 --- a/homeassistant/components/bang_olufsen/manifest.json +++ b/homeassistant/components/bang_olufsen/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/bang_olufsen", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mozart-api==4.1.1.116.0"], + "requirements": ["mozart-api==4.1.1.116.3"], "zeroconf": ["_bangolufsen._tcp.local."] } diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 913f7cb32414a..ff3ad849e927e 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -15,7 +15,7 @@ VolumeState, WebsocketNotificationTag, ) -from mozart_api.mozart_client import MozartClient +from mozart_api.mozart_client import BaseWebSocketResponse, MozartClient from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -202,12 +202,15 @@ async def on_software_update_state(self, notification: SoftwareUpdateState) -> N sw_version=software_status.software_version, ) - def on_all_notifications_raw(self, notification: dict) -> None: + def on_all_notifications_raw(self, notification: BaseWebSocketResponse) -> None: """Receive all notifications.""" - # Add the device_id and serial_number to the notification - notification["device_id"] = self._device.id - notification["serial_number"] = int(self._unique_id) - _LOGGER.debug("%s", notification) - self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, notification) + self.hass.bus.async_fire( + BANG_OLUFSEN_WEBSOCKET_EVENT, + { + "device_id": self._device.id, + "serial_number": int(self._unique_id), + **notification, + }, + ) diff --git a/requirements_all.txt b/requirements_all.txt index 7d607c9f616ac..fe6cdaa78bb32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1403,7 +1403,7 @@ motionblindsble==0.1.2 motioneye-client==0.3.14 # homeassistant.components.bang_olufsen -mozart-api==4.1.1.116.0 +mozart-api==4.1.1.116.3 # homeassistant.components.mullvad mullvad-api==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20da630f37c04..007ab7a3fa393 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1172,7 +1172,7 @@ motionblindsble==0.1.2 motioneye-client==0.3.14 # homeassistant.components.bang_olufsen -mozart-api==4.1.1.116.0 +mozart-api==4.1.1.116.3 # homeassistant.components.mullvad mullvad-api==1.0.0 From 49eeb2d99e4ab45f0bf0aa1f5004b6d0b888ad2d Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 22 Nov 2024 20:09:20 +0100 Subject: [PATCH 0746/1070] Add test foundation to Music Assistant integration (#129534) --- .../music_assistant/media_player.py | 6 +- tests/components/music_assistant/common.py | 91 +++++ tests/components/music_assistant/conftest.py | 52 ++- .../fixtures/player_queues.json | 328 ++++++++++++++++++ .../music_assistant/fixtures/players.json | 149 ++++++++ .../snapshots/test_media_player.ambr | 190 ++++++++++ .../music_assistant/test_media_player.py | 263 ++++++++++++++ 7 files changed, 1076 insertions(+), 3 deletions(-) create mode 100644 tests/components/music_assistant/common.py create mode 100644 tests/components/music_assistant/fixtures/player_queues.json create mode 100644 tests/components/music_assistant/fixtures/players.json create mode 100644 tests/components/music_assistant/snapshots/test_media_player.ambr create mode 100644 tests/components/music_assistant/test_media_player.py diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index f0f3675ee32ce..d898322c29304 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -152,6 +152,8 @@ def __init__(self, mass: MusicAssistantClient, player_id: str) -> None: self._attr_supported_features = SUPPORTED_FEATURES if PlayerFeature.SYNC in self.player.supported_features: self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING + if PlayerFeature.VOLUME_MUTE in self.player.supported_features: + self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._prev_time: float = 0 @@ -219,7 +221,9 @@ async def async_on_update(self) -> None: ) ) ] - self._attr_group_members = group_members_entity_ids + # NOTE: we sort the group_members for now, + # until the MA API returns them sorted (group_childs is now a set) + self._attr_group_members = sorted(group_members_entity_ids) self._attr_volume_level = ( player.volume_level / 100 if player.volume_level is not None else None ) diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py new file mode 100644 index 0000000000000..307a928f2cc6e --- /dev/null +++ b/tests/components/music_assistant/common.py @@ -0,0 +1,91 @@ +"""Provide common test tools.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +from music_assistant_models.enums import EventType +from music_assistant_models.player import Player +from music_assistant_models.player_queue import PlayerQueue +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, load_json_object_fixture + +MASS_DOMAIN = "music_assistant" +MOCK_URL = "http://mock-music_assistant-server-url" + + +def load_and_parse_fixture(fixture: str) -> dict[str, Any]: + """Load and parse a fixture.""" + data = load_json_object_fixture(f"music_assistant/{fixture}.json") + return data[fixture] + + +async def setup_integration_from_fixtures( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Set up MusicAssistant integration with fixture data.""" + players = create_players_from_fixture() + music_assistant_client.players._players = {x.player_id: x for x in players} + player_queues = create_player_queues_from_fixture() + music_assistant_client.player_queues._queues = { + x.queue_id: x for x in player_queues + } + config_entry = MockConfigEntry( + domain=MASS_DOMAIN, + data={"url": MOCK_URL}, + unique_id=music_assistant_client.server_info.server_id, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +def create_players_from_fixture() -> list[Player]: + """Create MA Players from fixture.""" + fixture_data = load_and_parse_fixture("players") + return [Player.from_dict(player_data) for player_data in fixture_data] + + +def create_player_queues_from_fixture() -> list[Player]: + """Create MA PlayerQueues from fixture.""" + fixture_data = load_and_parse_fixture("player_queues") + return [ + PlayerQueue.from_dict(player_queue_data) for player_queue_data in fixture_data + ] + + +async def trigger_subscription_callback( + hass: HomeAssistant, + client: MagicMock, + event: EventType = EventType.PLAYER_UPDATED, + data: Any = None, +) -> None: + """Trigger a subscription callback.""" + # trigger callback on all subscribers + for sub in client.subscribe_events.call_args_list: + callback = sub.kwargs["callback"] + event_filter = sub.kwargs.get("event_filter") + if event_filter in (None, event): + callback(event, data) + await hass.async_block_till_done() + + +def snapshot_music_assistant_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + platform: Platform, +) -> None: + """Snapshot MusicAssistant entities.""" + entities = hass.states.async_all(platform) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/music_assistant/conftest.py b/tests/components/music_assistant/conftest.py index b03a56ab4a6ea..2df43defe62c3 100644 --- a/tests/components/music_assistant/conftest.py +++ b/tests/components/music_assistant/conftest.py @@ -1,8 +1,12 @@ """Music Assistant test fixtures.""" -from collections.abc import Generator -from unittest.mock import patch +import asyncio +from collections.abc import AsyncGenerator, Generator +from unittest.mock import MagicMock, patch +from music_assistant_client.music import Music +from music_assistant_client.player_queues import PlayerQueues +from music_assistant_client.players import Players from music_assistant_models.api import ServerInfoMessage import pytest @@ -11,6 +15,8 @@ from tests.common import AsyncMock, MockConfigEntry, load_fixture +MOCK_SERVER_ID = "1234" + @pytest.fixture def mock_get_server_info() -> Generator[AsyncMock]: @@ -24,6 +30,48 @@ def mock_get_server_info() -> Generator[AsyncMock]: yield mock_get_server_info +@pytest.fixture(name="music_assistant_client") +async def music_assistant_client_fixture() -> AsyncGenerator[MagicMock]: + """Fixture for a Music Assistant client.""" + with patch( + "homeassistant.components.music_assistant.MusicAssistantClient", autospec=True + ) as client_class: + client = client_class.return_value + + async def connect() -> None: + """Mock connect.""" + await asyncio.sleep(0) + + async def listen(init_ready: asyncio.Event | None) -> None: + """Mock listen.""" + if init_ready is not None: + init_ready.set() + listen_block = asyncio.Event() + await listen_block.wait() + pytest.fail("Listen was not cancelled!") + + client.connect = AsyncMock(side_effect=connect) + client.start_listening = AsyncMock(side_effect=listen) + client.server_info = ServerInfoMessage( + server_id=MOCK_SERVER_ID, + server_version="0.0.0", + schema_version=1, + min_supported_schema_version=1, + base_url="http://localhost:8095", + homeassistant_addon=False, + onboard_done=True, + ) + client.connection = MagicMock() + client.connection.connected = True + client.players = Players(client) + client.player_queues = PlayerQueues(client) + client.music = Music(client) + client.server_url = client.server_info.base_url + client.get_media_item_image_url = MagicMock(return_value=None) + + yield client + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" diff --git a/tests/components/music_assistant/fixtures/player_queues.json b/tests/components/music_assistant/fixtures/player_queues.json new file mode 100644 index 0000000000000..5251560365c9e --- /dev/null +++ b/tests/components/music_assistant/fixtures/player_queues.json @@ -0,0 +1,328 @@ +{ + "player_queues": [ + { + "queue_id": "00:00:00:00:00:01", + "active": false, + "display_name": "Test Player 1", + "available": true, + "items": 0, + "shuffle_enabled": false, + "repeat_mode": "off", + "dont_stop_the_music_enabled": false, + "current_index": null, + "index_in_buffer": null, + "elapsed_time": 0, + "elapsed_time_last_updated": 1730118302.163217, + "state": "idle", + "current_item": null, + "next_item": null, + "radio_source": [], + "flow_mode": false, + "resume_pos": 0 + }, + { + "queue_id": "00:00:00:00:00:02", + "active": false, + "display_name": "My Super Test Player 2", + "available": true, + "items": 0, + "shuffle_enabled": false, + "repeat_mode": "off", + "dont_stop_the_music_enabled": false, + "current_index": null, + "index_in_buffer": null, + "elapsed_time": 0, + "elapsed_time_last_updated": 0, + "state": "idle", + "current_item": null, + "next_item": null, + "radio_source": [], + "flow_mode": false, + "resume_pos": 0 + }, + { + "queue_id": "test_group_player_1", + "active": true, + "display_name": "Test Group Player 1", + "available": true, + "items": 1094, + "shuffle_enabled": true, + "repeat_mode": "all", + "dont_stop_the_music_enabled": true, + "current_index": 26, + "index_in_buffer": 26, + "elapsed_time": 232.08810877799988, + "elapsed_time_last_updated": 1730313109.5659513, + "state": "playing", + "current_item": { + "queue_id": "test_group_player_1", + "queue_item_id": "5d95dc5be77e4f7eb4939f62cfef527b", + "name": "Guns N' Roses - November Rain", + "duration": 536, + "sort_index": 2109, + "streamdetails": { + "provider": "spotify", + "item_id": "3YRCqOhFifThpSRFJ1VWFM", + "audio_format": { + "content_type": "ogg", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "ogg", + "bit_rate": 0 + }, + "media_type": "track", + "stream_type": "custom", + "stream_title": null, + "duration": 536, + "size": null, + "can_seek": true, + "loudness": -12.47, + "loudness_album": null, + "prefer_album_loudness": false, + "volume_normalization_mode": "fallback_dynamic", + "target_loudness": -17, + "strip_silence_begin": false, + "strip_silence_end": true, + "stream_error": null + }, + "media_item": { + "item_id": "3YRCqOhFifThpSRFJ1VWFM", + "provider": "spotify", + "name": "November Rain", + "version": "", + "sort_name": "november rain", + "uri": "spotify://track/3YRCqOhFifThpSRFJ1VWFM", + "external_ids": [["isrc", "USGF19141510"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "3YRCqOhFifThpSRFJ1VWFM", + "provider_domain": "spotify", + "provider_instance": "spotify", + "available": true, + "audio_format": { + "content_type": "ogg", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "ogg", + "bit_rate": 320 + }, + "url": "https://open.spotify.com/track/3YRCqOhFifThpSRFJ1VWFM", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://i.scdn.co/image/ab67616d0000b273e44963b8bb127552ac761873", + "provider": "spotify", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "chapters": null, + "performers": null, + "preview": "https://p.scdn.co/mp3-preview/98deb9c370bbaa350be058b3470fbe3bc1e28d9d?cid=2eb96f9b37494be1824999d58028a305", + "popularity": 77, + "last_refresh": null + }, + "favorite": false, + "position": 1372, + "duration": 536, + "artists": [ + { + "item_id": "3qm84nBOXUEQ2vnTfUTTFC", + "provider": "spotify", + "name": "Guns N' Roses", + "version": "", + "sort_name": "guns n' roses", + "uri": "spotify://artist/3qm84nBOXUEQ2vnTfUTTFC", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": "0CxPbTRARqKUYighiEY9Sz", + "provider": "spotify", + "name": "Use Your Illusion I", + "version": "", + "sort_name": "use your illusion i", + "uri": "spotify://album/0CxPbTRARqKUYighiEY9Sz", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://i.scdn.co/image/ab67616d0000b273e44963b8bb127552ac761873", + "provider": "spotify", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 10 + }, + "image": { + "type": "thumb", + "path": "https://i.scdn.co/image/ab67616d0000b273e44963b8bb127552ac761873", + "provider": "spotify", + "remotely_accessible": true + }, + "index": 0 + }, + "next_item": { + "queue_id": "test_group_player_1", + "queue_item_id": "990ae8f29cdf4fb588d679b115621f55", + "name": "The Stranglers - Golden Brown", + "duration": 207, + "sort_index": 1138, + "streamdetails": { + "provider": "qobuz", + "item_id": "1004735", + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "media_type": "track", + "stream_type": "http", + "stream_title": null, + "duration": 207, + "size": null, + "can_seek": true, + "loudness": -14.23, + "loudness_album": null, + "prefer_album_loudness": true, + "volume_normalization_mode": "fallback_dynamic", + "target_loudness": -17, + "strip_silence_begin": true, + "strip_silence_end": true, + "stream_error": null + }, + "media_item": { + "item_id": "1004735", + "provider": "qobuz", + "name": "Golden Brown", + "version": "", + "sort_name": "golden brown", + "uri": "qobuz://track/1004735", + "external_ids": [["isrc", "GBAYE8100053"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "1004735", + "provider_domain": "qobuz", + "provider_instance": "qobuz", + "available": true, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://open.qobuz.com/track/1004735", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "https://static.qobuz.com/images/covers/59/88/0724353468859_600.jpg", + "provider": "qobuz", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "© 2001 Parlophone Records Ltd, a Warner Music Group Company ℗ 1981 Parlophone Records Ltd, a Warner Music Group Company", + "lyrics": null, + "label": null, + "links": null, + "chapters": null, + "performers": [ + "Dave Greenfield, Composer, Producer, Keyboards, Vocals", + "Jean", + "Hugh Cornwell, Composer, Producer, Guitar, Vocals", + "Jean Jacques Burnel, Producer, Bass Guitar, Vocals", + "Jet Black, Composer, Producer, Drums, Percussion", + "Jacques Burnell, Composer", + "The Stranglers, MainArtist" + ], + "preview": null, + "popularity": null, + "last_refresh": null + }, + "favorite": false, + "position": 183, + "duration": 207, + "artists": [ + { + "item_id": "26779", + "provider": "qobuz", + "name": "The Stranglers", + "version": "", + "sort_name": "stranglers, the", + "uri": "qobuz://artist/26779", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": "0724353468859", + "provider": "qobuz", + "name": "La Folie", + "version": "", + "sort_name": "folie, la", + "uri": "qobuz://album/0724353468859", + "external_ids": [["barcode", "0724353468859"]], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://static.qobuz.com/images/covers/59/88/0724353468859_600.jpg", + "provider": "qobuz", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 9 + }, + "image": { + "type": "thumb", + "path": "https://static.qobuz.com/images/covers/59/88/0724353468859_600.jpg", + "provider": "qobuz", + "remotely_accessible": true + }, + "index": 0 + }, + "radio_source": [], + "flow_mode": false, + "resume_pos": 0 + } + ] +} diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json new file mode 100644 index 0000000000000..b7ff304a7ee1d --- /dev/null +++ b/tests/components/music_assistant/fixtures/players.json @@ -0,0 +1,149 @@ +{ + "players": [ + { + "player_id": "00:00:00:00:00:01", + "provider": "test", + "type": "player", + "name": "Test Player 1", + "available": true, + "powered": false, + "device_info": { + "model": "Test Model", + "address": "192.168.1.1", + "manufacturer": "Test Manufacturer" + }, + "supported_features": [ + "volume_set", + "volume_mute", + "pause", + "sync", + "power", + "enqueue" + ], + "elapsed_time": 0, + "elapsed_time_last_updated": 0, + "state": "idle", + "volume_level": 20, + "volume_muted": false, + "group_childs": [], + "active_source": "00:00:00:00:00:01", + "active_group": null, + "current_media": null, + "synced_to": null, + "enabled_by_default": true, + "needs_poll": false, + "poll_interval": 30, + "enabled": true, + "hidden": false, + "icon": "mdi-speaker", + "group_volume": 20, + "display_name": "Test Player 1", + "extra_data": {}, + "announcement_in_progress": false + }, + { + "player_id": "00:00:00:00:00:02", + "provider": "test", + "type": "player", + "name": "Test Player 2", + "available": true, + "powered": true, + "device_info": { + "model": "Test Model", + "address": "192.168.1.2", + "manufacturer": "Test Manufacturer" + }, + "supported_features": [ + "volume_set", + "volume_mute", + "pause", + "sync", + "power", + "enqueue" + ], + "elapsed_time": 0, + "elapsed_time_last_updated": 0, + "state": "playing", + "volume_level": 20, + "volume_muted": false, + "group_childs": [], + "active_source": "spotify", + "active_group": null, + "current_media": { + "uri": "spotify://track/5d95dc5be77e4f7eb4939f62cfef527b", + "media_type": "track", + "title": "Test Track", + "artist": "Test Artist", + "album": "Test Album", + "image_url": null, + "duration": 300, + "queue_id": null, + "queue_item_id": null, + "custom_data": null + }, + "synced_to": null, + "enabled_by_default": true, + "needs_poll": false, + "poll_interval": 30, + "enabled": true, + "hidden": false, + "icon": "mdi-speaker", + "group_volume": 20, + "display_name": "My Super Test Player 2", + "extra_data": {}, + "announcement_in_progress": false + }, + { + "player_id": "test_group_player_1", + "provider": "player_group", + "type": "group", + "name": "Test Group Player 1", + "available": true, + "powered": true, + "device_info": { + "model": "Sync Group", + "address": "", + "manufacturer": "Test" + }, + "supported_features": [ + "volume_set", + "volume_mute", + "pause", + "sync", + "power", + "enqueue" + ], + "elapsed_time": 0.0, + "elapsed_time_last_updated": 1730315437.9904983, + "state": "idle", + "volume_level": 6, + "volume_muted": false, + "group_childs": ["00:00:00:00:00:01", "00:00:00:00:00:02"], + "active_source": "test_group_player_1", + "active_group": null, + "current_media": { + "uri": "http://192.168.1.1:8097/single/test_group_player_1/5d95dc5be77e4f7eb4939f62cfef527b.flac?ts=1730313038", + "media_type": "unknown", + "title": null, + "artist": null, + "album": null, + "image_url": null, + "duration": null, + "queue_id": "test_group_player_1", + "queue_item_id": "5d95dc5be77e4f7eb4939f62cfef527b", + "custom_data": null + }, + "synced_to": null, + "enabled_by_default": true, + "needs_poll": true, + "poll_interval": 30, + "enabled": true, + "hidden": false, + "icon": "mdi-speaker-multiple", + "group_volume": 6, + "display_name": "Test Group Player 1", + "extra_data": {}, + "announcement_in_progress": false + } + ] +} diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr new file mode 100644 index 0000000000000..e3d7a4a0cbc24 --- /dev/null +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -0,0 +1,190 @@ +# serializer version: 1 +# name: test_media_player[media_player.my_super_test_player_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.my_super_test_player_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:speaker', + 'original_name': None, + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:02', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player[media_player.my_super_test_player_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'active_queue': None, + 'app_id': 'spotify', + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'My Super Test Player 2', + 'group_members': list([ + ]), + 'icon': 'mdi:speaker', + 'is_volume_muted': False, + 'mass_player_type': 'player', + 'media_album_name': 'Test Album', + 'media_artist': 'Test Artist', + 'media_content_id': 'spotify://track/5d95dc5be77e4f7eb4939f62cfef527b', + 'media_content_type': , + 'media_duration': 300, + 'media_position': 0, + 'media_title': 'Test Track', + 'supported_features': , + 'volume_level': 0.2, + }), + 'context': , + 'entity_id': 'media_player.my_super_test_player_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_media_player[media_player.test_group_player_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_group_player_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:speaker-multiple', + 'original_name': None, + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'test_group_player_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player[media_player.test_group_player_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'active_queue': 'test_group_player_1', + 'app_id': 'music_assistant', + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Test Group Player 1', + 'group_members': list([ + 'media_player.my_super_test_player_2', + 'media_player.test_player_1', + ]), + 'icon': 'mdi:speaker-multiple', + 'is_volume_muted': False, + 'mass_player_type': 'group', + 'media_album_name': 'Use Your Illusion I', + 'media_artist': "Guns N' Roses", + 'media_content_id': 'spotify://track/3YRCqOhFifThpSRFJ1VWFM', + 'media_content_type': , + 'media_duration': 536, + 'media_position': 232, + 'media_position_updated_at': datetime.datetime(2024, 10, 30, 18, 31, 49, 565951, tzinfo=datetime.timezone.utc), + 'media_title': 'November Rain', + 'repeat': 'all', + 'shuffle': True, + 'supported_features': , + 'volume_level': 0.06, + }), + 'context': , + 'entity_id': 'media_player.test_group_player_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_media_player[media_player.test_player_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_player_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:speaker', + 'original_name': None, + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:01', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player[media_player.test_player_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'active_queue': '00:00:00:00:00:01', + 'device_class': 'speaker', + 'friendly_name': 'Test Player 1', + 'group_members': list([ + ]), + 'icon': 'mdi:speaker', + 'mass_player_type': 'player', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.test_player_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py new file mode 100644 index 0000000000000..2054ce1e6aa73 --- /dev/null +++ b/tests/components/music_assistant/test_media_player.py @@ -0,0 +1,263 @@ +"""Test Music Assistant media player entities.""" + +from unittest.mock import MagicMock, call + +from syrupy import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SEEK_POSITION, + ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_CLEAR_PLAYLIST, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_REPEAT_SET, + SERVICE_SHUFFLE_SET, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities + + +async def test_media_player( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + music_assistant_client: MagicMock, +) -> None: + """Test media player.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + snapshot_music_assistant_entities( + hass, entity_registry, snapshot, Platform.MEDIA_PLAYER + ) + + +async def test_media_player_basic_actions( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity basic actions (play/stop/pause etc.).""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + for action, cmd in ( + (SERVICE_MEDIA_PLAY, "play"), + (SERVICE_MEDIA_PAUSE, "pause"), + (SERVICE_MEDIA_STOP, "stop"), + (SERVICE_MEDIA_PREVIOUS_TRACK, "previous"), + (SERVICE_MEDIA_NEXT_TRACK, "next"), + (SERVICE_VOLUME_UP, "volume_up"), + (SERVICE_VOLUME_DOWN, "volume_down"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + action, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + f"players/cmd/{cmd}", player_id=mass_player_id + ) + music_assistant_client.send_command.reset_mock() + + +async def test_media_player_seek_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity seek action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + "media_seek", + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_SEEK_POSITION: 100, + }, + blocking=True, + ) + + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/seek", player_id=mass_player_id, position=100 + ) + + +async def test_media_player_volume_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity volume action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_VOLUME_LEVEL: 0.5, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/volume_set", player_id=mass_player_id, volume_level=50 + ) + + +async def test_media_player_volume_mute_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity volume_mute action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_VOLUME_MUTED: True, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/volume_mute", player_id=mass_player_id, muted=True + ) + + +async def test_media_player_turn_on_off_actions( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity turn_on/turn_off actions.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + for action, pwr in ( + (SERVICE_TURN_ON, True), + (SERVICE_TURN_OFF, False), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + action, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/power", player_id=mass_player_id, powered=pwr + ) + music_assistant_client.send_command.reset_mock() + + +async def test_media_player_shuffle_set_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity shuffle_set action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SHUFFLE_SET, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_SHUFFLE: True, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/shuffle", queue_id=mass_player_id, shuffle_enabled=True + ) + + +async def test_media_player_repeat_set_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity repeat_set action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_REPEAT: "one", + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/repeat", queue_id=mass_player_id, repeat_mode="one" + ) + + +async def test_media_player_clear_playlist_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity clear_playlist action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_CLEAR_PLAYLIST, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/clear", queue_id=mass_player_id + ) From 02f16ff568ef722743c36f27dd580aeca7b23c0a Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Sat, 23 Nov 2024 04:12:01 +0900 Subject: [PATCH 0747/1070] Add config_flow's seperated reaseon and more debug information (#131131) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/__init__.py | 1 + homeassistant/components/lg_thinq/config_flow.py | 14 +++++++++++--- homeassistant/components/lg_thinq/coordinator.py | 6 +++++- homeassistant/components/lg_thinq/entity.py | 2 +- homeassistant/components/lg_thinq/mqtt.py | 7 ++++++- homeassistant/components/lg_thinq/strings.json | 6 ++++++ tests/components/lg_thinq/test_config_flow.py | 2 +- 7 files changed, 31 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py index a8d3fe175efca..657524f0ef567 100644 --- a/homeassistant/components/lg_thinq/__init__.py +++ b/homeassistant/components/lg_thinq/__init__.py @@ -95,6 +95,7 @@ async def async_setup_coordinators( raise ConfigEntryNotReady(exc.message) from exc if not bridge_list: + _LOGGER.warning("No devices registered with the correct profile") return # Setup coordinator per device. diff --git a/homeassistant/components/lg_thinq/config_flow.py b/homeassistant/components/lg_thinq/config_flow.py index cdb419166880d..3bbcf3cd226d4 100644 --- a/homeassistant/components/lg_thinq/config_flow.py +++ b/homeassistant/components/lg_thinq/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import uuid -from thinqconnect import ThinQApi, ThinQAPIException +from thinqconnect import ThinQApi, ThinQAPIErrorCodes, ThinQAPIException from thinqconnect.country import Country import voluptuous as vol @@ -26,6 +26,13 @@ ) SUPPORTED_COUNTRIES = [country.value for country in Country] +THINQ_ERRORS = { + ThinQAPIErrorCodes.INVALID_TOKEN: "invalid_token", + ThinQAPIErrorCodes.NOT_ACCEPTABLE_TERMS: "not_acceptable_terms", + ThinQAPIErrorCodes.NOT_ALLOWED_API_AGAIN: "not_allowed_api_again", + ThinQAPIErrorCodes.NOT_SUPPORTED_COUNTRY: "not_supported_country", + ThinQAPIErrorCodes.EXCEEDED_API_CALLS: "exceeded_api_calls", +} _LOGGER = logging.getLogger(__name__) @@ -83,8 +90,9 @@ async def async_step_user( try: return await self._validate_and_create_entry(access_token, country_code) - except ThinQAPIException: - errors["base"] = "token_unauthorized" + except ThinQAPIException as exc: + errors["base"] = THINQ_ERRORS.get(exc.code, "token_unauthorized") + _LOGGER.error("Failed to validate access_token %s", exc) return self.async_show_form( step_id="user", diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py index 0ba859b1228fc..9f317dc21d969 100644 --- a/homeassistant/components/lg_thinq/coordinator.py +++ b/homeassistant/components/lg_thinq/coordinator.py @@ -77,5 +77,9 @@ async def async_setup_device_coordinator( coordinator = DeviceDataUpdateCoordinator(hass, ha_bridge) await coordinator.async_refresh() - _LOGGER.debug("Setup device's coordinator: %s", coordinator.device_name) + _LOGGER.debug( + "Setup device's coordinator: %s, model:%s", + coordinator.device_name, + coordinator.api.device.model_name, + ) return coordinator diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py index f31b535dcafbf..7856506559b1f 100644 --- a/homeassistant/components/lg_thinq/entity.py +++ b/homeassistant/components/lg_thinq/entity.py @@ -51,7 +51,7 @@ def __init__( self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, coordinator.unique_id)}, manufacturer=COMPANY, - model=coordinator.api.device.model_name, + model=f"{coordinator.api.device.model_name} ({self.coordinator.api.device.device_type})", name=coordinator.device_name, ) self._attr_unique_id = f"{coordinator.unique_id}_{self.property_id}" diff --git a/homeassistant/components/lg_thinq/mqtt.py b/homeassistant/components/lg_thinq/mqtt.py index 30d1302e458a9..8759869aad30b 100644 --- a/homeassistant/components/lg_thinq/mqtt.py +++ b/homeassistant/components/lg_thinq/mqtt.py @@ -167,7 +167,6 @@ def on_message_received( async def async_handle_device_event(self, message: dict) -> None: """Handle received mqtt message.""" - _LOGGER.debug("async_handle_device_event: message=%s", message) unique_id = ( f"{message["deviceId"]}_{list(message["report"].keys())[0]}" if message["deviceType"] == DeviceType.WASHTOWER @@ -178,6 +177,12 @@ async def async_handle_device_event(self, message: dict) -> None: _LOGGER.error("Failed to handle device event: No device") return + _LOGGER.debug( + "async_handle_device_event: %s, model:%s, message=%s", + coordinator.device_name, + coordinator.api.device.model_name, + message, + ) push_type = message.get("pushType") if push_type == DEVICE_STATUS_MESSAGE: diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 277e3db3df018..a776dde2054b3 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -5,6 +5,12 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" }, "error": { + "invalid_token": "The token is not valid.", + "not_acceptable_terms": "The service terms are not accepted.", + "not_allowed_api_again": "The user does NOT have permission on the API call.", + "not_supported_country": "The country is not supported.", + "exceeded_api_calls": "The number of API calls has been exceeded.", + "exceeded_user_api_calls": "The number of User API calls has been exceeded.", "token_unauthorized": "The token is invalid or unauthorized." }, "step": { diff --git a/tests/components/lg_thinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py index e7ee632810eef..8c5afb4dac7dc 100644 --- a/tests/components/lg_thinq/test_config_flow.py +++ b/tests/components/lg_thinq/test_config_flow.py @@ -50,7 +50,7 @@ async def test_config_flow_invalid_pat( data={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "token_unauthorized"} + assert result["errors"] mock_invalid_thinq_api.async_get_device_list.assert_called_once() From ec127fb61ec326517ad35729a8d2d1ce027c846b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 22 Nov 2024 21:07:50 +0100 Subject: [PATCH 0748/1070] Clean up hassfest, fix CI (#131305) --- script/hassfest/quality_scale.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 8dab8f3b8acf0..cb5e0d3d202e6 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -470,7 +470,6 @@ "homekit_controller", "homematic", "homematicip_cloud", - "homewizard", "homeworks", "honeywell", "horizon", @@ -557,7 +556,6 @@ "kwb", "lacrosse", "lacrosse_view", - "lamarzocco", "lametric", "landisgyr_heat_meter", "lannouncer", From 8f9095ba67e6a8c29608b1d0d2b1e35648b5d168 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 22 Nov 2024 21:20:39 +0100 Subject: [PATCH 0749/1070] Record current IQS state for Elgato (#131077) --- .../components/elgato/quality_scale.yaml | 85 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/elgato/quality_scale.yaml diff --git a/homeassistant/components/elgato/quality_scale.yaml b/homeassistant/components/elgato/quality_scale.yaml new file mode 100644 index 0000000000000..2910bdb447304 --- /dev/null +++ b/homeassistant/components/elgato/quality_scale.yaml @@ -0,0 +1,85 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: todo + comment: | + The data_description for port is missing. + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: todo + comment: | + The integration doesn't update the device info based on DHCP discovery + of known existing devices. + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: todo + comment: | + Device are documented, but some are missing. For example, the their pro + strip is supported as well. + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration connects to a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not raise any repairable issues. + stale-devices: + status: exempt + comment: | + This integration connects to a single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index cb5e0d3d202e6..0282afe6c5016 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -307,7 +307,6 @@ "electrasmart", "electric_kiwi", "elevenlabs", - "elgato", "eliqonline", "elkm1", "elmax", From f47840d83c0b92466917b40c85a7bdf10c8e0464 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 22 Nov 2024 19:57:42 -0600 Subject: [PATCH 0750/1070] Cache intent recognition results (#131114) --- .../components/conversation/default_agent.py | 368 +++++++++++++----- .../conversation/test_default_agent.py | 107 +++++ 2 files changed, 381 insertions(+), 94 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index c6d394a136686..20720b90423ed 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio +from collections import OrderedDict from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass +from enum import Enum, auto import functools import logging from pathlib import Path @@ -102,6 +104,77 @@ class SentenceTriggerResult: matched_triggers: dict[int, RecognizeResult] +class IntentMatchingStage(Enum): + """Stages of intent matching.""" + + EXPOSED_ENTITIES_ONLY = auto() + """Match against exposed entities only.""" + + ALL_ENTITIES = auto() + """Match against all entities in Home Assistant.""" + + FUZZY = auto() + """Capture names that are not known to Home Assistant.""" + + +@dataclass(frozen=True) +class IntentCacheKey: + """Key for IntentCache.""" + + text: str + """User input text.""" + + language: str + """Language of text.""" + + device_id: str | None + """Device id from user input.""" + + +@dataclass(frozen=True) +class IntentCacheValue: + """Value for IntentCache.""" + + result: RecognizeResult | None + """Result of intent recognition.""" + + stage: IntentMatchingStage + """Stage where result was found.""" + + +class IntentCache: + """LRU cache for intent recognition results.""" + + def __init__(self, capacity: int) -> None: + """Initialize cache.""" + self.cache: OrderedDict[IntentCacheKey, IntentCacheValue] = OrderedDict() + self.capacity = capacity + + def get(self, key: IntentCacheKey) -> IntentCacheValue | None: + """Get value for cache or None.""" + if key not in self.cache: + return None + + # Move the key to the end to show it was recently used + self.cache.move_to_end(key) + return self.cache[key] + + def put(self, key: IntentCacheKey, value: IntentCacheValue) -> None: + """Put a value in the cache, evicting the least recently used item if necessary.""" + if key in self.cache: + # Update value and mark as recently used + self.cache.move_to_end(key) + elif len(self.cache) >= self.capacity: + # Evict the oldest item + self.cache.popitem(last=False) + + self.cache[key] = value + + def clear(self) -> None: + """Clear the cache.""" + self.cache.clear() + + def _get_language_variations(language: str) -> Iterable[str]: """Generate language codes with and without region.""" yield language @@ -160,6 +233,7 @@ def __init__( # intent -> [sentences] self._config_intents: dict[str, Any] = config_intents self._slot_lists: dict[str, SlotList] | None = None + self._all_entity_names: TextSlotList | None = None # Sentences that will trigger a callback (skipping intent recognition) self._trigger_sentences: list[TriggerData] = [] @@ -167,6 +241,9 @@ def __init__( self._unsub_clear_slot_list: list[Callable[[], None]] | None = None self._load_intents_lock = asyncio.Lock() + # LRU cache to avoid unnecessary intent matching + self._intent_cache = IntentCache(capacity=128) + @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" @@ -417,18 +494,200 @@ def _recognize( strict_intents_only: bool, ) -> RecognizeResult | None: """Search intents for a match to user input.""" - strict_result = self._recognize_strict( - user_input, lang_intents, slot_lists, intent_context, language + skip_exposed_match = False + + # Try cache first + cache_key = IntentCacheKey( + text=user_input.text, language=language, device_id=user_input.device_id ) + cache_value = self._intent_cache.get(cache_key) + if cache_value is not None: + if (cache_value.result is not None) and ( + cache_value.stage == IntentMatchingStage.EXPOSED_ENTITIES_ONLY + ): + _LOGGER.debug("Got cached result for exposed entities") + return cache_value.result + + # Continue with matching, but we know we won't succeed for exposed + # entities only. + skip_exposed_match = True - if strict_result is not None: - # Successful strict match - return strict_result + if not skip_exposed_match: + start_time = time.monotonic() + strict_result = self._recognize_strict( + user_input, lang_intents, slot_lists, intent_context, language + ) + _LOGGER.debug( + "Checked exposed entities in %s second(s)", + time.monotonic() - start_time, + ) + + # Update cache + self._intent_cache.put( + cache_key, + IntentCacheValue( + result=strict_result, + stage=IntentMatchingStage.EXPOSED_ENTITIES_ONLY, + ), + ) + + if strict_result is not None: + # Successful strict match with exposed entities + return strict_result if strict_intents_only: + # Don't try matching against all entities or doing a fuzzy match return None # Try again with all entities (including unexposed) + skip_all_entities_match = False + if cache_value is not None: + if (cache_value.result is not None) and ( + cache_value.stage == IntentMatchingStage.ALL_ENTITIES + ): + _LOGGER.debug("Got cached result for all entities") + return cache_value.result + + # Continue with matching, but we know we won't succeed for all + # entities. + skip_all_entities_match = True + + if not skip_all_entities_match: + all_entities_slot_lists = { + **slot_lists, + "name": self._get_all_entity_names(), + } + + start_time = time.monotonic() + strict_result = self._recognize_strict( + user_input, + lang_intents, + all_entities_slot_lists, + intent_context, + language, + ) + + _LOGGER.debug( + "Checked all entities in %s second(s)", time.monotonic() - start_time + ) + + # Update cache + self._intent_cache.put( + cache_key, + IntentCacheValue( + result=strict_result, stage=IntentMatchingStage.ALL_ENTITIES + ), + ) + + if strict_result is not None: + # Not a successful match, but useful for an error message. + # This should fail the intent handling phase (async_match_targets). + return strict_result + + # Try again with missing entities enabled + skip_fuzzy_match = False + if cache_value is not None: + if (cache_value.result is not None) and ( + cache_value.stage == IntentMatchingStage.FUZZY + ): + _LOGGER.debug("Got cached result for fuzzy match") + return cache_value.result + + # We know we won't succeed for fuzzy matching. + skip_fuzzy_match = True + + maybe_result: RecognizeResult | None = None + if not skip_fuzzy_match: + start_time = time.monotonic() + best_num_matched_entities = 0 + best_num_unmatched_entities = 0 + best_num_unmatched_ranges = 0 + for result in recognize_all( + user_input.text, + lang_intents.intents, + slot_lists=slot_lists, + intent_context=intent_context, + allow_unmatched_entities=True, + ): + if result.text_chunks_matched < 1: + # Skip results that don't match any literal text + continue + + # Don't count missing entities that couldn't be filled from context + num_matched_entities = 0 + for matched_entity in result.entities_list: + if matched_entity.name not in result.unmatched_entities: + num_matched_entities += 1 + + num_unmatched_entities = 0 + num_unmatched_ranges = 0 + for unmatched_entity in result.unmatched_entities_list: + if isinstance(unmatched_entity, UnmatchedTextEntity): + if unmatched_entity.text != MISSING_ENTITY: + num_unmatched_entities += 1 + elif isinstance(unmatched_entity, UnmatchedRangeEntity): + num_unmatched_ranges += 1 + num_unmatched_entities += 1 + else: + num_unmatched_entities += 1 + + if ( + (maybe_result is None) # first result + or (num_matched_entities > best_num_matched_entities) + or ( + # Fewer unmatched entities + (num_matched_entities == best_num_matched_entities) + and (num_unmatched_entities < best_num_unmatched_entities) + ) + or ( + # Prefer unmatched ranges + (num_matched_entities == best_num_matched_entities) + and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges > best_num_unmatched_ranges) + ) + or ( + # More literal text matched + (num_matched_entities == best_num_matched_entities) + and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges == best_num_unmatched_ranges) + and ( + result.text_chunks_matched + > maybe_result.text_chunks_matched + ) + ) + or ( + # Prefer match failures with entities + (result.text_chunks_matched == maybe_result.text_chunks_matched) + and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges == best_num_unmatched_ranges) + and ( + ("name" in result.entities) + or ("name" in result.unmatched_entities) + ) + ) + ): + maybe_result = result + best_num_matched_entities = num_matched_entities + best_num_unmatched_entities = num_unmatched_entities + best_num_unmatched_ranges = num_unmatched_ranges + + # Update cache + self._intent_cache.put( + cache_key, + IntentCacheValue(result=maybe_result, stage=IntentMatchingStage.FUZZY), + ) + + _LOGGER.debug( + "Did fuzzy match in %s second(s)", time.monotonic() - start_time + ) + + return maybe_result + + def _get_all_entity_names(self) -> TextSlotList: + """Get slot list with all entity names in Home Assistant.""" + if self._all_entity_names is not None: + return self._all_entity_names + entity_registry = er.async_get(self.hass) all_entity_names: list[tuple[str, str, dict[str, Any]]] = [] @@ -459,96 +718,10 @@ def _recognize( # Default name all_entity_names.append((state.name, state.name, context)) - slot_lists = { - **slot_lists, - "name": TextSlotList.from_tuples(all_entity_names, allow_template=False), - } - - strict_result = self._recognize_strict( - user_input, - lang_intents, - slot_lists, - intent_context, - language, + self._all_entity_names = TextSlotList.from_tuples( + all_entity_names, allow_template=False ) - - if strict_result is not None: - # Not a successful match, but useful for an error message. - # This should fail the intent handling phase (async_match_targets). - return strict_result - - # Try again with missing entities enabled - maybe_result: RecognizeResult | None = None - best_num_matched_entities = 0 - best_num_unmatched_entities = 0 - best_num_unmatched_ranges = 0 - for result in recognize_all( - user_input.text, - lang_intents.intents, - slot_lists=slot_lists, - intent_context=intent_context, - allow_unmatched_entities=True, - ): - if result.text_chunks_matched < 1: - # Skip results that don't match any literal text - continue - - # Don't count missing entities that couldn't be filled from context - num_matched_entities = 0 - for matched_entity in result.entities_list: - if matched_entity.name not in result.unmatched_entities: - num_matched_entities += 1 - - num_unmatched_entities = 0 - num_unmatched_ranges = 0 - for unmatched_entity in result.unmatched_entities_list: - if isinstance(unmatched_entity, UnmatchedTextEntity): - if unmatched_entity.text != MISSING_ENTITY: - num_unmatched_entities += 1 - elif isinstance(unmatched_entity, UnmatchedRangeEntity): - num_unmatched_ranges += 1 - num_unmatched_entities += 1 - else: - num_unmatched_entities += 1 - - if ( - (maybe_result is None) # first result - or (num_matched_entities > best_num_matched_entities) - or ( - # Fewer unmatched entities - (num_matched_entities == best_num_matched_entities) - and (num_unmatched_entities < best_num_unmatched_entities) - ) - or ( - # Prefer unmatched ranges - (num_matched_entities == best_num_matched_entities) - and (num_unmatched_entities == best_num_unmatched_entities) - and (num_unmatched_ranges > best_num_unmatched_ranges) - ) - or ( - # More literal text matched - (num_matched_entities == best_num_matched_entities) - and (num_unmatched_entities == best_num_unmatched_entities) - and (num_unmatched_ranges == best_num_unmatched_ranges) - and (result.text_chunks_matched > maybe_result.text_chunks_matched) - ) - or ( - # Prefer match failures with entities - (result.text_chunks_matched == maybe_result.text_chunks_matched) - and (num_unmatched_entities == best_num_unmatched_entities) - and (num_unmatched_ranges == best_num_unmatched_ranges) - and ( - ("name" in result.entities) - or ("name" in result.unmatched_entities) - ) - ) - ): - maybe_result = result - best_num_matched_entities = num_matched_entities - best_num_unmatched_entities = num_unmatched_entities - best_num_unmatched_ranges = num_unmatched_ranges - - return maybe_result + return self._all_entity_names def _recognize_strict( self, @@ -653,6 +826,9 @@ async def async_reload(self, language: str | None = None) -> None: self._lang_intents.pop(language, None) _LOGGER.debug("Cleared intents for language: %s", language) + # Intents have changed, so we must clear the cache + self._intent_cache.clear() + async def async_prepare(self, language: str | None = None) -> None: """Load intents for a language.""" if language is None: @@ -837,10 +1013,14 @@ def _async_clear_slot_list(self, event: core.Event[Any] | None = None) -> None: if self._unsub_clear_slot_list is None: return self._slot_lists = None + self._all_entity_names = None for unsub in self._unsub_clear_slot_list: unsub() self._unsub_clear_slot_list = None + # Slot lists have changed, so we must clear the cache + self._intent_cache.clear() + @core.callback def _make_slot_lists(self) -> dict[str, SlotList]: """Create slot lists with areas and entity names/aliases.""" diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 3c6b463670a37..1e5e284a24572 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -2833,3 +2833,110 @@ async def test_query_same_name_different_areas( assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER assert len(result.response.matched_states) == 1 assert result.response.matched_states[0].entity_id == kitchen_light.entity_id + + +@pytest.mark.usefixtures("init_components") +async def test_intent_cache_exposed(hass: HomeAssistant) -> None: + """Test that intent recognition results are cached for exposed entities.""" + agent = hass.data[DATA_DEFAULT_ENTITY] + assert isinstance(agent, default_agent.DefaultAgent) + + entity_id = "light.test_light" + hass.states.async_set(entity_id, "off") + expose_entity(hass, entity_id, True) + await hass.async_block_till_done() + + user_input = ConversationInput( + text="turn on test light", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + agent_id=None, + ) + result = await agent.async_recognize_intent(user_input) + assert result is not None + assert result.entities["name"].text == "test light" + + # Mark this result so we know it is from cache next time + mark = "_from_cache" + setattr(result, mark, True) + + # Should be from cache this time + result = await agent.async_recognize_intent(user_input) + assert result is not None + assert getattr(result, mark, None) is True + + # Unexposing clears the cache + expose_entity(hass, entity_id, False) + result = await agent.async_recognize_intent(user_input) + assert result is not None + assert getattr(result, mark, None) is None + + +@pytest.mark.usefixtures("init_components") +async def test_intent_cache_all_entities(hass: HomeAssistant) -> None: + """Test that intent recognition results are cached for all entities.""" + agent = hass.data[DATA_DEFAULT_ENTITY] + assert isinstance(agent, default_agent.DefaultAgent) + + entity_id = "light.test_light" + hass.states.async_set(entity_id, "off") + expose_entity(hass, entity_id, False) # not exposed + await hass.async_block_till_done() + + user_input = ConversationInput( + text="turn on test light", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + agent_id=None, + ) + result = await agent.async_recognize_intent(user_input) + assert result is not None + assert result.entities["name"].text == "test light" + + # Mark this result so we know it is from cache next time + mark = "_from_cache" + setattr(result, mark, True) + + # Should be from cache this time + result = await agent.async_recognize_intent(user_input) + assert result is not None + assert getattr(result, mark, None) is True + + # Adding a new entity clears the cache + hass.states.async_set("light.new_light", "off") + result = await agent.async_recognize_intent(user_input) + assert result is not None + assert getattr(result, mark, None) is None + + +@pytest.mark.usefixtures("init_components") +async def test_intent_cache_fuzzy(hass: HomeAssistant) -> None: + """Test that intent recognition results are cached for fuzzy matches.""" + agent = hass.data[DATA_DEFAULT_ENTITY] + assert isinstance(agent, default_agent.DefaultAgent) + + # There is no entity named test light + user_input = ConversationInput( + text="turn on test light", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + agent_id=None, + ) + result = await agent.async_recognize_intent(user_input) + assert result is not None + assert result.unmatched_entities["name"].text == "test light" + + # Mark this result so we know it is from cache next time + mark = "_from_cache" + setattr(result, mark, True) + + # Should be from cache this time + result = await agent.async_recognize_intent(user_input) + assert result is not None + assert getattr(result, mark, None) is True From fd11fc3b3e96a3959d5882fc7a45d42a2d5d32eb Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 23 Nov 2024 00:16:47 -0800 Subject: [PATCH 0751/1070] Update quality scale validation to sort output (#131324) --- script/hassfest/quality_scale.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 0282afe6c5016..f8c501697c0b8 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -24,7 +24,6 @@ "dependency-transparency", "docs-actions", "docs-high-level-description", - "docs-installation-parameters", "docs-installation-instructions", "docs-removal-instructions", "entity-event-setup", @@ -1356,7 +1355,9 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: break required_rules = set(RULES[scale]) if missing_rules := (required_rules - rules_met): - friendly_rule_str = "\n".join(f" {rule}: todo" for rule in missing_rules) + friendly_rule_str = "\n".join( + f" {rule}: todo" for rule in sorted(missing_rules) + ) integration.add_error( "quality_scale", f"Quality scale tier {scale.name.lower()} requires quality scale rules to be met:\n{friendly_rule_str}", From a88edc71303518bf7b21d49a20bcc0a7be28e68f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 23 Nov 2024 09:23:13 +0100 Subject: [PATCH 0752/1070] Add quality scale to airgradient (#131292) --- .../components/airgradient/quality_scale.yaml | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml index 0def17a287c6a..8d62e8515fcd5 100644 --- a/homeassistant/components/airgradient/quality_scale.yaml +++ b/homeassistant/components/airgradient/quality_scale.yaml @@ -1,2 +1,80 @@ rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration has a fixed single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: | + This integration has a fixed single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done From f3a4a3141247387a321750db38d846a060e20315 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sat, 23 Nov 2024 09:37:41 +0100 Subject: [PATCH 0753/1070] Record current IQS state for tedee (#131081) --- homeassistant/components/tedee/manifest.json | 1 + .../components/tedee/quality_scale.yaml | 86 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tedee/quality_scale.yaml diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 67871f4c434a1..bca51f08f935a 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -7,5 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", "loggers": ["aiotedee"], + "quality_scale": "platinum", "requirements": ["aiotedee==0.2.20"] } diff --git a/homeassistant/components/tedee/quality_scale.yaml b/homeassistant/components/tedee/quality_scale.yaml new file mode 100644 index 0000000000000..974c8f82ec916 --- /dev/null +++ b/homeassistant/components/tedee/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No explicit event subscriptions + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + Options flow not documented, doesn't have one + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: | + Handled by coordinator + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + No discovery + discovery: + status: exempt + comment: | + No discovery supported atm + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + Currently no repairs/issues + stale-devices: done + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index f8c501697c0b8..cb5b692bcce73 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1013,7 +1013,6 @@ "tcp", "technove", "ted5000", - "tedee", "telegram", "telegram_bot", "tellduslive", From 789cc7608a0f866b1637694fff4cacf7801d1a6d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 23 Nov 2024 09:37:51 +0100 Subject: [PATCH 0754/1070] Record current IQS state for inexogy (#131208) --- .../components/discovergy/quality_scale.yaml | 96 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/discovergy/quality_scale.yaml diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml new file mode 100644 index 0000000000000..3caeaa6bbe03b --- /dev/null +++ b/homeassistant/components/discovergy/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: todo + comment: | + The data_descriptions are missing. + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + The integration does not provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + The integration does not provide any additional options. + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration cannot be discovered, it is a connecting to a cloud service. + discovery: + status: exempt + comment: | + This integration cannot be discovered, it is a connecting to a cloud service. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + The integration connects to a single device per configuration entry. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: + status: exempt + comment: | + The integration does not provide any additional icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not raise any repairable issues. + stale-devices: + status: exempt + comment: | + This integration connect to a single device per configuration entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index cb5b692bcce73..04818fa4296e2 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -261,7 +261,6 @@ "directv", "discogs", "discord", - "discovergy", "dlib_face_detect", "dlib_face_identify", "dlink", From 630afeefdb57110c11e6eda11b2adb18e36ee73c Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sat, 23 Nov 2024 10:15:18 +0100 Subject: [PATCH 0755/1070] Record current IQS state for ViCare (#131202) --- .../components/vicare/quality_scale.yaml | 49 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/vicare/quality_scale.yaml diff --git a/homeassistant/components/vicare/quality_scale.yaml b/homeassistant/components/vicare/quality_scale.yaml new file mode 100644 index 0000000000000..436e046204f60 --- /dev/null +++ b/homeassistant/components/vicare/quality_scale.yaml @@ -0,0 +1,49 @@ +rules: + # Bronze + config-flow: + status: todo + comment: data_description is missing. + test-before-configure: done + unique-config-entry: + status: todo + comment: Uniqueness is not checked yet. + config-flow-test-coverage: done + runtime-data: + status: todo + comment: runtime_data is not used yet. + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: Entities of this integration does not explicitly subscribe to events. + dependency-transparency: done + action-setup: + status: todo + comment: service registered in climate async_setup_entry. + common-modules: + status: done + comment: No coordinator is used, data update is centrally handled by the library. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: + status: todo + comment: removal instructions missing + docs-actions: done + brands: done + # Silver + integration-owner: done + reauthentication-flow: done + config-entry-unloading: done + # Gold + devices: done + diagnostics: done + entity-category: done + dynamic-devices: done + entity-device-class: done + entity-translations: done + entity-disabled-by-default: done + repair-issues: + status: exempt + comment: This integration does not raise any repairable issues. diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 04818fa4296e2..137f3bdd20425 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1103,7 +1103,6 @@ "version", "vesync", "viaggiatreno", - "vicare", "vilfo", "vivotek", "vizio", From b7e13bbab0ad33a810fbab55eaca188db8a66952 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Sat, 23 Nov 2024 10:31:00 +0100 Subject: [PATCH 0756/1070] Record current IQS state for Autarco (#131090) Co-authored-by: Franck Nijhof --- .../components/autarco/quality_scale.yaml | 99 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/autarco/quality_scale.yaml diff --git a/homeassistant/components/autarco/quality_scale.yaml b/homeassistant/components/autarco/quality_scale.yaml new file mode 100644 index 0000000000000..f0eb477144758 --- /dev/null +++ b/homeassistant/components/autarco/quality_scale.yaml @@ -0,0 +1,99 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: + status: todo + comment: | + The entity.py file is not used in this integration. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: | + This integration only polls data using a coordinator. + Since the integration is read-only and poll-only (only provide sensor + data), there is no need to implement parallel updates. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration cannot be discovered, it is a connecting to a service + provider, which uses the users home address to get the data. + discovery: + status: exempt + comment: | + This integration cannot be discovered, it is a connecting to a service + provider, which uses the users home address to get the data. + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: | + This is an service, which doesn't integrate with any devices. + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have any entities that should disabled by default. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 137f3bdd20425..97ae06ed786df 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -150,7 +150,6 @@ "aurora", "aurora_abb_powerone", "aussie_broadband", - "autarco", "avea", "avion", "awair", From 5a06e237e3bf043ceeb8783f8479deb0aa821f3a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 23 Nov 2024 10:35:53 +0100 Subject: [PATCH 0757/1070] Add quality scale for MQTT (#131113) Co-authored-by: Franck Nijhof --- .../components/mqtt/quality_scale.yaml | 122 ++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/mqtt/quality_scale.yaml diff --git a/homeassistant/components/mqtt/quality_scale.yaml b/homeassistant/components/mqtt/quality_scale.yaml new file mode 100644 index 0000000000000..b3084f67da382 --- /dev/null +++ b/homeassistant/components/mqtt/quality_scale.yaml @@ -0,0 +1,122 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: done + comment: > + Entities are updated through dispatchers, and these are + cleaned up when the integration unloads. + entity-unique-id: + status: exempt + comment: > + This is user configurable, but not required. + It is required though when a user wants to use device based discovery. + has-entity-name: done + runtime-data: + status: exempt + comment: > + Runtime data is not used, as the mqtt entry data is only used to set up the + MQTT broker, this happens during integration setup, + and only one config entry is allowed. + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: + status: done + comment: | + Only supported for entities the user has assigned a unique_id. + action-exceptions: done + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: done + + # Gold + entity-translations: + status: exempt + comment: > + This is not possible because the integrations generates entities + based on a user supplied config or discovery. + entity-device-class: + status: done + comment: An entity device class can be configured by the user for each entity. + devices: + status: done + comment: > + A device context can be configured by the user for each entity. + It is not required though, except when using device based discovery. + entity-category: + status: done + comment: An entity category can be configured by the user for each entity. + entity-disabled-by-default: + status: done + comment: > + The user can configure this through YAML or discover + entities that are disabled by default. + discovery: + status: done + comment: > + When the Mosquitto MQTT broker add on is installed, + a MQTT config flow allows an automatic setup from its discovered settings. + stale-devices: + status: exempt + comment: > + This is is only supported for entities that are configured through MQTT discovery. + Users must manually cleanup stale entities that were set up though YAML. + diagnostics: done + exception-translations: done + icon-translations: + status: exempt + comment: > + This is not possible because the integrations generates entities + based on a user supplied config or discovery. + reconfiguration-flow: done + dynamic-devices: + status: done + comment: | + MQTT allow to dynamically create and remove devices through MQTT discovery. + discovery-update-info: + status: done + comment: > + If the Mosquitto broker add-on is used to set up MQTT from discovery, + and the broker add-on is re-installed, + MQTT will automatically update from the new brokers credentials. + repair-issues: + status: done + comment: > + This integration uses repair-issues when entities are set up through YAML. + To avoid user panic, discovery deprecation issues are logged only. + It is the responsibility of the maintainer or the service or device to + correct the discovery messages. Extra options are allowed + in MQTT messages to avoid breaking issues. + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not use web sessions. + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 97ae06ed786df..eae2d1f3c6a3f 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -659,7 +659,6 @@ "motioneye", "motionmount", "mpd", - "mqtt", "mqtt_eventstream", "mqtt_json", "mqtt_room", From fe2260851e6eb14c2c3763aba320209d5a362fa5 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Sat, 23 Nov 2024 04:48:40 -0500 Subject: [PATCH 0758/1070] Bump Fully Kiosk Browser to Bronze quality scale (#131221) --- homeassistant/components/fully_kiosk/manifest.json | 1 + homeassistant/components/fully_kiosk/quality_scale.yaml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fully_kiosk/manifest.json b/homeassistant/components/fully_kiosk/manifest.json index 4d7d1a2d7dac2..1fbbb6656a266 100644 --- a/homeassistant/components/fully_kiosk/manifest.json +++ b/homeassistant/components/fully_kiosk/manifest.json @@ -12,5 +12,6 @@ "documentation": "https://www.home-assistant.io/integrations/fully_kiosk", "iot_class": "local_polling", "mqtt": ["fully/deviceInfo/+"], + "quality_scale": "bronze", "requirements": ["python-fullykiosk==0.0.14"] } diff --git a/homeassistant/components/fully_kiosk/quality_scale.yaml b/homeassistant/components/fully_kiosk/quality_scale.yaml index b30b629ae3ad1..615e4dfe79ae6 100644 --- a/homeassistant/components/fully_kiosk/quality_scale.yaml +++ b/homeassistant/components/fully_kiosk/quality_scale.yaml @@ -15,7 +15,7 @@ rules: common-modules: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done docs-actions: done brands: done From 27926caf774c321ba5d35c4c8b96dde0cf069325 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Sat, 23 Nov 2024 07:05:48 -0500 Subject: [PATCH 0759/1070] Bump aiostreammagic to 2.8.6 (#131312) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/components/cambridge_audio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/samsungtv/test_config_flow.py | 3 +++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index c359ca14a21a0..2125586fa2b1d 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aiostreammagic"], - "requirements": ["aiostreammagic==2.8.5"], + "requirements": ["aiostreammagic==2.8.6"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index fe6cdaa78bb32..2d92dca771e61 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -384,7 +384,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.8.5 +aiostreammagic==2.8.6 # homeassistant.components.switcher_kis aioswitcher==5.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 007ab7a3fa393..4e42a1e8761c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -366,7 +366,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.8.5 +aiostreammagic==2.8.6 # homeassistant.components.switcher_kis aioswitcher==5.0.0 diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 7e707376b6f44..32e169ffb2402 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -14,6 +14,9 @@ UnauthorizedError, ) from websockets import frames + +# WebSocketProtocolError was deprecated in websockets '14.0' +# pylint: disable-next=no-name-in-module from websockets.exceptions import ( ConnectionClosedError, WebSocketException, From d55eb896d2a8c5af028818f214de96c397802e01 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sat, 23 Nov 2024 13:17:28 +0100 Subject: [PATCH 0760/1070] Add Config Flow data descriptions for HomeWizard (#131315) * Add data_description to HomeWizard setup flow * Make quality_scale config-flow as done --- homeassistant/components/homewizard/quality_scale.yaml | 5 +---- homeassistant/components/homewizard/strings.json | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml index c7bac5434deb5..9d0cbc722c283 100644 --- a/homeassistant/components/homewizard/quality_scale.yaml +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -8,10 +8,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: | - The data_descriptions are missing. + config-flow: done dependency-transparency: done docs-actions: status: exempt diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 751c1ec450d24..06959fa47a5a9 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -6,6 +6,9 @@ "description": "Enter the IP address of your HomeWizard Energy device to integrate with Home Assistant.", "data": { "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "ip_address": "The IP address of your HomeWizard Energy device." } }, "discovery_confirm": { From 7d1a7b0870914831c9be6d6fc42d144590d3e75a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 23 Nov 2024 12:51:26 +0000 Subject: [PATCH 0761/1070] Webrtc use RTCIceCandidateInit messages with frontend (#129879) * Add sdp m line index to WebRtc Ice Candidates * Send RTCIceCandidate object in messages * Update tests * Update go2rtc to hardcode spdMid to 0 string on receive * Update for latest webrtc-model changes * Add error check for mushamuro error * Remove sdp_line_index from expected fail tests * Validate and parse message dict * Catch mashumaro error and raise vol.Invalid * Revert conftest change * Use custom validator instead --------- Co-authored-by: Robert Resch --- homeassistant/components/camera/webrtc.py | 17 ++-- tests/components/camera/test_webrtc.py | 96 ++++++++++++++++++++--- 2 files changed, 96 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index f020df6109293..6a7f70ea48b45 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -10,6 +10,7 @@ import logging from typing import TYPE_CHECKING, Any, Protocol +from mashumaro import MissingField import voluptuous as vol from webrtc_models import ( RTCConfiguration, @@ -89,7 +90,7 @@ def as_dict(self) -> dict[str, Any]: """Return a dict representation of the message.""" return { "type": self._get_type(), - "candidate": self.candidate.candidate, + "candidate": self.candidate.to_dict(), } @@ -328,12 +329,20 @@ async def ws_get_client_config( ) +def _parse_webrtc_candidate_init(value: Any) -> RTCIceCandidateInit: + """Validate and parse a WebRTCCandidateInit dict.""" + try: + return RTCIceCandidateInit.from_dict(value) + except (MissingField, ValueError) as ex: + raise vol.Invalid(str(ex)) from ex + + @websocket_api.websocket_command( { vol.Required("type"): "camera/webrtc/candidate", vol.Required("entity_id"): cv.entity_id, vol.Required("session_id"): str, - vol.Required("candidate"): str, + vol.Required("candidate"): _parse_webrtc_candidate_init, } ) @websocket_api.async_response @@ -342,9 +351,7 @@ async def ws_candidate( connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera ) -> None: """Handle WebRTC candidate websocket command.""" - await camera.async_on_webrtc_candidate( - msg["session_id"], RTCIceCandidateInit(msg["candidate"]) - ) + await camera.async_on_webrtc_candidate(msg["session_id"], msg["candidate"]) connection.send_message(websocket_api.result_message(msg["id"])) diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index ba90788bdc37d..76d7b15c286b9 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -495,7 +495,7 @@ async def test_websocket_webrtc_offer_webrtc_provider_deprecated( hass_ws_client, register_test_provider, WebRTCCandidate(RTCIceCandidate("candidate")), - {"type": "candidate", "candidate": "candidate"}, + {"type": "candidate", "candidate": {"candidate": "candidate"}}, ) @@ -504,7 +504,10 @@ async def test_websocket_webrtc_offer_webrtc_provider_deprecated( [ ( WebRTCCandidate(RTCIceCandidateInit("candidate")), - {"type": "candidate", "candidate": "candidate"}, + { + "type": "candidate", + "candidate": {"candidate": "candidate", "sdpMLineIndex": 0}, + }, ), ( WebRTCError("webrtc_offer_failed", "error"), @@ -955,14 +958,34 @@ async def provide_none( unsub() +@pytest.mark.parametrize( + ("frontend_candidate", "expected_candidate"), + [ + ( + {"candidate": "candidate", "sdpMLineIndex": 0}, + RTCIceCandidateInit("candidate"), + ), + ( + {"candidate": "candidate", "sdpMLineIndex": 1}, + RTCIceCandidateInit("candidate", sdp_m_line_index=1), + ), + ( + {"candidate": "candidate", "sdpMid": "1"}, + RTCIceCandidateInit("candidate", sdp_mid="1"), + ), + ], + ids=["candidate", "candidate-mline-index", "candidate-mid"], +) @pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_webrtc_candidate( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + frontend_candidate: dict[str, Any], + expected_candidate: RTCIceCandidateInit, ) -> None: """Test ws webrtc candidate command.""" client = await hass_ws_client(hass) session_id = "session_id" - candidate = "candidate" with patch.object( get_camera_from_entity_id(hass, "camera.async"), "async_on_webrtc_candidate" ) as mock_on_webrtc_candidate: @@ -971,15 +994,64 @@ async def test_ws_webrtc_candidate( "type": "camera/webrtc/candidate", "entity_id": "camera.async", "session_id": session_id, - "candidate": candidate, + "candidate": frontend_candidate, } ) response = await client.receive_json() assert response["type"] == TYPE_RESULT assert response["success"] - mock_on_webrtc_candidate.assert_called_once_with( - session_id, RTCIceCandidateInit(candidate) + mock_on_webrtc_candidate.assert_called_once_with(session_id, expected_candidate) + + +@pytest.mark.parametrize( + ("message", "expected_error_msg"), + [ + ( + {"sdpMLineIndex": 0}, + ( + 'Field "candidate" of type str is missing in RTCIceCandidateInit instance' + " for dictionary value @ data['candidate']. Got {'sdpMLineIndex': 0}" + ), + ), + ( + {"candidate": "candidate", "sdpMLineIndex": -1}, + ( + "sdpMLineIndex must be greater than or equal to 0 for dictionary value @ " + "data['candidate']. Got {'candidate': 'candidate', 'sdpMLineIndex': -1}" + ), + ), + ], + ids=[ + "candidate missing", + "spd_mline_index smaller than 0", + ], +) +@pytest.mark.usefixtures("mock_test_webrtc_cameras") +async def test_ws_webrtc_candidate_invalid_candidate_message( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + message: dict, + expected_error_msg: str, +) -> None: + """Test ws WebRTC candidate command for a camera with a different stream_type.""" + client = await hass_ws_client(hass) + with patch("homeassistant.components.camera.Camera.async_on_webrtc_candidate"): + await client.send_json_auto_id( + { + "type": "camera/webrtc/candidate", + "entity_id": "camera.async", + "session_id": "session_id", + "candidate": message, + } ) + response = await client.receive_json() + + assert response["type"] == TYPE_RESULT + assert not response["success"] + assert response["error"] == { + "code": "invalid_format", + "message": expected_error_msg, + } @pytest.mark.usefixtures("mock_test_webrtc_cameras") @@ -993,7 +1065,7 @@ async def test_ws_webrtc_candidate_not_supported( "type": "camera/webrtc/candidate", "entity_id": "camera.sync", "session_id": "session_id", - "candidate": "candidate", + "candidate": {"candidate": "candidate"}, } ) response = await client.receive_json() @@ -1023,14 +1095,14 @@ async def test_ws_webrtc_candidate_webrtc_provider( "type": "camera/webrtc/candidate", "entity_id": "camera.demo_camera", "session_id": session_id, - "candidate": candidate, + "candidate": {"candidate": candidate, "sdpMLineIndex": 1}, } ) response = await client.receive_json() assert response["type"] == TYPE_RESULT assert response["success"] mock_on_webrtc_candidate.assert_called_once_with( - session_id, RTCIceCandidateInit(candidate) + session_id, RTCIceCandidateInit(candidate, sdp_m_line_index=1) ) @@ -1045,7 +1117,7 @@ async def test_ws_webrtc_candidate_invalid_entity( "type": "camera/webrtc/candidate", "entity_id": "camera.does_not_exist", "session_id": "session_id", - "candidate": "candidate", + "candidate": {"candidate": "candidate"}, } ) response = await client.receive_json() @@ -1089,7 +1161,7 @@ async def test_ws_webrtc_candidate_invalid_stream_type( "type": "camera/webrtc/candidate", "entity_id": "camera.demo_camera", "session_id": "session_id", - "candidate": "candidate", + "candidate": {"candidate": "candidate"}, } ) response = await client.receive_json() From e856ba11d038dda82cf51b679cacd230194ae987 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sat, 23 Nov 2024 14:34:03 +0100 Subject: [PATCH 0762/1070] Bump pylamarzocco to 1.2.11 (#131331) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/components/lamarzocco/coordinator.py | 3 ++- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lamarzocco/test_init.py | 3 ++- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 646aad0e8dd26..d7539fd9ca43e 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -13,6 +13,7 @@ from pylamarzocco.client_local import LaMarzoccoLocalClient from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from pylamarzocco.lm_machine import LaMarzoccoMachine +from websockets.protocol import State from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, CONF_NAME, EVENT_HOMEASSISTANT_STOP @@ -85,7 +86,7 @@ async def websocket_close(_: Any | None = None) -> None: if ( self._local_client is not None and self._local_client.websocket is not None - and self._local_client.websocket.open + and self._local_client.websocket.state is State.OPEN ): self._local_client.terminating = True await self._local_client.websocket.close() diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 8b78a92feca10..4aef30a5c2674 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -33,5 +33,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["pylamarzocco"], - "requirements": ["pylamarzocco==1.2.7"] + "requirements": ["pylamarzocco==1.2.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2d92dca771e61..c9c17f5b6b896 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2027,7 +2027,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.2.7 +pylamarzocco==1.2.11 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e42a1e8761c7..943c919429a28 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1632,7 +1632,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.2.7 +pylamarzocco==1.2.11 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index b99077a905928..5ef0eca13aff1 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -5,6 +5,7 @@ from pylamarzocco.const import FirmwareType from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful import pytest +from websockets.protocol import State from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import DOMAIN @@ -182,7 +183,7 @@ async def test_websocket_closed_on_unload( ) as local_client: client = local_client.return_value client.websocket = AsyncMock() - client.websocket.connected = True + client.websocket.state = State.OPEN await async_init_integration(hass, mock_config_entry) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() From aa79321a2b4510a75b7e65d4b89867b2490ba1ec Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Sun, 24 Nov 2024 02:30:50 +1030 Subject: [PATCH 0763/1070] Bump solax to 3.2.1 (#131373) --- homeassistant/components/solax/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index 2ca246a4e77cf..631ace3792ffa 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/solax", "iot_class": "local_polling", "loggers": ["solax"], - "requirements": ["solax==3.1.1"] + "requirements": ["solax==3.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c9c17f5b6b896..f7b8813f206b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2704,7 +2704,7 @@ solaredge-local==0.2.3 solarlog_cli==0.3.2 # homeassistant.components.solax -solax==3.1.1 +solax==3.2.1 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 943c919429a28..7cf39640ddd43 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2156,7 +2156,7 @@ soco==0.30.6 solarlog_cli==0.3.2 # homeassistant.components.solax -solax==3.1.1 +solax==3.2.1 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 From 30c176b4001e2588d08f4581b6913685c6b391f2 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sat, 23 Nov 2024 17:03:00 +0100 Subject: [PATCH 0764/1070] Mark quality_scale docs-installation-parameters as done (#131372) --- homeassistant/components/homewizard/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml index 9d0cbc722c283..3f62223be5627 100644 --- a/homeassistant/components/homewizard/quality_scale.yaml +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -35,7 +35,7 @@ rules: status: exempt comment: | This integration does not have an options flow. - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done From 6e298f1b9b8a7903d21c3e60bf3a75b97274f402 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 23 Nov 2024 17:05:31 +0100 Subject: [PATCH 0765/1070] Add missing apostrophe for possessive form (#131368) --- homeassistant/components/file/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json index 8806c67cd9670..bd8f23602e385 100644 --- a/homeassistant/components/file/strings.json +++ b/homeassistant/components/file/strings.json @@ -18,7 +18,7 @@ }, "data_description": { "file_path": "The local file path to retrieve the sensor value from", - "value_template": "A template to render the sensors value based on the file content", + "value_template": "A template to render the sensor's value based on the file content", "unit_of_measurement": "Unit of measurement for the sensor" } }, From 557a80497e83c85e82ac2b376a359fd2de612fc2 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sat, 23 Nov 2024 17:07:34 +0100 Subject: [PATCH 0766/1070] Mark quality_scale docs-removal-instructions as done (#131370) --- homeassistant/components/homewizard/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml index 3f62223be5627..4d8e31f7f3fbb 100644 --- a/homeassistant/components/homewizard/quality_scale.yaml +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -16,7 +16,7 @@ rules: The integration does not provide any additional actions. docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: exempt comment: | From 460ce2463d1fe6bc5d1ba037542379d854cf0f7a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 23 Nov 2024 08:23:37 -0800 Subject: [PATCH 0767/1070] Add quality scale for rainbird (#131332) --- .../components/rainbird/quality_scale.yaml | 85 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/rainbird/quality_scale.yaml diff --git a/homeassistant/components/rainbird/quality_scale.yaml b/homeassistant/components/rainbird/quality_scale.yaml new file mode 100644 index 0000000000000..1626f93a00350 --- /dev/null +++ b/homeassistant/components/rainbird/quality_scale.yaml @@ -0,0 +1,85 @@ +rules: + # Bronze + config-flow: + status: todo + comment: Some fields are missing data descriptions. + brands: done + dependency-transparency: done + common-modules: done + has-entity-name: done + action-setup: + status: done + comment: | + The integration only has an entity service, registered in the platform. + appropriate-polling: + status: done + comment: | + Rainbird devices are local. Irrigation valve/controller status is polled + once per minute to get fast updates when turning on/off the valves. + The irrigation schedule uses a 15 minute poll interval since it rarely + changes. + + Rainbird devices can only accept a single http connection, so this uses a + an aiohttp.ClientSession with a connection limit, and also uses a request + debouncer. + test-before-configure: done + entity-event-setup: + status: exempt + comment: Integration is polling and does not subscribe to events. + unique-config-entry: done + entity-unique-id: done + docs-installation-instructions: + status: todo + comment: | + The introduction can be improved and is missing pre-requisites such as + installing the app. + docs-removal-instructions: todo + test-before-setup: done + docs-high-level-description: done + config-flow-test-coverage: done + docs-actions: done + runtime-data: + status: todo + comment: | + The integration currently stores config entry data in `hass.data` and + needs to be moved to `runtime_data`. + + # Silver + log-when-unavailable: todo + config-entry-unloading: todo + reauthentication-flow: todo + action-exceptions: todo + docs-installation-parameters: todo + integration-owner: todo + parallel-updates: todo + test-coverage: todo + docs-configuration-parameters: todo + entity-unavailable: todo + + # Gold + docs-examples: todo + discovery-update-info: todo + entity-device-class: todo + entity-translations: todo + docs-data-update: todo + entity-disabled-by-default: todo + discovery: todo + exception-translations: todo + devices: todo + docs-supported-devices: todo + icon-translations: todo + docs-known-limitations: todo + stale-devices: todo + docs-supported-functions: todo + repair-issues: todo + reconfiguration-flow: todo + entity-category: todo + dynamic-devices: todo + docs-troubleshooting: todo + diagnostics: todo + docs-use-cases: todo + + # Platinum + async-dependency: todo + strict-typing: todo + inject-websession: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index eae2d1f3c6a3f..f8ceb3a46c0ac 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -826,7 +826,6 @@ "radarr", "radio_browser", "radiotherm", - "rainbird", "raincloud", "rainforest_eagle", "rainforest_raven", From 0033ce4f968a92b040205626b9ea7a8eebfca853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 23 Nov 2024 17:27:56 +0100 Subject: [PATCH 0768/1070] Update AEMET-OpenData to v0.6.3 (#131303) --- homeassistant/components/aemet/__init__.py | 8 +++++--- homeassistant/components/aemet/config_flow.py | 2 +- homeassistant/components/aemet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index 29bc044c67d26..9ec52faec0085 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -3,7 +3,7 @@ import logging from aemet_opendata.exceptions import AemetError, TownNotFound -from aemet_opendata.interface import AEMET, ConnectionOptions +from aemet_opendata.interface import AEMET, ConnectionOptions, UpdateFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME @@ -23,9 +23,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> boo api_key = entry.data[CONF_API_KEY] latitude = entry.data[CONF_LATITUDE] longitude = entry.data[CONF_LONGITUDE] - station_updates = entry.options.get(CONF_STATION_UPDATES, True) + update_features: int = UpdateFeature.FORECAST + if entry.options.get(CONF_STATION_UPDATES, True): + update_features |= UpdateFeature.STATION - options = ConnectionOptions(api_key, station_updates) + options = ConnectionOptions(api_key, update_features) aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options) try: await aemet.select_coordinates(latitude, longitude) diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index 6b2eca3f5c9f4..e2b0b436c8c93 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -45,7 +45,7 @@ async def async_step_user( await self.async_set_unique_id(f"{latitude}-{longitude}") self._abort_if_unique_id_configured() - options = ConnectionOptions(user_input[CONF_API_KEY], False) + options = ConnectionOptions(user_input[CONF_API_KEY]) aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options) try: await aemet.select_coordinates(latitude, longitude) diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index d4b0db6193351..5c9d1ff7e5a5a 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aemet", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], - "requirements": ["AEMET-OpenData==0.6.2"] + "requirements": ["AEMET-OpenData==0.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index f7b8813f206b4..ae8ab5d3c466b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -4,7 +4,7 @@ -r requirements.txt # homeassistant.components.aemet -AEMET-OpenData==0.6.2 +AEMET-OpenData==0.6.3 # homeassistant.components.honeywell AIOSomecomfort==0.0.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7cf39640ddd43..19d11e3329b5c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,7 +4,7 @@ -r requirements_test.txt # homeassistant.components.aemet -AEMET-OpenData==0.6.2 +AEMET-OpenData==0.6.3 # homeassistant.components.honeywell AIOSomecomfort==0.0.25 From 2609fdf2a199cdb670964f2f6c61852aec374853 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 23 Nov 2024 17:30:00 +0100 Subject: [PATCH 0769/1070] Improve description of Elevation field in homeassistant.set_location (#131356) --- homeassistant/components/homeassistant/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 0dd4eff507d74..da8a1015d7915 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -134,7 +134,7 @@ }, "elevation": { "name": "[%key:common::config_flow::data::elevation%]", - "description": "Elevation of your location." + "description": "Elevation of your location above sea level." } } }, From 34df6ef64c63fec99b8bf0e329eb34a6e888b0a1 Mon Sep 17 00:00:00 2001 From: dotvav Date: Sat, 23 Nov 2024 17:40:34 +0100 Subject: [PATCH 0770/1070] Add quality_scale.yaml to palazzetti (#131335) Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> --- .../components/palazzetti/quality_scale.yaml | 86 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/palazzetti/quality_scale.yaml diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml new file mode 100644 index 0000000000000..da51f1002dcc7 --- /dev/null +++ b/homeassistant/components/palazzetti/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not register actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not register actions. + docs-high-level-description: done + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + This integration does not subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have configuration. + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration connects to a single device. + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: | + This integration does not have custom icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not raise any repairable issues. + stale-devices: + status: exempt + comment: | + This integration connects to a single device. + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index f8ceb3a46c0ac..e37aacb776b68 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -763,7 +763,6 @@ "ovo_energy", "owntracks", "p1_monitor", - "palazzetti", "panasonic_bluray", "panasonic_viera", "pandora", From 0d1400560293ca06fb22927fb632a62e1f9a6f23 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 23 Nov 2024 08:55:08 -0800 Subject: [PATCH 0771/1070] Remove unused config flow import step (#131379) Remove unused config flow import removed in #130783 --- homeassistant/components/fitbit/config_flow.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py index cb4e3fb4ea35b..d5b33a731e324 100644 --- a/homeassistant/components/fitbit/config_flow.py +++ b/homeassistant/components/fitbit/config_flow.py @@ -86,7 +86,3 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu self._abort_if_unique_id_configured() return self.async_create_entry(title=profile.display_name, data=data) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Handle import from YAML.""" - return await self.async_oauth_create_entry(import_data) From fa1b7d73d5a5dc137ad2fc0f57e9244a13074350 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 24 Nov 2024 02:58:58 +1000 Subject: [PATCH 0772/1070] Add dict of translated errors to Tessie (#131346) * Add dict of translated errors * Fix test --- homeassistant/components/tessie/const.py | 10 ++++++++++ homeassistant/components/tessie/entity.py | 7 ++++--- tests/components/tessie/test_cover.py | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index 90862eff96925..4731f5168a213 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -13,6 +13,16 @@ "models": "Model S", } +TRANSLATED_ERRORS = { + "unknown": "unknown", + "not supported": "not_supported", + "cable connected": "cable_connected", + "already active": "already_active", + "already inactive": "already_inactive", + "incorrect pin": "incorrect_pin", + "no cable": "no_cable", +} + class TessieState(StrEnum): """Tessie status.""" diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index 42a3c92b2be64..a2b6d3c9761fc 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -10,7 +10,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DOMAIN, TRANSLATED_ERRORS from .coordinator import ( TessieEnergySiteInfoCoordinator, TessieEnergySiteLiveCoordinator, @@ -107,10 +107,11 @@ async def run( if response["result"] is False: name: str = getattr(self, "name", self.entity_id) reason: str = response.get("reason", "unknown") + translation_key = TRANSLATED_ERRORS.get(reason, "command_failed") raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=reason.replace(" ", "_"), - translation_placeholders={"name": name}, + translation_key=translation_key, + translation_placeholders={"name": name, "message": reason}, ) def _async_update_attrs(self) -> None: diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py index 451d1758e560b..49a53fd327ca0 100644 --- a/tests/components/tessie/test_cover.py +++ b/tests/components/tessie/test_cover.py @@ -112,4 +112,4 @@ async def test_errors(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() - assert str(error.value) == TEST_RESPONSE_ERROR["reason"] + assert str(error.value) == f"Command failed, {TEST_RESPONSE_ERROR["reason"]}" From 7b70f2d83b3ff840b4016cc9c9fcc1d01dec8ab0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 23 Nov 2024 17:59:41 +0100 Subject: [PATCH 0773/1070] Translate UpdateFailed exception in PEGELONLINE (#131380) translate UpdateFailed exception --- homeassistant/components/pegel_online/coordinator.py | 8 ++++++-- homeassistant/components/pegel_online/strings.json | 5 +++++ tests/components/pegel_online/test_init.py | 9 +++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/pegel_online/coordinator.py b/homeassistant/components/pegel_online/coordinator.py index 1802af8e05ce6..c8233673fde55 100644 --- a/homeassistant/components/pegel_online/coordinator.py +++ b/homeassistant/components/pegel_online/coordinator.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import MIN_TIME_BETWEEN_UPDATES +from .const import DOMAIN, MIN_TIME_BETWEEN_UPDATES _LOGGER = logging.getLogger(__name__) @@ -33,4 +33,8 @@ async def _async_update_data(self) -> StationMeasurements: try: return await self.api.async_get_station_measurements(self.station.uuid) except CONNECT_ERRORS as err: - raise UpdateFailed(f"Failed to communicate with API: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/pegel_online/strings.json b/homeassistant/components/pegel_online/strings.json index e777f6169bace..b8d18e63a4fdb 100644 --- a/homeassistant/components/pegel_online/strings.json +++ b/homeassistant/components/pegel_online/strings.json @@ -48,5 +48,10 @@ "name": "Water temperature" } } + }, + "exceptions": { + "communication_error": { + "message": "Failed to communicate with API: {error}" + } } } diff --git a/tests/components/pegel_online/test_init.py b/tests/components/pegel_online/test_init.py index ee2e78af7cf12..c1b8f1861c49e 100644 --- a/tests/components/pegel_online/test_init.py +++ b/tests/components/pegel_online/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import patch from aiohttp.client_exceptions import ClientError +import pytest from homeassistant.components.pegel_online.const import ( CONF_STATION, @@ -23,7 +24,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed -async def test_update_error(hass: HomeAssistant) -> None: +async def test_update_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Tests error during update entity.""" entry = MockConfigEntry( domain=DOMAIN, @@ -43,9 +46,11 @@ async def test_update_error(hass: HomeAssistant) -> None: state = hass.states.get("sensor.dresden_elbe_water_level") assert state - pegelonline().override_side_effect(ClientError) + pegelonline().override_side_effect(ClientError("Boom")) async_fire_time_changed(hass, utcnow() + MIN_TIME_BETWEEN_UPDATES) await hass.async_block_till_done() + assert "Failed to communicate with API: Boom" in caplog.text + state = hass.states.get("sensor.dresden_elbe_water_level") assert state.state == STATE_UNAVAILABLE From f93525e0fcdd1e4412d3a9cf93ffee9aedc5a67a Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sat, 23 Nov 2024 17:00:28 +0000 Subject: [PATCH 0774/1070] Add unit of measurement to translations for Mastodon (#131343) * Add unit of measurement to translations * Fix strings lint --- homeassistant/components/mastodon/sensor.py | 3 --- homeassistant/components/mastodon/strings.json | 9 ++++++--- tests/components/mastodon/snapshots/test_sensor.ambr | 9 +++------ 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mastodon/sensor.py b/homeassistant/components/mastodon/sensor.py index 12acfc047435e..a7a1d40fcc4bf 100644 --- a/homeassistant/components/mastodon/sensor.py +++ b/homeassistant/components/mastodon/sensor.py @@ -35,21 +35,18 @@ class MastodonSensorEntityDescription(SensorEntityDescription): MastodonSensorEntityDescription( key="followers", translation_key="followers", - native_unit_of_measurement="accounts", state_class=SensorStateClass.TOTAL, value_fn=lambda data: data.get(ACCOUNT_FOLLOWERS_COUNT), ), MastodonSensorEntityDescription( key="following", translation_key="following", - native_unit_of_measurement="accounts", state_class=SensorStateClass.TOTAL, value_fn=lambda data: data.get(ACCOUNT_FOLLOWING_COUNT), ), MastodonSensorEntityDescription( key="posts", translation_key="posts", - native_unit_of_measurement="posts", state_class=SensorStateClass.TOTAL, value_fn=lambda data: data.get(ACCOUNT_STATUSES_COUNT), ), diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index fb51d86664259..c6aefefca06d2 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -42,13 +42,16 @@ "entity": { "sensor": { "followers": { - "name": "Followers" + "name": "Followers", + "unit_of_measurement": "accounts" }, "following": { - "name": "Following" + "name": "Following", + "unit_of_measurement": "[%key:component::mastodon::entity::sensor::followers::unit_of_measurement%]" }, "posts": { - "name": "Posts" + "name": "Posts", + "unit_of_measurement": "posts" } } } diff --git a/tests/components/mastodon/snapshots/test_sensor.ambr b/tests/components/mastodon/snapshots/test_sensor.ambr index c8df8cdab1977..3e6e41796f6fa 100644 --- a/tests/components/mastodon/snapshots/test_sensor.ambr +++ b/tests/components/mastodon/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'supported_features': 0, 'translation_key': 'followers', 'unique_id': 'trwnh_mastodon_social_followers', - 'unit_of_measurement': 'accounts', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_followers-state] @@ -39,7 +39,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mastodon @trwnh@mastodon.social Followers', 'state_class': , - 'unit_of_measurement': 'accounts', }), 'context': , 'entity_id': 'sensor.mastodon_trwnh_mastodon_social_followers', @@ -81,7 +80,7 @@ 'supported_features': 0, 'translation_key': 'following', 'unique_id': 'trwnh_mastodon_social_following', - 'unit_of_measurement': 'accounts', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_following-state] @@ -89,7 +88,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mastodon @trwnh@mastodon.social Following', 'state_class': , - 'unit_of_measurement': 'accounts', }), 'context': , 'entity_id': 'sensor.mastodon_trwnh_mastodon_social_following', @@ -131,7 +129,7 @@ 'supported_features': 0, 'translation_key': 'posts', 'unique_id': 'trwnh_mastodon_social_posts', - 'unit_of_measurement': 'posts', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_posts-state] @@ -139,7 +137,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mastodon @trwnh@mastodon.social Posts', 'state_class': , - 'unit_of_measurement': 'posts', }), 'context': , 'entity_id': 'sensor.mastodon_trwnh_mastodon_social_posts', From ea4bbfea7ef81cf98b539587486016f6a2875075 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Sat, 23 Nov 2024 18:03:51 +0100 Subject: [PATCH 0775/1070] Pass websession to solarlog_cli (#131300) --- homeassistant/components/solarlog/coordinator.py | 2 ++ homeassistant/components/solarlog/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 5fdf89c9e7492..fe075a209703f 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -19,6 +19,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify @@ -58,6 +59,7 @@ def __init__(self, hass: HomeAssistant, entry: SolarlogConfigEntry) -> None: self.host, tz=hass.config.time_zone, password=password, + session=async_get_clientsession(hass), ) async def _async_setup(self) -> None: diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index 9f80b749d0864..5bea017781dbe 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/solarlog", "iot_class": "local_polling", "loggers": ["solarlog_cli"], - "requirements": ["solarlog_cli==0.3.2"] + "requirements": ["solarlog_cli==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ae8ab5d3c466b..d82ae02fbfeae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2701,7 +2701,7 @@ soco==0.30.6 solaredge-local==0.2.3 # homeassistant.components.solarlog -solarlog_cli==0.3.2 +solarlog_cli==0.4.0 # homeassistant.components.solax solax==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19d11e3329b5c..8b5321f689586 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ snapcast==2.3.6 soco==0.30.6 # homeassistant.components.solarlog -solarlog_cli==0.3.2 +solarlog_cli==0.4.0 # homeassistant.components.solax solax==3.2.1 From 50013cf5c7c2fc395c54f3860d9c60a25eab95c5 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 23 Nov 2024 18:04:49 +0100 Subject: [PATCH 0776/1070] Add unit translations for NextDNS integration (#131281) * Add unit translations for NextDNS integration * Use translation keys --- homeassistant/components/nextdns/sensor.py | 15 ------- homeassistant/components/nextdns/strings.json | 45 ++++++++++++------- .../nextdns/snapshots/test_sensor.ambr | 45 +++++++------------ 3 files changed, 45 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index b390ac93e063d..ef2b5140fa18a 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -54,7 +54,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, translation_key="all_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.all_queries, ), @@ -63,7 +62,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, translation_key="blocked_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.blocked_queries, ), @@ -72,7 +70,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, translation_key="relayed_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.relayed_queries, ), @@ -91,7 +88,6 @@ class NextDnsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="doh_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.doh_queries, ), @@ -101,7 +97,6 @@ class NextDnsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="doh3_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.doh3_queries, ), @@ -111,7 +106,6 @@ class NextDnsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="dot_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.dot_queries, ), @@ -121,7 +115,6 @@ class NextDnsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="doq_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.doq_queries, ), @@ -131,7 +124,6 @@ class NextDnsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="tcp_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.tcp_queries, ), @@ -141,7 +133,6 @@ class NextDnsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="udp_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.udp_queries, ), @@ -211,7 +202,6 @@ class NextDnsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="encrypted_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.encrypted_queries, ), @@ -221,7 +211,6 @@ class NextDnsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="unencrypted_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.unencrypted_queries, ), @@ -241,7 +230,6 @@ class NextDnsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="ipv4_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.ipv4_queries, ), @@ -251,7 +239,6 @@ class NextDnsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="ipv6_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.ipv6_queries, ), @@ -271,7 +258,6 @@ class NextDnsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="validated_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.validated_queries, ), @@ -281,7 +267,6 @@ class NextDnsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="not_validated_queries", - native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, value=lambda data: data.not_validated_queries, ), diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 9dbc80618497d..f2a5fa2816df4 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -48,76 +48,91 @@ }, "sensor": { "all_queries": { - "name": "DNS queries" + "name": "DNS queries", + "unit_of_measurement": "queries" }, "blocked_queries": { - "name": "DNS queries blocked" + "name": "DNS queries blocked", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "blocked_queries_ratio": { "name": "DNS queries blocked ratio" }, "doh3_queries": { - "name": "DNS-over-HTTP/3 queries" + "name": "DNS-over-HTTP/3 queries", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "doh3_queries_ratio": { "name": "DNS-over-HTTP/3 queries ratio" }, "doh_queries": { - "name": "DNS-over-HTTPS queries" + "name": "DNS-over-HTTPS queries", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "doh_queries_ratio": { "name": "DNS-over-HTTPS queries ratio" }, "doq_queries": { - "name": "DNS-over-QUIC queries" + "name": "DNS-over-QUIC queries", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "doq_queries_ratio": { "name": "DNS-over-QUIC queries ratio" }, "dot_queries": { - "name": "DNS-over-TLS queries" + "name": "DNS-over-TLS queries", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "dot_queries_ratio": { "name": "DNS-over-TLS queries ratio" }, "encrypted_queries": { - "name": "Encrypted queries" + "name": "Encrypted queries", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "encrypted_queries_ratio": { "name": "Encrypted queries ratio" }, "ipv4_queries": { - "name": "IPv4 queries" + "name": "IPv4 queries", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "ipv6_queries": { - "name": "IPv6 queries" + "name": "IPv6 queries", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "ipv6_queries_ratio": { "name": "IPv6 queries ratio" }, "not_validated_queries": { - "name": "DNSSEC not validated queries" + "name": "DNSSEC not validated queries", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "relayed_queries": { - "name": "DNS queries relayed" + "name": "DNS queries relayed", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "tcp_queries": { - "name": "TCP queries" + "name": "TCP queries", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "tcp_queries_ratio": { "name": "TCP queries ratio" }, "udp_queries": { - "name": "UDP queries" + "name": "UDP queries", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "udp_queries_ratio": { "name": "UDP queries ratio" }, "unencrypted_queries": { - "name": "Unencrypted queries" + "name": "Unencrypted queries", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "validated_queries": { - "name": "DNSSEC validated queries" + "name": "DNSSEC validated queries", + "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]" }, "validated_queries_ratio": { "name": "DNSSEC validated queries ratio" diff --git a/tests/components/nextdns/snapshots/test_sensor.ambr b/tests/components/nextdns/snapshots/test_sensor.ambr index 14bebea53f866..a1ff0941e3fb1 100644 --- a/tests/components/nextdns/snapshots/test_sensor.ambr +++ b/tests/components/nextdns/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'supported_features': 0, 'translation_key': 'doh3_queries', 'unique_id': 'xyz12_doh3_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_dns_over_http_3_queries-state] @@ -39,7 +39,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS-over-HTTP/3 queries', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_over_http_3_queries', @@ -131,7 +130,7 @@ 'supported_features': 0, 'translation_key': 'doh_queries', 'unique_id': 'xyz12_doh_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_dns_over_https_queries-state] @@ -139,7 +138,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS-over-HTTPS queries', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_over_https_queries', @@ -231,7 +229,7 @@ 'supported_features': 0, 'translation_key': 'doq_queries', 'unique_id': 'xyz12_doq_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_dns_over_quic_queries-state] @@ -239,7 +237,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS-over-QUIC queries', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_over_quic_queries', @@ -331,7 +328,7 @@ 'supported_features': 0, 'translation_key': 'dot_queries', 'unique_id': 'xyz12_dot_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_dns_over_tls_queries-state] @@ -339,7 +336,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS-over-TLS queries', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_over_tls_queries', @@ -431,7 +427,7 @@ 'supported_features': 0, 'translation_key': 'all_queries', 'unique_id': 'xyz12_all_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_dns_queries-state] @@ -439,7 +435,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS queries', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_queries', @@ -481,7 +476,7 @@ 'supported_features': 0, 'translation_key': 'blocked_queries', 'unique_id': 'xyz12_blocked_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_dns_queries_blocked-state] @@ -489,7 +484,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS queries blocked', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_queries_blocked', @@ -581,7 +575,7 @@ 'supported_features': 0, 'translation_key': 'relayed_queries', 'unique_id': 'xyz12_relayed_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_dns_queries_relayed-state] @@ -589,7 +583,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS queries relayed', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_queries_relayed', @@ -631,7 +624,7 @@ 'supported_features': 0, 'translation_key': 'not_validated_queries', 'unique_id': 'xyz12_not_validated_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_dnssec_not_validated_queries-state] @@ -639,7 +632,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNSSEC not validated queries', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dnssec_not_validated_queries', @@ -681,7 +673,7 @@ 'supported_features': 0, 'translation_key': 'validated_queries', 'unique_id': 'xyz12_validated_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_dnssec_validated_queries-state] @@ -689,7 +681,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNSSEC validated queries', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dnssec_validated_queries', @@ -781,7 +772,7 @@ 'supported_features': 0, 'translation_key': 'encrypted_queries', 'unique_id': 'xyz12_encrypted_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_encrypted_queries-state] @@ -789,7 +780,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile Encrypted queries', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_encrypted_queries', @@ -881,7 +871,7 @@ 'supported_features': 0, 'translation_key': 'ipv4_queries', 'unique_id': 'xyz12_ipv4_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_ipv4_queries-state] @@ -889,7 +879,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile IPv4 queries', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_ipv4_queries', @@ -931,7 +920,7 @@ 'supported_features': 0, 'translation_key': 'ipv6_queries', 'unique_id': 'xyz12_ipv6_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_ipv6_queries-state] @@ -939,7 +928,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile IPv6 queries', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_ipv6_queries', @@ -1031,7 +1019,7 @@ 'supported_features': 0, 'translation_key': 'tcp_queries', 'unique_id': 'xyz12_tcp_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_tcp_queries-state] @@ -1039,7 +1027,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile TCP queries', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_tcp_queries', @@ -1131,7 +1118,7 @@ 'supported_features': 0, 'translation_key': 'udp_queries', 'unique_id': 'xyz12_udp_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_udp_queries-state] @@ -1139,7 +1126,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile UDP queries', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_udp_queries', @@ -1231,7 +1217,7 @@ 'supported_features': 0, 'translation_key': 'unencrypted_queries', 'unique_id': 'xyz12_unencrypted_queries', - 'unit_of_measurement': 'queries', + 'unit_of_measurement': None, }) # --- # name: test_sensor[sensor.fake_profile_unencrypted_queries-state] @@ -1239,7 +1225,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile Unencrypted queries', 'state_class': , - 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_unencrypted_queries', From c7485b94d5b9ff6a33fd185a13aaf8bc5772b866 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 23 Nov 2024 18:58:24 +0100 Subject: [PATCH 0777/1070] Use breaks_in_ha_version in report_usage (#131137) * Use breaks_in_ha_version in report_usage * Revert behavior change --- homeassistant/components/http/__init__.py | 4 ++-- homeassistant/core.py | 20 ++++++++++---------- homeassistant/core_config.py | 4 ++-- homeassistant/data_entry_flow.py | 6 ++---- homeassistant/helpers/event.py | 13 ++++--------- homeassistant/helpers/service.py | 6 ++---- homeassistant/helpers/template.py | 6 ++---- homeassistant/helpers/update_coordinator.py | 8 ++++---- homeassistant/loader.py | 12 +++++------- tests/helpers/test_event.py | 6 ++++-- tests/helpers/test_update_coordinator.py | 12 ++++++------ tests/test_core.py | 19 ++++++++----------- tests/test_core_config.py | 5 ++--- tests/test_loader.py | 4 ++-- 14 files changed, 55 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index c9c75b0c04e1c..3b18b44862a99 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -509,10 +509,10 @@ def register_static_path( "calls hass.http.register_static_path which is deprecated because " "it does blocking I/O in the event loop, instead " "call `await hass.http.async_register_static_paths(" - f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`; ' - "This function will be removed in 2025.7", + f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`', exclude_integrations={"http"}, core_behavior=frame.ReportBehavior.LOG, + breaks_in_ha_version="2025.7", ) configs = [StaticPathConfig(url_path, path, cache_headers)] resources = self._make_static_resources(configs) diff --git a/homeassistant/core.py b/homeassistant/core.py index cdfb5570b4437..f4c819c1262c3 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -657,11 +657,11 @@ def async_add_job[_R, *_Ts]( from .helpers import frame # pylint: disable=import-outside-toplevel frame.report_usage( - "calls `async_add_job`, which is deprecated and will be removed in Home " - "Assistant 2025.4; Please review " + "calls `async_add_job`, which should be reviewed against " "https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job" " for replacement options", core_behavior=frame.ReportBehavior.LOG, + breaks_in_ha_version="2025.4", ) if target is None: @@ -713,11 +713,11 @@ def async_add_hass_job[_R]( from .helpers import frame # pylint: disable=import-outside-toplevel frame.report_usage( - "calls `async_add_hass_job`, which is deprecated and will be removed in Home " - "Assistant 2025.5; Please review " + "calls `async_add_hass_job`, which should be reviewed against " "https://developers.home-assistant.io/blog/2024/04/07/deprecate_add_hass_job" " for replacement options", core_behavior=frame.ReportBehavior.LOG, + breaks_in_ha_version="2025.5", ) return self._async_add_hass_job(hassjob, *args, background=background) @@ -987,11 +987,11 @@ def async_run_job[_R, *_Ts]( from .helpers import frame # pylint: disable=import-outside-toplevel frame.report_usage( - "calls `async_run_job`, which is deprecated and will be removed in Home " - "Assistant 2025.4; Please review " + "calls `async_run_job`, which should be reviewed against " "https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job" " for replacement options", core_behavior=frame.ReportBehavior.LOG, + breaks_in_ha_version="2025.4", ) if asyncio.iscoroutine(target): @@ -1636,9 +1636,9 @@ def async_listen( from .helpers import frame # pylint: disable=import-outside-toplevel frame.report_usage( - "calls `async_listen` with run_immediately, which is" - " deprecated and will be removed in Home Assistant 2025.5", + "calls `async_listen` with run_immediately", core_behavior=frame.ReportBehavior.LOG, + breaks_in_ha_version="2025.5", ) if event_filter is not None and not is_callback_check_partial(event_filter): @@ -1706,9 +1706,9 @@ def async_listen_once( from .helpers import frame # pylint: disable=import-outside-toplevel frame.report_usage( - "calls `async_listen_once` with run_immediately, which is " - "deprecated and will be removed in Home Assistant 2025.5", + "calls `async_listen_once` with run_immediately", core_behavior=frame.ReportBehavior.LOG, + breaks_in_ha_version="2025.5", ) one_time_listener: _OneTimeListener[_DataT] = _OneTimeListener( diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index 5c773c57bc439..430a882ecb930 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -696,10 +696,10 @@ def set_time_zone(self, time_zone_str: str) -> None: It will be removed in Home Assistant 2025.6. """ report_usage( - "set the time zone using set_time_zone instead of async_set_time_zone" - " which will stop working in Home Assistant 2025.6", + "sets the time zone using set_time_zone instead of async_set_time_zone", core_integration_behavior=ReportBehavior.ERROR, custom_integration_behavior=ReportBehavior.ERROR, + breaks_in_ha_version="2025.6", ) if time_zone := dt_util.get_time_zone(time_zone_str): self.time_zone = time_zone_str diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 63baca56aebd3..338b5f3992f4b 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -531,11 +531,9 @@ async def _async_handle_step( if not isinstance(result["type"], FlowResultType): result["type"] = FlowResultType(result["type"]) # type: ignore[unreachable] report_usage( - ( - "does not use FlowResultType enum for data entry flow result type. " - "This is deprecated and will stop working in Home Assistant 2025.1" - ), + "does not use FlowResultType enum for data entry flow result type", core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2025.1", ) if ( diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 779cd8d5108bd..578132f358f10 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -996,15 +996,10 @@ def __init__( if track_template_.template.hass: continue - # pylint: disable-next=import-outside-toplevel - from .frame import ReportBehavior, report_usage - - report_usage( - ( - "calls async_track_template_result with template without hass, " - "which will stop working in HA Core 2025.10" - ), - core_behavior=ReportBehavior.LOG, + frame.report_usage( + "calls async_track_template_result with template without hass", + core_behavior=frame.ReportBehavior.LOG, + breaks_in_ha_version="2025.10", ) track_template_.template.hass = hass diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index e3da52604cb7e..31b2e8e8ac8cb 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1280,11 +1280,9 @@ def async_register_entity_service( from .frame import ReportBehavior, report_usage report_usage( - ( - "registers an entity service with a non entity service schema " - "which will stop working in HA Core 2025.9" - ), + "registers an entity service with a non entity service schema", core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2025.9", ) service_func: str | HassJob[..., Any] diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 2eab666bbd4de..57587dc21d60d 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -522,11 +522,9 @@ def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: if not hass: report_usage( - ( - "creates a template object without passing hass, " - "which will stop working in HA Core 2025.10" - ), + "creates a template object without passing hass", core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2025.10", ) self.template: str = template.strip() diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 6c94fba65d68a..6cc4584935e5f 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -289,8 +289,8 @@ async def async_config_entry_first_refresh(self) -> None: if self.config_entry is None: report_usage( "uses `async_config_entry_first_refresh`, which is only supported " - "for coordinators with a config entry and will stop working in " - "Home Assistant 2025.11" + "for coordinators with a config entry", + breaks_in_ha_version="2025.11", ) elif ( self.config_entry.state @@ -299,8 +299,8 @@ async def async_config_entry_first_refresh(self) -> None: report_usage( "uses `async_config_entry_first_refresh`, which is only supported " f"when entry state is {config_entries.ConfigEntryState.SETUP_IN_PROGRESS}, " - f"but it is in state {self.config_entry.state}, " - "This will stop working in Home Assistant 2025.11", + f"but it is in state {self.config_entry.state}", + breaks_in_ha_version="2025.11", ) if await self.__wrap_async_setup(): await self._async_refresh( diff --git a/homeassistant/loader.py b/homeassistant/loader.py index e63de0c80262a..4313cd2d6e0cd 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1560,14 +1560,12 @@ def __getattr__(self, comp_name: str) -> ModuleWrapper: from .helpers.frame import ReportBehavior, report_usage report_usage( - ( - f"accesses hass.components.{comp_name}." - " This is deprecated and will stop working in Home Assistant 2025.3, it" - f" should be updated to import functions used from {comp_name} directly" - ), + f"accesses hass.components.{comp_name}, which" + f" should be updated to import functions used from {comp_name} directly", core_behavior=ReportBehavior.IGNORE, core_integration_behavior=ReportBehavior.IGNORE, custom_integration_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2025.3", ) wrapped = ModuleWrapper(self._hass, component) @@ -1592,13 +1590,13 @@ def __getattr__(self, helper_name: str) -> ModuleWrapper: report_usage( ( - f"accesses hass.helpers.{helper_name}." - " This is deprecated and will stop working in Home Assistant 2025.5, it" + f"accesses hass.helpers.{helper_name}, which" f" should be updated to import functions used from {helper_name} directly" ), core_behavior=ReportBehavior.IGNORE, core_integration_behavior=ReportBehavior.IGNORE, custom_integration_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2025.5", ) wrapped = ModuleWrapper(self._hass, helper) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index f01fcf3dddad7..a0014587cd0c5 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4946,7 +4946,8 @@ async def test_async_track_template_no_hass_deprecated( """Test async_track_template with a template without hass is deprecated.""" message = ( "Detected code that calls async_track_template_result with template without " - "hass, which will stop working in HA Core 2025.10. Please report this issue" + "hass. This will stop working in Home Assistant 2025.10, please " + "report this issue" ) async_track_template(hass, Template("blah"), lambda x, y, z: None) @@ -4964,7 +4965,8 @@ async def test_async_track_template_result_no_hass_deprecated( """Test async_track_template_result with a template without hass is deprecated.""" message = ( "Detected code that calls async_track_template_result with template without " - "hass, which will stop working in HA Core 2025.10. Please report this issue" + "hass. This will stop working in Home Assistant 2025.10, please " + "report this issue" ) async_track_template_result( diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 70a95c6bec848..539762a60ff72 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -628,8 +628,7 @@ async def test_async_config_entry_first_refresh_invalid_state( RuntimeError, match="Detected code that uses `async_config_entry_first_refresh`, which " "is only supported when entry state is ConfigEntryState.SETUP_IN_PROGRESS, " - "but it is in state ConfigEntryState.NOT_LOADED. This will stop working " - "in Home Assistant 2025.11. Please report this issue", + "but it is in state ConfigEntryState.NOT_LOADED. Please report this issue", ): await crd.async_config_entry_first_refresh() @@ -653,8 +652,9 @@ async def test_async_config_entry_first_refresh_invalid_state_in_integration( assert ( "Detected that integration 'hue' uses `async_config_entry_first_refresh`, which " "is only supported when entry state is ConfigEntryState.SETUP_IN_PROGRESS, " - "but it is in state ConfigEntryState.NOT_LOADED, This will stop working " - "in Home Assistant 2025.11" + "but it is in state ConfigEntryState.NOT_LOADED at " + "homeassistant/components/hue/light.py, line 23: self.light.is_on. " + "This will stop working in Home Assistant 2025.11" ) in caplog.text @@ -665,8 +665,8 @@ async def test_async_config_entry_first_refresh_no_entry(hass: HomeAssistant) -> with pytest.raises( RuntimeError, match="Detected code that uses `async_config_entry_first_refresh`, " - "which is only supported for coordinators with a config entry and will " - "stop working in Home Assistant 2025.11. Please report this issue", + "which is only supported for coordinators with a config entry. " + "Please report this issue", ): await crd.async_config_entry_first_refresh() diff --git a/tests/test_core.py b/tests/test_core.py index ed1aad15a2dc2..df2d916e1668d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3040,10 +3040,9 @@ async def _test() -> None: hass.async_run_job(_test) assert ( - "Detected code that calls `async_run_job`, which is deprecated " - "and will be removed in Home Assistant 2025.4; Please review " + "Detected code that calls `async_run_job`, which should be reviewed against " "https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job" - " for replacement options" + " for replacement options. This will stop working in Home Assistant 2025.4" ) in caplog.text @@ -3057,10 +3056,9 @@ async def _test() -> None: hass.async_add_job(_test) assert ( - "Detected code that calls `async_add_job`, which is deprecated " - "and will be removed in Home Assistant 2025.4; Please review " + "Detected code that calls `async_add_job`, which should be reviewed against " "https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job" - " for replacement options" + " for replacement options. This will stop working in Home Assistant 2025.4" ) in caplog.text @@ -3074,10 +3072,9 @@ async def _test() -> None: hass.async_add_hass_job(HassJob(_test)) assert ( - "Detected code that calls `async_add_hass_job`, which is deprecated " - "and will be removed in Home Assistant 2025.5; Please review " + "Detected code that calls `async_add_hass_job`, which should be reviewed against " "https://developers.home-assistant.io/blog/2024/04/07/deprecate_add_hass_job" - " for replacement options" + " for replacement options. This will stop working in Home Assistant 2025.5" ) in caplog.text @@ -3245,8 +3242,8 @@ async def _test(event: ha.Event): func = getattr(hass.bus, method) func(EVENT_HOMEASSISTANT_START, _test, run_immediately=run_immediately) assert ( - f"Detected code that calls `{method}` with run_immediately, which is " - "deprecated and will be removed in Home Assistant 2025.5." + f"Detected code that calls `{method}` with run_immediately. " + "This will stop working in Home Assistant 2025.5" ) in caplog.text diff --git a/tests/test_core_config.py b/tests/test_core_config.py index 4de7ab1e07849..cd77e3608dd6e 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -1075,9 +1075,8 @@ async def test_set_time_zone_deprecated(hass: HomeAssistant) -> None: with pytest.raises( RuntimeError, match=re.escape( - "Detected code that set the time zone using set_time_zone instead of " - "async_set_time_zone which will stop working in Home Assistant 2025.6. " - "Please report this issue", + "Detected code that sets the time zone using set_time_zone instead of " + "async_set_time_zone. Please report this issue" ), ): await hass.config.set_time_zone("America/New_York") diff --git a/tests/test_loader.py b/tests/test_loader.py index 57d3d6fa83253..a39bd63ad0d13 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1327,7 +1327,7 @@ async def test_hass_components_use_reported( reported = ( "Detected that custom integration 'test_integration_frame'" - " accesses hass.components.http. This is deprecated" + " accesses hass.components.http, which should be updated" ) in caplog.text assert reported == expected @@ -2023,7 +2023,7 @@ async def test_hass_helpers_use_reported( reported = ( "Detected that custom integration 'test_integration_frame' " - "accesses hass.helpers.aiohttp_client. This is deprecated" + "accesses hass.helpers.aiohttp_client, which should be updated" ) in caplog.text assert reported == expected From e6715fd4d7fa758418d6213c423c4dfa949ee5e0 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 23 Nov 2024 19:44:40 +0100 Subject: [PATCH 0778/1070] Fix errors in fixtures and tests (#131388) * Fix errors in fixtures and tests * stale function name --- .../fixtures/common_buttons_unavailable.json | 5 +++-- .../habitica/fixtures/healer_fixture.json | 7 ++++--- .../fixtures/healer_skills_unavailable.json | 7 ++++--- .../fixtures/quest_invitation_off.json | 1 + .../habitica/fixtures/rogue_fixture.json | 7 ++++--- .../fixtures/rogue_skills_unavailable.json | 7 ++++--- .../fixtures/rogue_stealth_unavailable.json | 7 ++++--- tests/components/habitica/fixtures/user.json | 10 +++++---- .../habitica/fixtures/warrior_fixture.json | 7 ++++--- .../fixtures/warrior_skills_unavailable.json | 7 ++++--- .../habitica/fixtures/wizard_fixture.json | 7 ++++--- .../fixtures/wizard_frost_unavailable.json | 7 ++++--- .../fixtures/wizard_skills_unavailable.json | 7 ++++--- .../habitica/snapshots/test_sensor.ambr | 16 +++++++------- tests/components/habitica/test_todo.py | 21 +++++++------------ 15 files changed, 66 insertions(+), 57 deletions(-) diff --git a/tests/components/habitica/fixtures/common_buttons_unavailable.json b/tests/components/habitica/fixtures/common_buttons_unavailable.json index efee5364e023d..bcc65ee3f91e8 100644 --- a/tests/components/habitica/fixtures/common_buttons_unavailable.json +++ b/tests/components/habitica/fixtures/common_buttons_unavailable.json @@ -1,4 +1,5 @@ { + "success": true, "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, @@ -45,8 +46,8 @@ "shield": "shield_warrior_5", "back": "heroicAureole", "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", - "eyewear": "plagueDoctorMask", - "body": "aetherAmulet" + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" } } } diff --git a/tests/components/habitica/fixtures/healer_fixture.json b/tests/components/habitica/fixtures/healer_fixture.json index 85f719f4ca7d2..d76ae6126627e 100644 --- a/tests/components/habitica/fixtures/healer_fixture.json +++ b/tests/components/habitica/fixtures/healer_fixture.json @@ -1,4 +1,5 @@ { + "success": true, "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, @@ -48,10 +49,10 @@ "armor": "armor_healer_5", "head": "head_healer_5", "shield": "shield_healer_5", - "back": "heroicAureole", + "back": "back_special_heroicAureole", "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", - "eyewear": "plagueDoctorMask", - "body": "aetherAmulet" + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" } } } diff --git a/tests/components/habitica/fixtures/healer_skills_unavailable.json b/tests/components/habitica/fixtures/healer_skills_unavailable.json index a6bff246b2a93..e3cead40f7d58 100644 --- a/tests/components/habitica/fixtures/healer_skills_unavailable.json +++ b/tests/components/habitica/fixtures/healer_skills_unavailable.json @@ -1,4 +1,5 @@ { + "success": true, "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, @@ -47,10 +48,10 @@ "armor": "armor_healer_5", "head": "head_healer_5", "shield": "shield_healer_5", - "back": "heroicAureole", + "back": "back_special_heroicAureole", "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", - "eyewear": "plagueDoctorMask", - "body": "aetherAmulet" + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" } } } diff --git a/tests/components/habitica/fixtures/quest_invitation_off.json b/tests/components/habitica/fixtures/quest_invitation_off.json index b5eccd99e10b1..0f191696476ea 100644 --- a/tests/components/habitica/fixtures/quest_invitation_off.json +++ b/tests/components/habitica/fixtures/quest_invitation_off.json @@ -1,4 +1,5 @@ { + "success": true, "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, diff --git a/tests/components/habitica/fixtures/rogue_fixture.json b/tests/components/habitica/fixtures/rogue_fixture.json index 1e5e996c03463..b6fcd9f14274c 100644 --- a/tests/components/habitica/fixtures/rogue_fixture.json +++ b/tests/components/habitica/fixtures/rogue_fixture.json @@ -1,4 +1,5 @@ { + "success": true, "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, @@ -48,10 +49,10 @@ "armor": "armor_rogue_5", "head": "head_rogue_5", "shield": "shield_rogue_5", - "back": "heroicAureole", + "back": "back_special_heroicAureole", "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", - "eyewear": "plagueDoctorMask", - "body": "aetherAmulet" + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" } } } diff --git a/tests/components/habitica/fixtures/rogue_skills_unavailable.json b/tests/components/habitica/fixtures/rogue_skills_unavailable.json index c7c5ff322458a..b3bada649fada 100644 --- a/tests/components/habitica/fixtures/rogue_skills_unavailable.json +++ b/tests/components/habitica/fixtures/rogue_skills_unavailable.json @@ -1,4 +1,5 @@ { + "success": true, "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, @@ -47,10 +48,10 @@ "armor": "armor_rogue_5", "head": "head_rogue_5", "shield": "shield_rogue_5", - "back": "heroicAureole", + "back": "back_special_heroicAureole", "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", - "eyewear": "plagueDoctorMask", - "body": "aetherAmulet" + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" } } } diff --git a/tests/components/habitica/fixtures/rogue_stealth_unavailable.json b/tests/components/habitica/fixtures/rogue_stealth_unavailable.json index 9fd7adcca4260..9478feb91fa29 100644 --- a/tests/components/habitica/fixtures/rogue_stealth_unavailable.json +++ b/tests/components/habitica/fixtures/rogue_stealth_unavailable.json @@ -1,4 +1,5 @@ { + "success": true, "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, @@ -47,10 +48,10 @@ "armor": "armor_rogue_5", "head": "head_rogue_5", "shield": "shield_rogue_5", - "back": "heroicAureole", + "back": "back_special_heroicAureole", "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", - "eyewear": "plagueDoctorMask", - "body": "aetherAmulet" + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" } } } diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index e1b77cd31f21b..a498de910efec 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -1,4 +1,5 @@ { + "success": true, "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, @@ -62,7 +63,8 @@ "quest": { "RSVPNeeded": true, "key": "dustbunnies" - } + }, + "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" }, "needsCron": true, "lastCron": "2024-09-21T22:01:55.586Z", @@ -74,10 +76,10 @@ "armor": "armor_warrior_5", "head": "head_warrior_5", "shield": "shield_warrior_5", - "back": "heroicAureole", + "back": "back_special_heroicAureole", "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", - "eyewear": "plagueDoctorMask", - "body": "aetherAmulet" + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" } } } diff --git a/tests/components/habitica/fixtures/warrior_fixture.json b/tests/components/habitica/fixtures/warrior_fixture.json index 3517e8a908aae..97ad9e5b0607c 100644 --- a/tests/components/habitica/fixtures/warrior_fixture.json +++ b/tests/components/habitica/fixtures/warrior_fixture.json @@ -1,4 +1,5 @@ { + "success": true, "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, @@ -48,10 +49,10 @@ "armor": "armor_warrior_5", "head": "head_warrior_5", "shield": "shield_warrior_5", - "back": "heroicAureole", + "back": "back_special_heroicAureole", "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", - "eyewear": "plagueDoctorMask", - "body": "aetherAmulet" + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" } } } diff --git a/tests/components/habitica/fixtures/warrior_skills_unavailable.json b/tests/components/habitica/fixtures/warrior_skills_unavailable.json index b3d33c85d5cd4..f25ca484cbac8 100644 --- a/tests/components/habitica/fixtures/warrior_skills_unavailable.json +++ b/tests/components/habitica/fixtures/warrior_skills_unavailable.json @@ -1,4 +1,5 @@ { + "success": true, "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, @@ -47,10 +48,10 @@ "armor": "armor_warrior_5", "head": "head_warrior_5", "shield": "shield_warrior_5", - "back": "heroicAureole", + "back": "back_special_heroicAureole", "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", - "eyewear": "plagueDoctorMask", - "body": "aetherAmulet" + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" } } } diff --git a/tests/components/habitica/fixtures/wizard_fixture.json b/tests/components/habitica/fixtures/wizard_fixture.json index de596e231de86..655c0ad1f0d66 100644 --- a/tests/components/habitica/fixtures/wizard_fixture.json +++ b/tests/components/habitica/fixtures/wizard_fixture.json @@ -1,4 +1,5 @@ { + "success": true, "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, @@ -48,10 +49,10 @@ "armor": "armor_wizard_5", "head": "head_wizard_5", "shield": "shield_base_0", - "back": "heroicAureole", + "back": "back_special_heroicAureole", "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", - "eyewear": "plagueDoctorMask", - "body": "aetherAmulet" + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" } } } diff --git a/tests/components/habitica/fixtures/wizard_frost_unavailable.json b/tests/components/habitica/fixtures/wizard_frost_unavailable.json index 31d10fde4b992..d5634633a0dff 100644 --- a/tests/components/habitica/fixtures/wizard_frost_unavailable.json +++ b/tests/components/habitica/fixtures/wizard_frost_unavailable.json @@ -1,4 +1,5 @@ { + "success": true, "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, @@ -47,10 +48,10 @@ "armor": "armor_wizard_5", "head": "head_wizard_5", "shield": "shield_base_0", - "back": "heroicAureole", + "back": "back_special_heroicAureole", "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", - "eyewear": "plagueDoctorMask", - "body": "aetherAmulet" + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" } } } diff --git a/tests/components/habitica/fixtures/wizard_skills_unavailable.json b/tests/components/habitica/fixtures/wizard_skills_unavailable.json index f3bdee9dd74ca..eaf5f6f55b81e 100644 --- a/tests/components/habitica/fixtures/wizard_skills_unavailable.json +++ b/tests/components/habitica/fixtures/wizard_skills_unavailable.json @@ -1,4 +1,5 @@ { + "success": true, "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, @@ -47,10 +48,10 @@ "armor": "armor_wizard_5", "head": "head_wizard_5", "shield": "shield_base_0", - "back": "heroicAureole", + "back": "back_special_heroicAureole", "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", - "eyewear": "plagueDoctorMask", - "body": "aetherAmulet" + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" } } } diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 07eddf496b270..250648a5572c7 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -101,7 +101,7 @@ 'allocated': 15, 'buffs': 26, 'class': 0, - 'equipment': 20, + 'equipment': 42, 'friendly_name': 'test-user Constitution', 'level': 19, 'unit_of_measurement': 'CON', @@ -111,7 +111,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '80', + 'state': '102', }) # --- # name: test_sensors[sensor.test_user_dailies-entry] @@ -665,7 +665,7 @@ 'allocated': 15, 'buffs': 26, 'class': 0, - 'equipment': 0, + 'equipment': 12, 'friendly_name': 'test-user Intelligence', 'level': 19, 'unit_of_measurement': 'INT', @@ -675,7 +675,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '60', + 'state': '72', }) # --- # name: test_sensors[sensor.test_user_level-entry] @@ -1007,7 +1007,7 @@ 'allocated': 15, 'buffs': 26, 'class': 0, - 'equipment': 8, + 'equipment': 15, 'friendly_name': 'test-user Perception', 'level': 19, 'unit_of_measurement': 'PER', @@ -1017,7 +1017,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '68', + 'state': '75', }) # --- # name: test_sensors[sensor.test_user_rewards-entry] @@ -1123,7 +1123,7 @@ 'allocated': 15, 'buffs': 26, 'class': 0, - 'equipment': 27, + 'equipment': 44, 'friendly_name': 'test-user Strength', 'level': 19, 'unit_of_measurement': 'STR', @@ -1133,7 +1133,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '87', + 'state': '104', }) # --- # name: test_sensors[sensor.test_user_to_do_s-entry] diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py index c9a4b3dd37a94..66f741eb39a75 100644 --- a/tests/components/habitica/test_todo.py +++ b/tests/components/habitica/test_todo.py @@ -1,7 +1,6 @@ """Tests for Habitica todo platform.""" from collections.abc import Generator -from datetime import datetime from http import HTTPStatus import json import re @@ -39,7 +38,7 @@ @pytest.fixture(autouse=True) -def switch_only() -> Generator[None]: +def todo_only() -> Generator[None]: """Enable only the todo platform.""" with patch( "homeassistant.components.habitica.PLATFORMS", @@ -628,12 +627,12 @@ async def test_move_todo_item_exception( @pytest.mark.parametrize( ("fixture", "calculated_due_date"), [ - ("duedate_fixture_1.json", (2024, 9, 23)), - ("duedate_fixture_2.json", (2024, 9, 24)), - ("duedate_fixture_3.json", (2024, 10, 23)), - ("duedate_fixture_4.json", (2024, 10, 23)), - ("duedate_fixture_5.json", (2024, 9, 28)), - ("duedate_fixture_6.json", (2024, 10, 21)), + ("duedate_fixture_1.json", "2024-09-22"), + ("duedate_fixture_2.json", "2024-09-24"), + ("duedate_fixture_3.json", "2024-10-23"), + ("duedate_fixture_4.json", "2024-10-23"), + ("duedate_fixture_5.json", "2024-09-28"), + ("duedate_fixture_6.json", "2024-10-21"), ("duedate_fixture_7.json", None), ("duedate_fixture_8.json", None), ], @@ -693,8 +692,4 @@ async def test_next_due_date( return_response=True, ) - assert ( - result[dailies_entity]["items"][0].get("due") is None - if not calculated_due_date - else datetime(*calculated_due_date).date() - ) + assert result[dailies_entity]["items"][0].get("due") == calculated_due_date From 1e313c6ff5ff90c2727d5fd799c831926c58dfc9 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sat, 23 Nov 2024 20:29:03 +0100 Subject: [PATCH 0779/1070] Bumb python-homewizard-energy to 7.0.0 (#131366) --- homeassistant/components/homewizard/config_flow.py | 6 +++--- homeassistant/components/homewizard/const.py | 2 +- homeassistant/components/homewizard/coordinator.py | 10 +++++----- homeassistant/components/homewizard/manifest.json | 2 +- homeassistant/components/homewizard/sensor.py | 2 +- homeassistant/components/homewizard/switch.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homewizard/conftest.py | 6 +++--- tests/components/homewizard/test_sensor.py | 2 +- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index d52e53cf39be7..aab6ce055a235 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -6,9 +6,9 @@ import logging from typing import Any, NamedTuple -from homewizard_energy import HomeWizardEnergy +from homewizard_energy import HomeWizardEnergyV1 from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError -from homewizard_energy.models import Device +from homewizard_energy.v1.models import Device from voluptuous import Required, Schema from homeassistant.components import onboarding, zeroconf @@ -177,7 +177,7 @@ async def _async_try_connect(ip_address: str) -> Device: Make connection with device to test the connection and to get info for unique_id. """ - energy_api = HomeWizardEnergy(ip_address) + energy_api = HomeWizardEnergyV1(ip_address) try: return await energy_api.device() diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index 8cee8350268c8..809ecc1416bb4 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging -from homewizard_energy.models import Data, Device, State, System +from homewizard_energy.v1.models import Data, Device, State, System from homeassistant.const import Platform diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index 61b304eb39c42..4a6b8edbca49e 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -4,10 +4,10 @@ import logging -from homewizard_energy import HomeWizardEnergy -from homewizard_energy.const import SUPPORTS_IDENTIFY, SUPPORTS_STATE +from homewizard_energy import HomeWizardEnergyV1 from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError -from homewizard_energy.models import Device +from homewizard_energy.v1.const import SUPPORTS_IDENTIFY, SUPPORTS_STATE +from homewizard_energy.v1.models import Device from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS @@ -23,7 +23,7 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]): """Gather data for the energy device.""" - api: HomeWizardEnergy + api: HomeWizardEnergyV1 api_disabled: bool = False _unsupported_error: bool = False @@ -36,7 +36,7 @@ def __init__( ) -> None: """Initialize update coordinator.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) - self.api = HomeWizardEnergy( + self.api = HomeWizardEnergyV1( self.config_entry.data[CONF_IP_ADDRESS], clientsession=async_get_clientsession(hass), ) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 233c39e5275ca..0ba2ac0eea76a 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/homewizard", "iot_class": "local_polling", "loggers": ["homewizard_energy"], - "requirements": ["python-homewizard-energy==v6.3.0"], + "requirements": ["python-homewizard-energy==v7.0.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 57071875edbab..24ed5933d063c 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Final -from homewizard_energy.models import Data, ExternalDevice +from homewizard_energy.v1.models import Data, ExternalDevice from homeassistant.components.sensor import ( DEVICE_CLASS_UNITS, diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 14c6e0778f130..36cca4663698a 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Any -from homewizard_energy import HomeWizardEnergy +from homewizard_energy import HomeWizardEnergyV1 from homeassistant.components.switch import ( SwitchDeviceClass, @@ -31,7 +31,7 @@ class HomeWizardSwitchEntityDescription(SwitchEntityDescription): available_fn: Callable[[DeviceResponseEntry], bool] create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool] is_on_fn: Callable[[DeviceResponseEntry], bool | None] - set_fn: Callable[[HomeWizardEnergy, bool], Awaitable[Any]] + set_fn: Callable[[HomeWizardEnergyV1, bool], Awaitable[Any]] SWITCHES = [ diff --git a/requirements_all.txt b/requirements_all.txt index d82ae02fbfeae..b5c530a4821ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2347,7 +2347,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.8.0 # homeassistant.components.homewizard -python-homewizard-energy==v6.3.0 +python-homewizard-energy==v7.0.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b5321f689586..cf4a31717159e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1880,7 +1880,7 @@ python-fullykiosk==0.0.14 python-homeassistant-analytics==0.8.0 # homeassistant.components.homewizard -python-homewizard-energy==v6.3.0 +python-homewizard-energy==v7.0.0 # homeassistant.components.izone python-izone==1.2.9 diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index fcfe1e5c1890b..82ec0ecef789d 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from homewizard_energy.errors import NotFoundError -from homewizard_energy.models import Data, Device, State, System +from homewizard_energy.v1.models import Data, Device, State, System import pytest from homeassistant.components.homewizard.const import DOMAIN @@ -27,11 +27,11 @@ def mock_homewizardenergy( """Return a mock bridge.""" with ( patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", + "homeassistant.components.homewizard.coordinator.HomeWizardEnergyV1", autospec=True, ) as homewizard, patch( - "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", + "homeassistant.components.homewizard.config_flow.HomeWizardEnergyV1", new=homewizard, ), ): diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index c180c2a4defbc..60077c2cdf960 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock from homewizard_energy.errors import RequestError -from homewizard_energy.models import Data +from homewizard_energy.v1.models import Data import pytest from syrupy.assertion import SnapshotAssertion From 33983fa9a715f67ffe2c2c00eeedeb74507ac230 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 23 Nov 2024 15:32:05 -0500 Subject: [PATCH 0780/1070] Update snapshots for mashumaro 3.15 (#131406) * Update snapshots * Add it back --- .../snapshots/test_diagnostics.ambr | 2 +- .../snapshots/test_init.ambr | 2 +- .../mealie/snapshots/test_diagnostics.ambr | 66 +++++++++---------- .../mealie/snapshots/test_services.ambr | 36 +++++----- .../wled/snapshots/test_diagnostics.ambr | 2 +- 5 files changed, 54 insertions(+), 54 deletions(-) diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index ee9b75107702e..a0bb8302fcc10 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -123,7 +123,7 @@ 'system': dict({ 'model': 'HUSQVARNA AUTOMOWER® 450XH', 'name': 'Test Mower 1', - 'serial_number': 123, + 'serial_number': '123', }), 'work_area_dict': dict({ '0': 'my_lawn', diff --git a/tests/components/husqvarna_automower/snapshots/test_init.ambr b/tests/components/husqvarna_automower/snapshots/test_init.ambr index e79bd1f8145d2..036783dd6d05e 100644 --- a/tests/components/husqvarna_automower/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_init.ambr @@ -25,7 +25,7 @@ 'name': 'Test Mower 1', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': 123, + 'serial_number': '123', 'suggested_area': 'Garden', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index a694c72fcf696..ecb5d1d6cd196 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -15,7 +15,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': 229, + 'mealplan_id': '229', 'recipe': dict({ 'description': 'The BEST Roast Chicken recipe is simple, budget friendly, and gives you a tender, mouth-watering chicken full of flavor! Served with roasted vegetables, this recipe is simple enough for any cook!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -42,7 +42,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': 230, + 'mealplan_id': '230', 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -67,7 +67,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': 222, + 'mealplan_id': '222', 'recipe': dict({ 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -92,7 +92,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': 221, + 'mealplan_id': '221', 'recipe': dict({ 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -117,7 +117,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': 219, + 'mealplan_id': '219', 'recipe': dict({ 'description': 'This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -142,7 +142,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': 217, + 'mealplan_id': '217', 'recipe': dict({ 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -167,7 +167,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': 212, + 'mealplan_id': '212', 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -192,7 +192,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': 211, + 'mealplan_id': '211', 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -217,7 +217,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': 196, + 'mealplan_id': '196', 'recipe': dict({ 'description': 'Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -242,7 +242,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': 195, + 'mealplan_id': '195', 'recipe': dict({ 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -267,7 +267,7 @@ '__type': "", 'isoformat': '2024-01-21', }), - 'mealplan_id': 1, + 'mealplan_id': '1', 'recipe': None, 'title': 'Aquavite', 'user_id': '6caa6e4d-521f-4ef4-9ed7-388bdd63f47d', @@ -283,7 +283,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': 226, + 'mealplan_id': '226', 'recipe': dict({ 'description': 'Te explicamos paso a paso, de manera sencilla, la elaboración de la receta de pollo al curry con leche de coco en 10 minutos. Ingredientes, tiempo de...', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -308,7 +308,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': 224, + 'mealplan_id': '224', 'recipe': dict({ 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -333,7 +333,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': 216, + 'mealplan_id': '216', 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -360,7 +360,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': 220, + 'mealplan_id': '220', 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -385,15 +385,15 @@ 'checked': False, 'disable_amount': True, 'display': '2 Apples', - 'food_id': None, + 'food_id': 'None', 'is_food': False, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', - 'label_id': None, + 'label_id': 'None', 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': 'Apples', 'position': 0, 'quantity': 2.0, - 'unit_id': None, + 'unit_id': 'None', }), dict({ 'checked': False, @@ -402,7 +402,7 @@ 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', 'is_food': True, 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', - 'label_id': None, + 'label_id': 'None', 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 1, @@ -416,12 +416,12 @@ 'food_id': '96801494-4e26-4148-849a-8155deb76327', 'is_food': True, 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', - 'label_id': None, + 'label_id': 'None', 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 2, 'quantity': 0.0, - 'unit_id': None, + 'unit_id': 'None', }), ]), 'shopping_list': dict({ @@ -435,15 +435,15 @@ 'checked': False, 'disable_amount': True, 'display': '2 Apples', - 'food_id': None, + 'food_id': 'None', 'is_food': False, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', - 'label_id': None, + 'label_id': 'None', 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': 'Apples', 'position': 0, 'quantity': 2.0, - 'unit_id': None, + 'unit_id': 'None', }), dict({ 'checked': False, @@ -452,7 +452,7 @@ 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', 'is_food': True, 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', - 'label_id': None, + 'label_id': 'None', 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 1, @@ -466,12 +466,12 @@ 'food_id': '96801494-4e26-4148-849a-8155deb76327', 'is_food': True, 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', - 'label_id': None, + 'label_id': 'None', 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 2, 'quantity': 0.0, - 'unit_id': None, + 'unit_id': 'None', }), ]), 'shopping_list': dict({ @@ -485,15 +485,15 @@ 'checked': False, 'disable_amount': True, 'display': '2 Apples', - 'food_id': None, + 'food_id': 'None', 'is_food': False, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', - 'label_id': None, + 'label_id': 'None', 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': 'Apples', 'position': 0, 'quantity': 2.0, - 'unit_id': None, + 'unit_id': 'None', }), dict({ 'checked': False, @@ -502,7 +502,7 @@ 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', 'is_food': True, 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', - 'label_id': None, + 'label_id': 'None', 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 1, @@ -516,12 +516,12 @@ 'food_id': '96801494-4e26-4148-849a-8155deb76327', 'is_food': True, 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', - 'label_id': None, + 'label_id': 'None', 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 2, 'quantity': 0.0, - 'unit_id': None, + 'unit_id': 'None', }), ]), 'shopping_list': dict({ diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 4f9ee6a5c0913..93b5f2cad1d39 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -199,7 +199,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': 230, + 'mealplan_id': '230', 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -221,7 +221,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': 229, + 'mealplan_id': '229', 'recipe': dict({ 'description': 'The BEST Roast Chicken recipe is simple, budget friendly, and gives you a tender, mouth-watering chicken full of flavor! Served with roasted vegetables, this recipe is simple enough for any cook!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -243,7 +243,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': 226, + 'mealplan_id': '226', 'recipe': dict({ 'description': 'Te explicamos paso a paso, de manera sencilla, la elaboración de la receta de pollo al curry con leche de coco en 10 minutos. Ingredientes, tiempo de...', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -265,7 +265,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': 224, + 'mealplan_id': '224', 'recipe': dict({ 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -287,7 +287,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': 222, + 'mealplan_id': '222', 'recipe': dict({ 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -309,7 +309,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': 221, + 'mealplan_id': '221', 'recipe': dict({ 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -331,7 +331,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': 220, + 'mealplan_id': '220', 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -353,7 +353,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': 219, + 'mealplan_id': '219', 'recipe': dict({ 'description': 'This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -375,7 +375,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': 217, + 'mealplan_id': '217', 'recipe': dict({ 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -397,7 +397,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': 216, + 'mealplan_id': '216', 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -419,7 +419,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': 212, + 'mealplan_id': '212', 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -441,7 +441,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': 211, + 'mealplan_id': '211', 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -463,7 +463,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': 196, + 'mealplan_id': '196', 'recipe': dict({ 'description': 'Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -485,7 +485,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': 195, + 'mealplan_id': '195', 'recipe': dict({ 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -507,7 +507,7 @@ 'group_id': '3931df86-0679-4579-8c63-4bedc9ca9a85', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 21), - 'mealplan_id': 1, + 'mealplan_id': '1', 'recipe': None, 'title': 'Aquavite', 'user_id': '6caa6e4d-521f-4ef4-9ed7-388bdd63f47d', @@ -714,7 +714,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': datetime.date(2024, 1, 22), - 'mealplan_id': 230, + 'mealplan_id': '230', 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -740,7 +740,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': datetime.date(2024, 1, 22), - 'mealplan_id': 230, + 'mealplan_id': '230', 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -766,7 +766,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': datetime.date(2024, 1, 22), - 'mealplan_id': 230, + 'mealplan_id': '230', 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', diff --git a/tests/components/wled/snapshots/test_diagnostics.ambr b/tests/components/wled/snapshots/test_diagnostics.ambr index 90732c02c363b..46953b004408f 100644 --- a/tests/components/wled/snapshots/test_diagnostics.ambr +++ b/tests/components/wled/snapshots/test_diagnostics.ambr @@ -224,7 +224,7 @@ 'udpport': 21324, 'uptime': 966, 'ver': '0.14.4', - 'vid': 2405180, + 'vid': '2405180', 'wifi': '**REDACTED**', }), 'palettes': dict({ From b11d951ed7441777f474f6db128af258cc36f416 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 23 Nov 2024 22:26:16 +0100 Subject: [PATCH 0781/1070] Add ability to get config_entry as required (#131400) * Add ability to get config_entry as required * One more * Use new API --- homeassistant/config_entries.py | 56 ++++++++++++++------------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a33f21fa22f7b..ef5ade9f98a7a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1828,6 +1828,16 @@ def async_get_entry(self, entry_id: str) -> ConfigEntry | None: """Return entry with matching entry_id.""" return self._entries.data.get(entry_id) + @callback + def async_get_known_entry(self, entry_id: str) -> ConfigEntry: + """Return entry with matching entry_id. + + Raises UnknownEntry if entry is not found. + """ + if (entry := self.async_get_entry(entry_id)) is None: + raise UnknownEntry + return entry + @callback def async_entry_ids(self) -> list[str]: """Return entry ids.""" @@ -1917,8 +1927,7 @@ async def async_remove(self, entry_id: str) -> dict[str, Any]: async def _async_remove(self, entry_id: str) -> tuple[bool, ConfigEntry]: """Remove and unload an entry.""" - if (entry := self.async_get_entry(entry_id)) is None: - raise UnknownEntry + entry = self.async_get_known_entry(entry_id) async with entry.setup_lock: if not entry.state.recoverable: @@ -2011,8 +2020,7 @@ async def async_setup(self, entry_id: str, _lock: bool = True) -> bool: Return True if entry has been successfully loaded. """ - if (entry := self.async_get_entry(entry_id)) is None: - raise UnknownEntry + entry = self.async_get_known_entry(entry_id) if entry.state is not ConfigEntryState.NOT_LOADED: raise OperationNotAllowed( @@ -2043,8 +2051,7 @@ async def async_setup(self, entry_id: str, _lock: bool = True) -> bool: async def async_unload(self, entry_id: str, _lock: bool = True) -> bool: """Unload a config entry.""" - if (entry := self.async_get_entry(entry_id)) is None: - raise UnknownEntry + entry = self.async_get_known_entry(entry_id) if not entry.state.recoverable: raise OperationNotAllowed( @@ -2062,8 +2069,7 @@ async def async_unload(self, entry_id: str, _lock: bool = True) -> bool: @callback def async_schedule_reload(self, entry_id: str) -> None: """Schedule a config entry to be reloaded.""" - if (entry := self.async_get_entry(entry_id)) is None: - raise UnknownEntry + entry = self.async_get_known_entry(entry_id) entry.async_cancel_retry_setup() self.hass.async_create_task( self.async_reload(entry_id), @@ -2081,8 +2087,7 @@ async def async_reload(self, entry_id: str) -> bool: If an entry was not loaded, will just load. """ - if (entry := self.async_get_entry(entry_id)) is None: - raise UnknownEntry + entry = self.async_get_known_entry(entry_id) # Cancel the setup retry task before waiting for the # reload lock to reduce the chance of concurrent reload @@ -2112,8 +2117,7 @@ async def async_set_disabled_by( If disabled_by is changed, the config entry will be reloaded. """ - if (entry := self.async_get_entry(entry_id)) is None: - raise UnknownEntry + entry = self.async_get_known_entry(entry_id) _validate_item(disabled_by=disabled_by) if entry.disabled_by is disabled_by: @@ -3000,9 +3004,7 @@ def _reauth_entry_id(self) -> str: @callback def _get_reauth_entry(self) -> ConfigEntry: """Return the reauth config entry linked to the current context.""" - if entry := self.hass.config_entries.async_get_entry(self._reauth_entry_id): - return entry - raise UnknownEntry + return self.hass.config_entries.async_get_known_entry(self._reauth_entry_id) @property def _reconfigure_entry_id(self) -> str: @@ -3014,11 +3016,9 @@ def _reconfigure_entry_id(self) -> str: @callback def _get_reconfigure_entry(self) -> ConfigEntry: """Return the reconfigure config entry linked to the current context.""" - if entry := self.hass.config_entries.async_get_entry( + return self.hass.config_entries.async_get_known_entry( self._reconfigure_entry_id - ): - return entry - raise UnknownEntry + ) class OptionsFlowManager( @@ -3030,11 +3030,7 @@ class OptionsFlowManager( def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry: """Return config entry or raise if not found.""" - entry = self.hass.config_entries.async_get_entry(config_entry_id) - if entry is None: - raise UnknownEntry(config_entry_id) - - return entry + return self.hass.config_entries.async_get_known_entry(config_entry_id) async def async_create_flow( self, @@ -3068,9 +3064,8 @@ async def async_finish_flow( if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return result - entry = self.hass.config_entries.async_get_entry(flow.handler) - if entry is None: - raise UnknownEntry(flow.handler) + entry = self.hass.config_entries.async_get_known_entry(flow.handler) + if result["data"] is not None: self.hass.config_entries.async_update_entry(entry, options=result["data"]) @@ -3142,9 +3137,7 @@ def config_entry(self) -> ConfigEntry: if self.hass is None: raise ValueError("The config entry is not available during initialisation") - if entry := self.hass.config_entries.async_get_entry(self._config_entry_id): - return entry - raise UnknownEntry + return self.hass.config_entries.async_get_known_entry(self._config_entry_id) @config_entry.setter def config_entry(self, value: ConfigEntry) -> None: @@ -3223,10 +3216,9 @@ def _handle_entry_updated( ): return - config_entry = self.hass.config_entries.async_get_entry( + config_entry = self.hass.config_entries.async_get_known_entry( entity_entry.config_entry_id ) - assert config_entry is not None if config_entry.entry_id not in self.changed and config_entry.supports_unload: self.changed.add(config_entry.entry_id) From cfa8ca877fbee6713331c90da53c1737bf8f621c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 23 Nov 2024 22:58:16 +0100 Subject: [PATCH 0782/1070] Replace "Add" with "Create" in description of Helper (#131403) Replace "Add" with "Create" for Helper type The user can create History stats sensor helpers for different purposes. Following the HA Design guidelines this means that the description should use "Create" not "Add". --- homeassistant/components/history_stats/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/history_stats/strings.json b/homeassistant/components/history_stats/strings.json index 8961d66118d28..aff2ac50bef06 100644 --- a/homeassistant/components/history_stats/strings.json +++ b/homeassistant/components/history_stats/strings.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "Add a history stats sensor", + "description": "Create a history stats sensor", "data": { "name": "[%key:common::config_flow::data::name%]", "entity_id": "Entity", From 913ec53f8c7dc35eb04285c1d89f9e471d7e993b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 23 Nov 2024 22:58:38 +0100 Subject: [PATCH 0783/1070] Replace "Add" with "Create" in description of Helper (#131407) The user can create Statistics helpers for different purposes. Following the HA Design guidelines this means that the description in the dialog should use "Create" not "Add". --- homeassistant/components/statistics/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/statistics/strings.json b/homeassistant/components/statistics/strings.json index 3e6fec9d986fb..91aead261fffb 100644 --- a/homeassistant/components/statistics/strings.json +++ b/homeassistant/components/statistics/strings.json @@ -10,7 +10,7 @@ }, "step": { "user": { - "description": "Add a statistics sensor", + "description": "Create a statistics sensor", "data": { "name": "[%key:common::config_flow::data::name%]", "entity_id": "Entity" From 9a2eb8410eb6e14b36d4f410755b674368c561c2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 23 Nov 2024 22:59:09 +0100 Subject: [PATCH 0784/1070] Replace "Add" with "Create" in description of Helper (#131405) The user can create Mold indicator helpers for different purposes. Following the HA Design guidelines this means that the description in the dialog should use "Create" not "Add". --- homeassistant/components/mold_indicator/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mold_indicator/strings.json b/homeassistant/components/mold_indicator/strings.json index e19fed690b200..74614bba1395b 100644 --- a/homeassistant/components/mold_indicator/strings.json +++ b/homeassistant/components/mold_indicator/strings.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "Add Mold indicator helper", + "description": "Create Mold indicator helper", "data": { "name": "[%key:common::config_flow::data::name%]", "indoor_humidity_sensor": "Indoor humidity sensor", From 832d5e27fee5776d03f234996b7c1e3d564ff9b4 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 23 Nov 2024 23:01:33 +0100 Subject: [PATCH 0785/1070] Remove deprecation warnings for KNX yaml (#131402) --- homeassistant/components/knx/__init__.py | 15 --------------- homeassistant/components/knx/schema.py | 9 --------- 2 files changed, 24 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index fe6f3ad88922a..9180e287618b1 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -29,7 +29,6 @@ ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.storage import STORAGE_DIR @@ -102,20 +101,6 @@ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( - # deprecated since 2021.12 - cv.deprecated(CONF_KNX_STATE_UPDATER), - cv.deprecated(CONF_KNX_RATE_LIMIT), - cv.deprecated(CONF_KNX_ROUTING), - cv.deprecated(CONF_KNX_TUNNELING), - cv.deprecated(CONF_KNX_INDIVIDUAL_ADDRESS), - cv.deprecated(CONF_KNX_MCAST_GRP), - cv.deprecated(CONF_KNX_MCAST_PORT), - cv.deprecated("event_filter"), - # deprecated since 2021.4 - cv.deprecated("config_file"), - # deprecated since 2021.2 - cv.deprecated("fire_event"), - cv.deprecated("fire_event_filter"), vol.Schema( { **EventSchema.SCHEMA, diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index bf2fc55e5c944..9311046e410f7 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -222,9 +222,6 @@ class BinarySensorSchema(KNXPlatformSchema): DEFAULT_NAME = "KNX Binary Sensor" ENTITY_SCHEMA = vol.All( - # deprecated since September 2020 - cv.deprecated("significant_bit"), - cv.deprecated("automation"), vol.Schema( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -358,10 +355,6 @@ class ClimateSchema(KNXPlatformSchema): DEFAULT_FAN_SPEED_MODE = "percent" ENTITY_SCHEMA = vol.All( - # deprecated since September 2020 - cv.deprecated("setpoint_shift_step", replacement_key=CONF_TEMPERATURE_STEP), - # deprecated since 2021.6 - cv.deprecated("create_temperature_sensors"), vol.Schema( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -969,8 +962,6 @@ class WeatherSchema(KNXPlatformSchema): DEFAULT_NAME = "KNX Weather Station" ENTITY_SCHEMA = vol.All( - # deprecated since 2021.6 - cv.deprecated("create_sensors"), vol.Schema( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, From 2d779a4e4cab1623773cc4ca665d07c78f5e4113 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 23 Nov 2024 23:12:39 +0100 Subject: [PATCH 0786/1070] Mark IQS rule `config-flow` as todo in AVM Fritz!BOX Tools (#131419) mark rule `config-flow` as todo --- homeassistant/components/fritz/quality_scale.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 7f1b49fe5d41f..b832492cf9d6a 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -9,7 +9,9 @@ rules: config-flow-test-coverage: status: todo comment: one coverage miss in line 110 - config-flow: done + config-flow: + status: todo + comment: data_description are missing dependency-transparency: done docs-actions: done docs-high-level-description: done From 0a8dde3740d71df4a2d093b2ea9a446f94aa5655 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Nov 2024 16:17:20 -0600 Subject: [PATCH 0787/1070] Bump yalexs-ble to 2.5.1 (#131398) changelog: https://github.com/bdraco/yalexs-ble/compare/v2.5.0...v2.5.1 Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 4bc7e77d2d844..96ed982e4ec9b 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.0"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.1"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 34f3a7a172840..50c2a0af457eb 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.0"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.1"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 1baeaeea63f8c..c3d1a3d97f143 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.5.0"] + "requirements": ["yalexs-ble==2.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index b5c530a4821ef..d01ab5171ee1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3044,7 +3044,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.0 +yalexs-ble==2.5.1 # homeassistant.components.august # homeassistant.components.yale diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf4a31717159e..288d121dddf90 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2433,7 +2433,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.0 +yalexs-ble==2.5.1 # homeassistant.components.august # homeassistant.components.yale From 7ba3ce67f178b3ecfb8b72fea02ca0ef61a0ecca Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 24 Nov 2024 00:12:40 +0100 Subject: [PATCH 0788/1070] Use short namespace for dr and er in config_entries (#131412) --- homeassistant/config_entries.py | 36 ++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ef5ade9f98a7a..ade4cd855cac2 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -54,7 +54,12 @@ ConfigEntryNotReady, HomeAssistantError, ) -from .helpers import device_registry, entity_registry, issue_registry as ir, storage +from .helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, + storage, +) from .helpers.debounce import Debouncer from .helpers.discovery_flow import DiscoveryKey from .helpers.dispatcher import SignalType, async_dispatcher_send_internal @@ -1948,8 +1953,8 @@ def _async_clean_up(self, entry: ConfigEntry) -> None: """Clean up after an entry.""" entry_id = entry.entry_id - dev_reg = device_registry.async_get(self.hass) - ent_reg = entity_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) + ent_reg = er.async_get(self.hass) dev_reg.async_clear_config_entry(entry_id) ent_reg.async_clear_config_entry(entry_id) @@ -2126,21 +2131,21 @@ async def async_set_disabled_by( entry.disabled_by = disabled_by self._async_schedule_save() - dev_reg = device_registry.async_get(self.hass) - ent_reg = entity_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) + ent_reg = er.async_get(self.hass) if not entry.disabled_by: # The config entry will no longer be disabled, enable devices and entities - device_registry.async_config_entry_disabled_by_changed(dev_reg, entry) - entity_registry.async_config_entry_disabled_by_changed(ent_reg, entry) + dr.async_config_entry_disabled_by_changed(dev_reg, entry) + er.async_config_entry_disabled_by_changed(ent_reg, entry) # Load or unload the config entry reload_result = await self.async_reload(entry_id) if entry.disabled_by: # The config entry has been disabled, disable devices and entities - device_registry.async_config_entry_disabled_by_changed(dev_reg, entry) - entity_registry.async_config_entry_disabled_by_changed(ent_reg, entry) + dr.async_config_entry_disabled_by_changed(dev_reg, entry) + er.async_config_entry_disabled_by_changed(ent_reg, entry) return reload_result @@ -3182,7 +3187,7 @@ class EntityRegistryDisabledHandler: def __init__(self, hass: HomeAssistant) -> None: """Initialize the handler.""" self.hass = hass - self.registry: entity_registry.EntityRegistry | None = None + self.registry: er.EntityRegistry | None = None self.changed: set[str] = set() self._remove_call_later: Callable[[], None] | None = None @@ -3190,18 +3195,18 @@ def __init__(self, hass: HomeAssistant) -> None: def async_setup(self) -> None: """Set up the disable handler.""" self.hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entry_updated, event_filter=_handle_entry_updated_filter, ) @callback def _handle_entry_updated( - self, event: Event[entity_registry.EventEntityRegistryUpdatedData] + self, event: Event[er.EventEntityRegistryUpdatedData] ) -> None: """Handle entity registry entry update.""" if self.registry is None: - self.registry = entity_registry.async_get(self.hass) + self.registry = er.async_get(self.hass) entity_entry = self.registry.async_get(event.data["entity_id"]) @@ -3258,7 +3263,7 @@ def _async_handle_reload(self, _now: Any) -> None: @callback def _handle_entry_updated_filter( - event_data: entity_registry.EventEntityRegistryUpdatedData, + event_data: er.EventEntityRegistryUpdatedData, ) -> bool: """Handle entity registry entry update filter. @@ -3268,8 +3273,7 @@ def _handle_entry_updated_filter( return not ( event_data["action"] != "update" or "disabled_by" not in event_data["changes"] - or event_data["changes"]["disabled_by"] - is entity_registry.RegistryEntryDisabler.CONFIG_ENTRY + or event_data["changes"]["disabled_by"] is er.RegistryEntryDisabler.CONFIG_ENTRY ) From d527788a606b0336f5c0317f2c377ed910da4764 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Sat, 23 Nov 2024 18:44:31 -0500 Subject: [PATCH 0789/1070] Bump py-aosmith to 1.0.11 (#131422) --- homeassistant/components/aosmith/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json index 4cd1eb32cd194..eae7981d5b9b9 100644 --- a/homeassistant/components/aosmith/manifest.json +++ b/homeassistant/components/aosmith/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aosmith", "iot_class": "cloud_polling", - "requirements": ["py-aosmith==1.0.10"] + "requirements": ["py-aosmith==1.0.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index d01ab5171ee1d..6f40455262461 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1672,7 +1672,7 @@ pushover_complete==1.1.1 pvo==2.1.1 # homeassistant.components.aosmith -py-aosmith==1.0.10 +py-aosmith==1.0.11 # homeassistant.components.canary py-canary==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 288d121dddf90..8d4cec109f251 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1367,7 +1367,7 @@ pushover_complete==1.1.1 pvo==2.1.1 # homeassistant.components.aosmith -py-aosmith==1.0.10 +py-aosmith==1.0.11 # homeassistant.components.canary py-canary==0.5.4 From 60cf79765039eafa94897343ea0393f5233d40f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Nov 2024 17:25:45 -0800 Subject: [PATCH 0790/1070] Bump aioesphomeapi to 27.0.2 (#131397) Fixes for cancellation during Bluetooth connect changelog: https://github.com/esphome/aioesphomeapi/compare/v27.0.1...v27.0.2 Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 2d6fae0816ac6..5524e87e2a80c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==27.0.1", + "aioesphomeapi==27.0.2", "esphome-dashboard-api==1.2.3", "bleak-esphome==1.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 6f40455262461..f0fb753f9d7db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==27.0.1 +aioesphomeapi==27.0.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d4cec109f251..0c1b3e9446fd8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==27.0.1 +aioesphomeapi==27.0.2 # homeassistant.components.flo aioflo==2021.11.0 From d65d5ceac75da9048931e66ee5fd735050af82db Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 24 Nov 2024 11:09:46 +0100 Subject: [PATCH 0791/1070] Small cleanup in Trafikverket Camera (#131424) --- .../components/trafikverket_camera/__init__.py | 4 ++-- .../components/trafikverket_camera/config_flow.py | 10 +++++++--- .../components/trafikverket_camera/coordinator.py | 15 +++++++-------- tests/components/trafikverket_camera/conftest.py | 2 +- .../trafikverket_camera/test_binary_sensor.py | 2 +- .../components/trafikverket_camera/test_camera.py | 2 +- .../trafikverket_camera/test_config_flow.py | 8 ++++++-- .../trafikverket_camera/test_coordinator.py | 4 ++-- tests/components/trafikverket_camera/test_init.py | 3 +-- .../trafikverket_camera/test_recorder.py | 2 +- .../components/trafikverket_camera/test_sensor.py | 2 +- 11 files changed, 30 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index 938bfce23184b..614072cc7066c 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -4,7 +4,7 @@ import logging -from pytrafikverket.trafikverket_camera import TrafikverketCamera +from pytrafikverket import TrafikverketCamera from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TVCameraConfigEntry) -> bool: """Set up Trafikverket Camera from a config entry.""" - coordinator = TVDataUpdateCoordinator(hass) + coordinator = TVDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index 18e210beb16c2..29f3db7beac60 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -5,9 +5,13 @@ from collections.abc import Mapping from typing import Any -from pytrafikverket.exceptions import InvalidAuthentication, NoCameraFound, UnknownError -from pytrafikverket.models import CameraInfoModel -from pytrafikverket.trafikverket_camera import TrafikverketCamera +from pytrafikverket import ( + CameraInfoModel, + InvalidAuthentication, + NoCameraFound, + TrafikverketCamera, + UnknownError, +) import voluptuous as vol from homeassistant.config_entries import ( diff --git a/homeassistant/components/trafikverket_camera/coordinator.py b/homeassistant/components/trafikverket_camera/coordinator.py index 7bc5c556c00b9..649eb10257504 100644 --- a/homeassistant/components/trafikverket_camera/coordinator.py +++ b/homeassistant/components/trafikverket_camera/coordinator.py @@ -9,14 +9,14 @@ from typing import TYPE_CHECKING import aiohttp -from pytrafikverket.exceptions import ( +from pytrafikverket import ( + CameraInfoModel, InvalidAuthentication, MultipleCamerasFound, NoCameraFound, + TrafikverketCamera, UnknownError, ) -from pytrafikverket.models import CameraInfoModel -from pytrafikverket.trafikverket_camera import TrafikverketCamera from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant @@ -44,21 +44,20 @@ class CameraData: class TVDataUpdateCoordinator(DataUpdateCoordinator[CameraData]): """A Trafikverket Data Update Coordinator.""" - config_entry: TVCameraConfigEntry - - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: TVCameraConfigEntry) -> None: """Initialize the Trafikverket coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=TIME_BETWEEN_UPDATES, ) self.session = async_get_clientsession(hass) self._camera_api = TrafikverketCamera( - self.session, self.config_entry.data[CONF_API_KEY] + self.session, config_entry.data[CONF_API_KEY] ) - self._id = self.config_entry.data[CONF_ID] + self._id = config_entry.data[CONF_ID] async def _async_update_data(self) -> CameraData: """Fetch data from Trafikverket.""" diff --git a/tests/components/trafikverket_camera/conftest.py b/tests/components/trafikverket_camera/conftest.py index cef85af222881..5e0e9bfa593b3 100644 --- a/tests/components/trafikverket_camera/conftest.py +++ b/tests/components/trafikverket_camera/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest -from pytrafikverket.models import CameraInfoModel +from pytrafikverket import CameraInfoModel from homeassistant.components.trafikverket_camera.const import DOMAIN from homeassistant.config_entries import SOURCE_USER diff --git a/tests/components/trafikverket_camera/test_binary_sensor.py b/tests/components/trafikverket_camera/test_binary_sensor.py index 6750c05772b6a..46cf93726c7b3 100644 --- a/tests/components/trafikverket_camera/test_binary_sensor.py +++ b/tests/components/trafikverket_camera/test_binary_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from pytrafikverket.models import CameraInfoModel +from pytrafikverket import CameraInfoModel from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON diff --git a/tests/components/trafikverket_camera/test_camera.py b/tests/components/trafikverket_camera/test_camera.py index 51d4563c19b0d..f61dd497c9c9b 100644 --- a/tests/components/trafikverket_camera/test_camera.py +++ b/tests/components/trafikverket_camera/test_camera.py @@ -7,7 +7,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest -from pytrafikverket.models import CameraInfoModel +from pytrafikverket import CameraInfoModel from homeassistant.components.camera import async_get_image from homeassistant.config_entries import ConfigEntry diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index 48162a17e2c4e..cc37e2b5441a3 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -5,8 +5,12 @@ from unittest.mock import patch import pytest -from pytrafikverket.exceptions import InvalidAuthentication, NoCameraFound, UnknownError -from pytrafikverket.models import CameraInfoModel +from pytrafikverket import ( + CameraInfoModel, + InvalidAuthentication, + NoCameraFound, + UnknownError, +) from homeassistant import config_entries from homeassistant.components.trafikverket_camera.const import DOMAIN diff --git a/tests/components/trafikverket_camera/test_coordinator.py b/tests/components/trafikverket_camera/test_coordinator.py index f50ab56724e47..7deeeccf8ad9c 100644 --- a/tests/components/trafikverket_camera/test_coordinator.py +++ b/tests/components/trafikverket_camera/test_coordinator.py @@ -5,13 +5,13 @@ from unittest.mock import patch import pytest -from pytrafikverket.exceptions import ( +from pytrafikverket import ( + CameraInfoModel, InvalidAuthentication, MultipleCamerasFound, NoCameraFound, UnknownError, ) -from pytrafikverket.models import CameraInfoModel from homeassistant.components.trafikverket_camera.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState diff --git a/tests/components/trafikverket_camera/test_init.py b/tests/components/trafikverket_camera/test_init.py index aaa4c3cfed7f8..5b77f17ac3edc 100644 --- a/tests/components/trafikverket_camera/test_init.py +++ b/tests/components/trafikverket_camera/test_init.py @@ -6,8 +6,7 @@ from unittest.mock import patch import pytest -from pytrafikverket.exceptions import UnknownError -from pytrafikverket.models import CameraInfoModel +from pytrafikverket import CameraInfoModel, UnknownError from homeassistant.components.trafikverket_camera import async_migrate_entry from homeassistant.components.trafikverket_camera.const import DOMAIN diff --git a/tests/components/trafikverket_camera/test_recorder.py b/tests/components/trafikverket_camera/test_recorder.py index d9778ab851adc..c14f05ca7ab9c 100644 --- a/tests/components/trafikverket_camera/test_recorder.py +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from pytrafikverket.models import CameraInfoModel +from pytrafikverket import CameraInfoModel from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states diff --git a/tests/components/trafikverket_camera/test_sensor.py b/tests/components/trafikverket_camera/test_sensor.py index 0f4ef02a8506d..f8e0342b0f611 100644 --- a/tests/components/trafikverket_camera/test_sensor.py +++ b/tests/components/trafikverket_camera/test_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from pytrafikverket.models import CameraInfoModel +from pytrafikverket import CameraInfoModel from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant From 07e8d2d11d465057519f7d6729e9afd1559a976b Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sun, 24 Nov 2024 11:26:11 +0100 Subject: [PATCH 0792/1070] Set parallel updates for acaia (#131306) --- homeassistant/components/acaia/button.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/acaia/button.py b/homeassistant/components/acaia/button.py index 50671eecbba53..a41233bfc174a 100644 --- a/homeassistant/components/acaia/button.py +++ b/homeassistant/components/acaia/button.py @@ -13,6 +13,8 @@ from .coordinator import AcaiaConfigEntry from .entity import AcaiaEntity +PARALLEL_UPDATES = 0 + @dataclass(kw_only=True, frozen=True) class AcaiaButtonEntityDescription(ButtonEntityDescription): From c402bb5da03699535df96aaf9d755eb5424f226b Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sun, 24 Nov 2024 11:27:39 +0100 Subject: [PATCH 0793/1070] Mark HomeWizard docs quality scale requirements as done (#131414) --- homeassistant/components/homewizard/quality_scale.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml index 4d8e31f7f3fbb..175a5b6c79779 100644 --- a/homeassistant/components/homewizard/quality_scale.yaml +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -52,13 +52,13 @@ rules: The integration doesn't update the device info based on DHCP discovery of known existing devices. discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | From 66450d7912095d214d726097d33bdeacc856d7da Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 24 Nov 2024 11:28:37 +0100 Subject: [PATCH 0794/1070] Add quality_scale.yaml to hassfest pre-commit filter (#131392) --- .pre-commit-config.yaml | 2 +- script/hassfest/quality_scale.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eff1eafe6fde0..3a20276c8814a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -83,7 +83,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$ + files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(quality_scale)\.yaml|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$ - id: hassfest-metadata name: hassfest-metadata entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index e37aacb776b68..1a9bb31083a4c 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1314,7 +1314,7 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: if integration.domain in INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE: integration.add_error( "quality_scale", - "Quality scale file found! Please remove from quality_scale.py", + "Quality scale file found! Please remove from script/hassfest/quality_scale.py", ) return name = str(iqs_file) From 00ea56e0856c0f9809ebc9deb5b165b0ad336d50 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 24 Nov 2024 11:30:05 +0100 Subject: [PATCH 0795/1070] Add quality scale for IMAP integration (#131289) --- .../components/imap/quality_scale.yaml | 97 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/imap/quality_scale.yaml diff --git a/homeassistant/components/imap/quality_scale.yaml b/homeassistant/components/imap/quality_scale.yaml new file mode 100644 index 0000000000000..180aef93f9113 --- /dev/null +++ b/homeassistant/components/imap/quality_scale.yaml @@ -0,0 +1,97 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: + status: todo + comment: | + The package is only tested, but not built and published inside a CI pipeline yet. + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: > + Per IMAP service instance there is one numeric sensor entity to reflect + the actual number of emails for a service. There is no event registration. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + log-when-unavailable: + status: done + comment: | + Logs for unavailability are on debug level to avoid flooding the logs. + entity-unavailable: + status: done + comment: > + An entity is available as long as the service is loaded. + An `unknown` value is set if the mail service is temporary unavailable. + action-exceptions: done + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: done + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: + status: done + comment: The only entity supplied returns the primary value for the service. + discovery: + status: exempt + comment: | + Discovery for IMAP services is not desirerable. + stale-devices: + status: exempt + comment: > + The device class is a service. When removed, entities are removed as well. + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: todo + comment: | + Options can be set through the option flow, reconfiguration is not supported yet. + dynamic-devices: + status: exempt + comment: | + The device class is a service. + discovery-update-info: + status: exempt + comment: Discovery is not desirable for this integration. + repair-issues: + status: exempt + comment: There are no repairs currently. + docs-use-cases: done + docs-supported-devices: + status: exempt + comment: The device class is a service. + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not use web sessions. + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 1a9bb31083a4c..bbb2d3e4d0a17 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -493,7 +493,6 @@ "iglo", "ign_sismologia", "ihc", - "imap", "imgw_pib", "improv_ble", "incomfort", From c9ede11b1f61556a9345ccccad28ffef9990b34b Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 24 Nov 2024 11:31:32 +0100 Subject: [PATCH 0796/1070] Add entity picture for mystic hourglasses to Habitica (#131428) --- homeassistant/components/habitica/sensor.py | 1 + tests/components/habitica/snapshots/test_sensor.ambr | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index d6943fcae56ce..41d0168d74867 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -158,6 +158,7 @@ class HabitipySensorEntity(StrEnum): ), suggested_display_precision=0, native_unit_of_measurement="⧖", + entity_picture="notif_subscriber_reward.png", ), HabitipySensorEntityDescription( key=HabitipySensorEntity.STRENGTH, diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 250648a5572c7..28dd7eb8c4381 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -907,6 +907,7 @@ # name: test_sensors[sensor.test_user_mystic_hourglasses-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/notif_subscriber_reward.png', 'friendly_name': 'test-user Mystic hourglasses', 'unit_of_measurement': '⧖', }), From ca3be6661a9b9348b3472c7dc481fb3357bae841 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 24 Nov 2024 11:36:40 +0100 Subject: [PATCH 0797/1070] Remove deprecated yaml import in media extractor (#131426) --- .../components/media_extractor/__init__.py | 41 +------------------ .../components/media_extractor/config_flow.py | 4 -- .../media_extractor/test_config_flow.py | 15 +------ tests/components/media_extractor/test_init.py | 7 +++- 4 files changed, 8 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index b8bb5f98cd004..79fa9d6fb9a03 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -16,10 +16,9 @@ MEDIA_PLAYER_PLAY_MEDIA_SCHEMA, SERVICE_PLAY_MEDIA, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, ServiceCall, ServiceResponse, @@ -27,7 +26,6 @@ ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import ( @@ -43,19 +41,7 @@ CONF_CUSTOMIZE_ENTITIES = "customize" CONF_DEFAULT_STREAM_QUERY = "default_query" -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_DEFAULT_STREAM_QUERY): cv.string, - vol.Optional(CONF_CUSTOMIZE_ENTITIES): vol.Schema( - {cv.entity_id: vol.Schema({cv.string: cv.string})} - ), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -67,29 +53,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the media extractor service.""" - if DOMAIN in config: - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Media extractor", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - ) - ) - async def extract_media_url(call: ServiceCall) -> ServiceResponse: """Extract media url.""" diff --git a/homeassistant/components/media_extractor/config_flow.py b/homeassistant/components/media_extractor/config_flow.py index b91942d7b13dc..cb2166c35f15b 100644 --- a/homeassistant/components/media_extractor/config_flow.py +++ b/homeassistant/components/media_extractor/config_flow.py @@ -24,7 +24,3 @@ async def async_step_user( return self.async_create_entry(title="Media extractor", data={}) return self.async_show_form(step_id="user", data_schema=vol.Schema({})) - - async def async_step_import(self, import_data: None) -> ConfigFlowResult: - """Handle import.""" - return self.async_create_entry(title="Media extractor", data={}) diff --git a/tests/components/media_extractor/test_config_flow.py b/tests/components/media_extractor/test_config_flow.py index bfee5ec4879c3..786341fd55355 100644 --- a/tests/components/media_extractor/test_config_flow.py +++ b/tests/components/media_extractor/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for the Media extractor config flow.""" from homeassistant.components.media_extractor.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -41,16 +41,3 @@ async def test_single_instance_allowed(hass: HomeAssistant) -> None: assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "single_instance_allowed" - - -async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None: - """Test import flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "Media extractor" - assert result.get("data") == {} - assert result.get("options") == {} - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/media_extractor/test_init.py b/tests/components/media_extractor/test_init.py index bc80e063697cc..21fab6f875ceb 100644 --- a/tests/components/media_extractor/test_init.py +++ b/tests/components/media_extractor/test_init.py @@ -22,12 +22,15 @@ from . import YOUTUBE_EMPTY_PLAYLIST, YOUTUBE_PLAYLIST, YOUTUBE_VIDEO, MockYoutubeDL from .const import NO_FORMATS_RESPONSE, SOUNDCLOUD_TRACK -from tests.common import load_json_object_fixture +from tests.common import MockConfigEntry, load_json_object_fixture async def test_play_media_service_is_registered(hass: HomeAssistant) -> None: """Test play media service is registered.""" - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + mock_config_entry = MockConfigEntry(domain=DOMAIN) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert hass.services.has_service(DOMAIN, SERVICE_PLAY_MEDIA) From 767ac4068550beb43f41bee3019fe6d676d89de8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 24 Nov 2024 11:37:29 +0100 Subject: [PATCH 0798/1070] Fix language picker in workday (#131423) --- homeassistant/components/workday/config_flow.py | 6 ++++-- tests/components/workday/test_config_flow.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 727c4340ea3c7..2036d685d3139 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -67,12 +67,14 @@ def add_province_and_language_to_schema( _country = country_holidays(country=country) if country_default_language := (_country.default_language): selectable_languages = _country.supported_languages - new_selectable_languages = [lang[:2] for lang in selectable_languages] + new_selectable_languages = list(selectable_languages) language_schema = { vol.Optional( CONF_LANGUAGE, default=country_default_language ): LanguageSelector( - LanguageSelectorConfig(languages=new_selectable_languages) + LanguageSelectorConfig( + languages=new_selectable_languages, native_name=True + ) ) } diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index cc83cee93a261..1bf0f176fe9c6 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -557,7 +557,7 @@ async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: ("language", "holiday"), [ ("de", "Weihnachtstag"), - ("en", "Christmas"), + ("en_US", "Christmas"), ], ) async def test_language( From 106602669df92746dbcbbaae6fed3d5772f0e1b9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 24 Nov 2024 02:39:04 -0800 Subject: [PATCH 0799/1070] Set data description for all Rainbird config flow fields (#131432) --- homeassistant/components/rainbird/quality_scale.yaml | 4 +--- homeassistant/components/rainbird/strings.json | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rainbird/quality_scale.yaml b/homeassistant/components/rainbird/quality_scale.yaml index 1626f93a00350..63ea38d47bde9 100644 --- a/homeassistant/components/rainbird/quality_scale.yaml +++ b/homeassistant/components/rainbird/quality_scale.yaml @@ -1,8 +1,6 @@ rules: # Bronze - config-flow: - status: todo - comment: Some fields are missing data descriptions. + config-flow: done brands: done dependency-transparency: done common-modules: done diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index ea0d64f620894..61498b3681677 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -9,7 +9,8 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your Rain Bird device." + "host": "The hostname or IP address of your Rain Bird device.", + "password": "The password used to authenticate with the Rain Bird device." } } }, From 7b139b75aef5dc665ad856a0fb26c4baf1ecca7d Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Sun, 24 Nov 2024 11:40:27 +0100 Subject: [PATCH 0800/1070] Add data description to config flow for fyta (#131441) --- homeassistant/components/fyta/strings.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index bacd24555b08b..3fb01ba042231 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -7,6 +7,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The username to login to your FYTA account.", + "password": "The password to login to your FYTA account." } }, "reauth_confirm": { @@ -14,6 +18,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::fyta::config::step::user::data_description::username%]", + "password": "[%key:component::fyta::config::step::user::data_description::password%]" } } }, From 5bdbd4360e222514bacfed1c98a3310fdf602779 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sun, 24 Nov 2024 11:41:05 +0100 Subject: [PATCH 0801/1070] Add data description for acaia (#131437) --- homeassistant/components/acaia/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/acaia/strings.json b/homeassistant/components/acaia/strings.json index 0e52e2c0b2f86..e0e97b7c2ffbd 100644 --- a/homeassistant/components/acaia/strings.json +++ b/homeassistant/components/acaia/strings.json @@ -18,6 +18,9 @@ "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { "address": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "address": "Select Acaia scale you want to set up" } } } From 5b27f07f81dc38dab50c1919474c8524e6bf329d Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sun, 24 Nov 2024 11:41:50 +0100 Subject: [PATCH 0802/1070] Add data description for lamarzocco (#131435) --- homeassistant/components/lamarzocco/strings.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index e0e2ba105f28e..a9784eadf9ae4 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -25,7 +25,10 @@ "bluetooth_selection": { "description": "Select your device from available Bluetooth devices.", "data": { - "mac": "Bluetooth device" + "mac": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "mac": "Select the Bluetooth device that is your machine" } }, "machine_selection": { @@ -35,7 +38,8 @@ "machine": "Machine" }, "data_description": { - "host": "Local IP address of the machine" + "host": "Local IP address of the machine", + "machine": "Select the machine you want to integrate" } }, "reauth_confirm": { From 076a351ce4f98d3c986ee8d2254759a4d701217a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 24 Nov 2024 15:28:07 +0100 Subject: [PATCH 0803/1070] Add keepalive `data_description` for mqtt (#131446) --- homeassistant/components/mqtt/strings.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 8ab31e378578a..7cf3578356973 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -61,6 +61,7 @@ "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", "client_cert": "The client certificate to authenticate against your MQTT broker.", "client_key": "The private key file that belongs to your client certificate.", + "keepalive": "A value less than 90 seconds is advised.", "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", "set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT brokers certificate.", @@ -172,6 +173,7 @@ "client_id": "[%key:component::mqtt::config::step::broker::data_description::client_id%]", "client_cert": "[%key:component::mqtt::config::step::broker::data_description::client_cert%]", "client_key": "[%key:component::mqtt::config::step::broker::data_description::client_key%]", + "keepalive": "[%key:component::mqtt::config::step::broker::data_description::keepalive%]", "tls_insecure": "[%key:component::mqtt::config::step::broker::data_description::tls_insecure%]", "protocol": "[%key:component::mqtt::config::step::broker::data_description::protocol%]", "set_ca_cert": "[%key:component::mqtt::config::step::broker::data_description::set_ca_cert%]", From d790a2d74c31b9b5af871e14e39ae0784ff0d20e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 24 Nov 2024 17:11:56 +0100 Subject: [PATCH 0804/1070] Allow Alexa to stop a cover (#130846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Allow Alexa to stop a cover * Fix tests * Update tests/components/alexa/test_smart_home.py Co-authored-by: Abílio Costa --------- Co-authored-by: Abílio Costa --- .../components/alexa/capabilities.py | 20 +-- homeassistant/components/alexa/entities.py | 4 + homeassistant/components/alexa/handlers.py | 23 +++- tests/components/alexa/test_smart_home.py | 114 +++++++++++++++++- 4 files changed, 146 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 09b461428ac4e..b2cda8ad76e75 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -816,13 +816,19 @@ def supported_operations(self) -> list[str]: """ supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - operations = { - media_player.MediaPlayerEntityFeature.NEXT_TRACK: "Next", - media_player.MediaPlayerEntityFeature.PAUSE: "Pause", - media_player.MediaPlayerEntityFeature.PLAY: "Play", - media_player.MediaPlayerEntityFeature.PREVIOUS_TRACK: "Previous", - media_player.MediaPlayerEntityFeature.STOP: "Stop", - } + operations: dict[ + cover.CoverEntityFeature | media_player.MediaPlayerEntityFeature, str + ] + if self.entity.domain == cover.DOMAIN: + operations = {cover.CoverEntityFeature.STOP: "Stop"} + else: + operations = { + media_player.MediaPlayerEntityFeature.NEXT_TRACK: "Next", + media_player.MediaPlayerEntityFeature.PAUSE: "Pause", + media_player.MediaPlayerEntityFeature.PLAY: "Play", + media_player.MediaPlayerEntityFeature.PREVIOUS_TRACK: "Previous", + media_player.MediaPlayerEntityFeature.STOP: "Stop", + } return [ value diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index ca7b389a0f1b0..8c139d66369de 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -559,6 +559,10 @@ def interfaces(self) -> Generator[AlexaCapability]: ) if supported & cover.CoverEntityFeature.SET_TILT_POSITION: yield AlexaRangeController(self.entity, instance=f"{cover.DOMAIN}.tilt") + if supported & ( + cover.CoverEntityFeature.STOP | cover.CoverEntityFeature.STOP_TILT + ): + yield AlexaPlaybackController(self.entity, instance=f"{cover.DOMAIN}.stop") yield AlexaEndpointHealth(self.hass, self.entity) yield Alexa(self.entity) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 8ea61ddbceb10..89e47673f0796 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine import logging import math @@ -764,9 +765,25 @@ async def async_api_stop( entity = directive.entity data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_STOP, data, blocking=False, context=context - ) + if entity.domain == cover.DOMAIN: + supported: int = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + feature_services: dict[int, str] = { + cover.CoverEntityFeature.STOP.value: cover.SERVICE_STOP_COVER, + cover.CoverEntityFeature.STOP_TILT.value: cover.SERVICE_STOP_COVER_TILT, + } + await asyncio.gather( + *( + hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + for feature, service in feature_services.items() + if feature & supported + ) + ) + else: + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_STOP, data, blocking=False, context=context + ) return directive.response() diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 68010a6a7111c..e4a46db7d3449 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -4546,6 +4546,7 @@ async def test_presence_sensor(hass: HomeAssistant) -> None: "tilt_position_attr_in_service_call", "supported_features", "service_call", + "stop_feature_enabled", ), [ ( @@ -4556,6 +4557,7 @@ async def test_presence_sensor(hass: HomeAssistant) -> None: | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.STOP_TILT, "cover.set_cover_tilt_position", + True, ), ( 0, @@ -4565,6 +4567,7 @@ async def test_presence_sensor(hass: HomeAssistant) -> None: | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.STOP_TILT, "cover.close_cover_tilt", + True, ), ( 99, @@ -4574,6 +4577,7 @@ async def test_presence_sensor(hass: HomeAssistant) -> None: | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.STOP_TILT, "cover.set_cover_tilt_position", + True, ), ( 100, @@ -4583,36 +4587,42 @@ async def test_presence_sensor(hass: HomeAssistant) -> None: | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.STOP_TILT, "cover.open_cover_tilt", + True, ), ( 0, 0, CoverEntityFeature.SET_TILT_POSITION, "cover.set_cover_tilt_position", + False, ), ( 60, 60, CoverEntityFeature.SET_TILT_POSITION, "cover.set_cover_tilt_position", + False, ), ( 100, 100, CoverEntityFeature.SET_TILT_POSITION, "cover.set_cover_tilt_position", + False, ), ( 0, 0, CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.OPEN_TILT, "cover.set_cover_tilt_position", + False, ), ( 100, 100, CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.CLOSE_TILT, "cover.set_cover_tilt_position", + False, ), ], ids=[ @@ -4633,6 +4643,7 @@ async def test_cover_tilt_position( tilt_position_attr_in_service_call: int | None, supported_features: CoverEntityFeature, service_call: str, + stop_feature_enabled: bool, ) -> None: """Test cover discovery and tilt position using rangeController.""" device = ( @@ -4651,12 +4662,24 @@ async def test_cover_tilt_position( assert appliance["displayCategories"][0] == "INTERIOR_BLIND" assert appliance["friendlyName"] == "Test cover tilt range" + expected_interfaces: dict[bool, list[str]] = { + False: [ + "Alexa.PowerController", + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa", + ], + True: [ + "Alexa.PowerController", + "Alexa.RangeController", + "Alexa.PlaybackController", + "Alexa.EndpointHealth", + "Alexa", + ], + } + capabilities = assert_endpoint_capabilities( - appliance, - "Alexa.PowerController", - "Alexa.RangeController", - "Alexa.EndpointHealth", - "Alexa", + appliance, *expected_interfaces[stop_feature_enabled] ) range_capability = get_capability(capabilities, "Alexa.RangeController") @@ -4713,6 +4736,7 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: appliance, "Alexa.PowerController", "Alexa.RangeController", + "Alexa.PlaybackController", "Alexa.EndpointHealth", "Alexa", ) @@ -4767,6 +4791,66 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize( + ("supported_stop_features", "cover_stop_calls", "cover_stop_tilt_calls"), + [ + (CoverEntityFeature(0), 0, 0), + (CoverEntityFeature.STOP, 1, 0), + (CoverEntityFeature.STOP_TILT, 0, 1), + (CoverEntityFeature.STOP | CoverEntityFeature.STOP_TILT, 1, 1), + ], + ids=["no_stop", "stop_cover", "stop_cover_tilt", "stop_cover_and_stop_cover_tilt"], +) +async def test_cover_stop( + hass: HomeAssistant, + supported_stop_features: CoverEntityFeature, + cover_stop_calls: int, + cover_stop_tilt_calls: int, +) -> None: + """Test cover and cover tilt can be stopped.""" + + base_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + ) + + device = ( + "cover.test_semantics", + "open", + { + "friendly_name": "Test cover semantics", + "device_class": "blind", + "supported_features": int(base_features | supported_stop_features), + "current_position": 30, + "tilt_position": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "cover#test_semantics" + assert appliance["displayCategories"][0] == "INTERIOR_BLIND" + assert appliance["friendlyName"] == "Test cover semantics" + + calls_stop = async_mock_service(hass, "cover", "stop_cover") + calls_stop_tilt = async_mock_service(hass, "cover", "stop_cover_tilt") + + context = Context() + request = get_new_request( + "Alexa.PlaybackController", "Stop", "cover#test_semantics" + ) + await smart_home.async_handle_message( + hass, get_default_config(hass), request, context + ) + await hass.async_block_till_done() + + assert len(calls_stop) == cover_stop_calls + assert len(calls_stop_tilt) == cover_stop_tilt_calls + + async def test_cover_semantics_position_and_tilt(hass: HomeAssistant) -> None: """Test cover discovery and semantics with position and tilt support.""" device = ( @@ -4790,10 +4874,30 @@ async def test_cover_semantics_position_and_tilt(hass: HomeAssistant) -> None: appliance, "Alexa.PowerController", "Alexa.RangeController", + "Alexa.PlaybackController", "Alexa.EndpointHealth", "Alexa", ) + playback_controller_capability = get_capability( + capabilities, "Alexa.PlaybackController" + ) + assert playback_controller_capability is not None + assert playback_controller_capability["supportedOperations"] == ["Stop"] + + # Assert both the cover and tilt stop calls are invoked + stop_cover_tilt_calls = async_mock_service(hass, "cover", "stop_cover_tilt") + await assert_request_calls_service( + "Alexa.PlaybackController", + "Stop", + "cover#test_semantics", + "cover.stop_cover", + hass, + ) + assert len(stop_cover_tilt_calls) == 1 + call = stop_cover_tilt_calls[0] + assert call.data == {"entity_id": "cover.test_semantics"} + # Assert for Position Semantics position_capability = get_capability( capabilities, "Alexa.RangeController", "cover.position" From b7e960f0bc57a507c8c5c0fe2fddb17181fdd705 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 24 Nov 2024 18:32:37 +0100 Subject: [PATCH 0805/1070] Translate UpdateFailed error in AVM Fritz/BOX Tools (#131466) translate UpdateFailed error --- homeassistant/components/fritz/coordinator.py | 6 +++++- homeassistant/components/fritz/strings.json | 3 +++ tests/components/fritz/test_sensor.py | 6 ++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 31d8ff814915f..90bd6068ecb13 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -326,7 +326,11 @@ async def _async_update_data(self) -> UpdateCoordinatorDataType: "call_deflections" ] = await self.async_update_call_deflections() except FRITZ_EXCEPTIONS as ex: - raise UpdateFailed(ex) from ex + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(ex)}, + ) from ex _LOGGER.debug("enity_data: %s", entity_data) return entity_data diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 96eb6243529dd..06a07cba79ef2 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -176,6 +176,9 @@ }, "unable_to_connect": { "message": "Unable to establish a connection" + }, + "update_failed": { + "message": "Error while uptaing the data: {error}" } } } diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index 77deb665f5eef..7dec640b898b5 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -43,7 +43,7 @@ async def test_sensor_setup( async def test_sensor_update_fail( - hass: HomeAssistant, fc_class_mock, fh_class_mock + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, fc_class_mock, fh_class_mock ) -> None: """Test failed update of Fritz!Tools sensors.""" @@ -53,10 +53,12 @@ async def test_sensor_update_fail( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - fc_class_mock().call_action_side_effect(FritzConnectionException) + fc_class_mock().call_action_side_effect(FritzConnectionException("Boom")) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=300)) await hass.async_block_till_done(wait_background_tasks=True) + assert "Error while uptaing the data: Boom" in caplog.text + sensors = hass.states.async_all(SENSOR_DOMAIN) for sensor in sensors: assert sensor.state == STATE_UNAVAILABLE From 1dc99ebc059c4d8ec2a4f13db3220809883b9529 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 24 Nov 2024 10:33:19 -0800 Subject: [PATCH 0806/1070] Add reauthentication support for Rainbird (#131434) * Add reauthentication support for Rainbird * Add test coverage for getting the password wrong on reauth * Improve the reauth test --- homeassistant/components/rainbird/__init__.py | 6 +- .../components/rainbird/config_flow.py | 51 +++++++- .../components/rainbird/quality_scale.yaml | 2 +- .../components/rainbird/strings.json | 16 ++- tests/components/rainbird/test_config_flow.py | 116 +++++++++++++++++- tests/components/rainbird/test_init.py | 17 ++- 6 files changed, 196 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index da2a0e4b475ca..737b8a0341db0 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -7,7 +7,7 @@ import aiohttp from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController -from pyrainbird.exceptions import RainbirdApiException +from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -18,7 +18,7 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import format_mac @@ -91,6 +91,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: model_info = await controller.get_model_and_version() + except RainbirdAuthException as err: + raise ConfigEntryAuthFailed from err except RainbirdApiException as err: raise ConfigEntryNotReady from err diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index abeb1b5da157c..86a3c5d5d1c1c 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -3,15 +3,13 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import logging from typing import Any -from pyrainbird.async_client import ( - AsyncRainbirdClient, - AsyncRainbirdController, - RainbirdApiException, -) +from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController from pyrainbird.data import WifiParams +from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException import voluptuous as vol from homeassistant.config_entries import ( @@ -45,6 +43,13 @@ ), } ) +REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + } +) class ConfigFlowError(Exception): @@ -59,6 +64,8 @@ def __init__(self, message: str, error_code: str) -> None: class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Rain Bird.""" + host: str + @staticmethod @callback def async_get_options_flow( @@ -67,6 +74,35 @@ def async_get_options_flow( """Define the config flow to handle options.""" return RainBirdOptionsFlowHandler() + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + self.host = entry_data[CONF_HOST] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + if user_input: + try: + await self._test_connection(self.host, user_input[CONF_PASSWORD]) + except ConfigFlowError as err: + _LOGGER.error("Error during config flow: %s", err) + errors["base"] = err.error_code + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + errors=errors, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -123,6 +159,11 @@ async def _test_connection( f"Timeout connecting to Rain Bird controller: {err!s}", "timeout_connect", ) from err + except RainbirdAuthException as err: + raise ConfigFlowError( + f"Authentication error connecting from Rain Bird controller: {err!s}", + "invalid_auth", + ) from err except RainbirdApiException as err: raise ConfigFlowError( f"Error connecting to Rain Bird controller: {err!s}", diff --git a/homeassistant/components/rainbird/quality_scale.yaml b/homeassistant/components/rainbird/quality_scale.yaml index 63ea38d47bde9..e918bf845ba85 100644 --- a/homeassistant/components/rainbird/quality_scale.yaml +++ b/homeassistant/components/rainbird/quality_scale.yaml @@ -45,7 +45,7 @@ rules: # Silver log-when-unavailable: todo config-entry-unloading: todo - reauthentication-flow: todo + reauthentication-flow: done action-exceptions: todo docs-installation-parameters: todo integration-owner: todo diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 61498b3681677..25d3a962b36ed 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -12,14 +12,26 @@ "host": "The hostname or IP address of your Rain Bird device.", "password": "The password used to authenticate with the Rain Bird device." } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Rain Bird integration needs to re-authenticate with the device.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "The password to authenticate with your Rain Bird device." + } } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } }, "options": { diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index 87506ad656c69..6e76943f202e9 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -56,7 +56,7 @@ async def mock_setup() -> AsyncGenerator[AsyncMock]: yield mock_setup -async def complete_flow(hass: HomeAssistant) -> FlowResult: +async def complete_flow(hass: HomeAssistant, password: str = PASSWORD) -> FlowResult: """Start the config flow and enter the host and password.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -268,6 +268,59 @@ async def test_controller_cannot_connect( assert not mock_setup.mock_calls +async def test_controller_invalid_auth( + hass: HomeAssistant, + mock_setup: Mock, + responses: list[AiohttpClientMockResponse], + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test an invalid password.""" + + responses.clear() + responses.extend( + [ + # Incorrect password response + AiohttpClientMockResponse("POST", URL, status=HTTPStatus.FORBIDDEN), + AiohttpClientMockResponse("POST", URL, status=HTTPStatus.FORBIDDEN), + # Second attempt with the correct password + mock_response(SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ] + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert not result.get("errors") + assert "flow_id" in result + + # Simulate authentication error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: HOST, CONF_PASSWORD: "wrong-password"}, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "invalid_auth"} + + assert not mock_setup.mock_calls + + # Correct the form and enter the password again and setup completes + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: HOST, CONF_PASSWORD: PASSWORD}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == HOST + assert "result" in result + assert dict(result["result"].data) == CONFIG_ENTRY_DATA + assert result["result"].unique_id == MAC_ADDRESS_UNIQUE_ID + + assert len(mock_setup.mock_calls) == 1 + + async def test_controller_timeout( hass: HomeAssistant, mock_setup: Mock, @@ -286,6 +339,67 @@ async def test_controller_timeout( assert not mock_setup.mock_calls +@pytest.mark.parametrize( + ("responses", "config_entry_data"), + [ + ( + [ + # First attempt simulate the wrong password + AiohttpClientMockResponse("POST", URL, status=HTTPStatus.FORBIDDEN), + AiohttpClientMockResponse("POST", URL, status=HTTPStatus.FORBIDDEN), + # Second attempt simulate the correct password + mock_response(SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], + { + **CONFIG_ENTRY_DATA, + CONF_PASSWORD: "old-password", + }, + ), + ], +) +async def test_reauth_flow( + hass: HomeAssistant, + mock_setup: Mock, + config_entry: MockConfigEntry, +) -> None: + """Test the controller is setup correctly.""" + assert config_entry.data.get(CONF_PASSWORD) == "old-password" + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result.get("step_id") == "reauth_confirm" + assert not result.get("errors") + + # Simluate the wrong password + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "incorrect_password"}, + ) + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + assert result.get("errors") == {"base": "invalid_auth"} + + # Enter the correct password and complete the flow + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: PASSWORD}, + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.unique_id == MAC_ADDRESS_UNIQUE_ID + assert entry.data.get(CONF_PASSWORD) == PASSWORD + + assert len(mock_setup.mock_calls) == 1 + + async def test_options_flow(hass: HomeAssistant, mock_setup: Mock) -> None: """Test config flow options.""" diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 5b2e2ea6d1be4..01e0c4458e4f3 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -45,17 +45,19 @@ async def test_init_success( @pytest.mark.parametrize( - ("config_entry_data", "responses", "config_entry_state"), + ("config_entry_data", "responses", "config_entry_state", "config_flow_steps"), [ ( CONFIG_ENTRY_DATA, [mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)], ConfigEntryState.SETUP_RETRY, + [], ), ( CONFIG_ENTRY_DATA, [mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR)], ConfigEntryState.SETUP_RETRY, + [], ), ( CONFIG_ENTRY_DATA, @@ -64,6 +66,7 @@ async def test_init_success( mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE), ], ConfigEntryState.SETUP_RETRY, + [], ), ( CONFIG_ENTRY_DATA, @@ -72,6 +75,13 @@ async def test_init_success( mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR), ], ConfigEntryState.SETUP_RETRY, + [], + ), + ( + CONFIG_ENTRY_DATA, + [mock_response_error(HTTPStatus.FORBIDDEN)], + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], ), ], ids=[ @@ -79,17 +89,22 @@ async def test_init_success( "server-error", "coordinator-unavailable", "coordinator-server-error", + "forbidden", ], ) async def test_communication_failure( hass: HomeAssistant, config_entry: MockConfigEntry, config_entry_state: list[ConfigEntryState], + config_flow_steps: list[str], ) -> None: """Test unable to talk to device on startup, which fails setup.""" await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state == config_entry_state + flows = hass.config_entries.flow.async_progress() + assert [flow["step_id"] for flow in flows] == config_flow_steps + @pytest.mark.parametrize( ("config_entry_unique_id", "config_entry_data"), From 84630ef8cc26b92b77a49a935d98262ef9089630 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sun, 24 Nov 2024 19:37:40 +0100 Subject: [PATCH 0807/1070] Define ViCare fan entity presets based on the actual by the device supported presets (#130886) * only show supported presets * update snapshot * Apply suggestions from code review * move code to init * async executor * Revert "update snapshot" This reverts commit ca92b5ed27ff03b863e48423f19e0d2b5762ce52. * Update fan.py --- homeassistant/components/vicare/fan.py | 40 +++++++++++++++++--------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index b787de207736a..9973cf56e39d0 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -29,6 +29,7 @@ from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity +from .types import ViCareDevice from .utils import get_device_serial _LOGGER = logging.getLogger(__name__) @@ -90,6 +91,17 @@ def from_vicare_mode(vicare_mode: str | None) -> str | None: ] +def _build_entities( + device_list: list[ViCareDevice], +) -> list[ViCareFan]: + """Create ViCare climate entities for a device.""" + return [ + ViCareFan(get_device_serial(device.api), device.config, device.api) + for device in device_list + if isinstance(device.api, PyViCareVentilationDevice) + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -100,27 +112,18 @@ async def async_setup_entry( device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] async_add_entities( - [ - ViCareFan(get_device_serial(device.api), device.config, device.api) - for device in device_list - if isinstance(device.api, PyViCareVentilationDevice) - ] + await hass.async_add_executor_job( + _build_entities, + device_list, + ) ) class ViCareFan(ViCareEntity, FanEntity): """Representation of the ViCare ventilation device.""" - _attr_preset_modes = list[str]( - [ - VentilationMode.PERMANENT, - VentilationMode.VENTILATION, - VentilationMode.SENSOR_DRIVEN, - VentilationMode.SENSOR_OVERRIDE, - ] - ) _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) - _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE + _attr_supported_features = FanEntityFeature.SET_SPEED _attr_translation_key = "ventilation" _enable_turn_on_off_backwards_compatibility = False @@ -134,6 +137,15 @@ def __init__( super().__init__( self._attr_translation_key, device_serial, device_config, device ) + # init presets + supported_modes = list[str](self._api.getAvailableModes()) + self._attr_preset_modes = [ + mode + for mode in VentilationMode + if VentilationMode.to_vicare_mode(mode) in supported_modes + ] + if len(self._attr_preset_modes) > 0: + self._attr_supported_features |= FanEntityFeature.PRESET_MODE def update(self) -> None: """Update state of fan.""" From 4c603913caadddb2894bace33cb963d4a52b399e Mon Sep 17 00:00:00 2001 From: kongo09 Date: Sun, 24 Nov 2024 20:27:22 +0100 Subject: [PATCH 0808/1070] Fix humidifier entity feature docstring (#131470) fix docstring to refer to the correct entity --- homeassistant/components/humidifier/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index fc6b0fc14d4bb..03ff0774ca043 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -57,7 +57,7 @@ class HumidifierAction(StrEnum): class HumidifierEntityFeature(IntFlag): - """Supported features of the alarm control panel entity.""" + """Supported features of the humidifier entity.""" MODES = 1 From 1e169d185f720efd9313822e3958295a6854f7f3 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 24 Nov 2024 20:36:59 +0100 Subject: [PATCH 0809/1070] Add version to SABnzbd device info (#131479) --- homeassistant/components/sabnzbd/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sabnzbd/entity.py b/homeassistant/components/sabnzbd/entity.py index f7515c3d1783c..946e1de447661 100644 --- a/homeassistant/components/sabnzbd/entity.py +++ b/homeassistant/components/sabnzbd/entity.py @@ -27,4 +27,5 @@ def __init__( self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry_id)}, + sw_version=coordinator.data["version"], ) From 8baa477efeea7b95cbf0333b35937cc71bb9af70 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 24 Nov 2024 21:35:26 +0100 Subject: [PATCH 0810/1070] Set single_config_entry in azure event hub (#131483) Set single_config_entry in azure-event-hub --- homeassistant/components/azure_event_hub/config_flow.py | 4 ---- homeassistant/components/azure_event_hub/manifest.json | 3 ++- homeassistant/components/azure_event_hub/strings.json | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/azure_event_hub/config_flow.py b/homeassistant/components/azure_event_hub/config_flow.py index 60ac9bff8cd49..baed866042e0d 100644 --- a/homeassistant/components/azure_event_hub/config_flow.py +++ b/homeassistant/components/azure_event_hub/config_flow.py @@ -102,8 +102,6 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial user step.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") if user_input is None: return self.async_show_form(step_id=STEP_USER, data_schema=BASE_SCHEMA) @@ -160,8 +158,6 @@ async def async_step_sas( async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Import config from configuration.yaml.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") if CONF_SEND_INTERVAL in import_data: self._options[CONF_SEND_INTERVAL] = import_data.pop(CONF_SEND_INTERVAL) if CONF_MAX_DELAY in import_data: diff --git a/homeassistant/components/azure_event_hub/manifest.json b/homeassistant/components/azure_event_hub/manifest.json index c6d5835fd1dbe..45fbf8c4a5626 100644 --- a/homeassistant/components/azure_event_hub/manifest.json +++ b/homeassistant/components/azure_event_hub/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/azure_event_hub", "iot_class": "cloud_push", "loggers": ["azure"], - "requirements": ["azure-eventhub==5.11.1"] + "requirements": ["azure-eventhub==5.11.1"], + "single_config_entry": true } diff --git a/homeassistant/components/azure_event_hub/strings.json b/homeassistant/components/azure_event_hub/strings.json index 3319a29a15432..d17c4a385c04f 100644 --- a/homeassistant/components/azure_event_hub/strings.json +++ b/homeassistant/components/azure_event_hub/strings.json @@ -31,7 +31,6 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "cannot_connect": "Connecting with the credentials from the configuration.yaml failed, please remove from yaml and use the config flow.", "unknown": "Connecting with the credentials from the configuration.yaml failed with an unknown error, please remove from yaml and use the config flow." } From 428d7d1ad80712902f0cb0f3ff210292344b986c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 24 Nov 2024 21:53:52 +0100 Subject: [PATCH 0811/1070] Rename `.sab` module to `.helpers` in SABnzbd (#131481) Rename sab module to helpers in SABnzbd --- homeassistant/components/sabnzbd/__init__.py | 2 +- homeassistant/components/sabnzbd/config_flow.py | 2 +- homeassistant/components/sabnzbd/{sab.py => helpers.py} | 0 tests/components/sabnzbd/conftest.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename homeassistant/components/sabnzbd/{sab.py => helpers.py} (100%) diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 74920ba6465be..19b114a452555 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -25,7 +25,7 @@ SERVICE_SET_SPEED, ) from .coordinator import SabnzbdUpdateCoordinator -from .sab import get_client +from .helpers import get_client PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sabnzbd/config_flow.py b/homeassistant/components/sabnzbd/config_flow.py index c52572ed762d8..846f7b2b4676c 100644 --- a/homeassistant/components/sabnzbd/config_flow.py +++ b/homeassistant/components/sabnzbd/config_flow.py @@ -16,7 +16,7 @@ ) from .const import DOMAIN -from .sab import get_client +from .helpers import get_client _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sabnzbd/sab.py b/homeassistant/components/sabnzbd/helpers.py similarity index 100% rename from homeassistant/components/sabnzbd/sab.py rename to homeassistant/components/sabnzbd/helpers.py diff --git a/tests/components/sabnzbd/conftest.py b/tests/components/sabnzbd/conftest.py index 67243a6a19893..6fa3d14e88045 100644 --- a/tests/components/sabnzbd/conftest.py +++ b/tests/components/sabnzbd/conftest.py @@ -26,7 +26,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: def mock_sabnzbd() -> Generator[AsyncMock]: """Mock the Sabnzbd API.""" with patch( - "homeassistant.components.sabnzbd.sab.SabnzbdApi", autospec=True + "homeassistant.components.sabnzbd.helpers.SabnzbdApi", autospec=True ) as mock_sabnzbd: mock = mock_sabnzbd.return_value mock.return_value.check_available = True From 9f8a656effda2c6ada3bc3de31457e71f76f5b9e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 24 Nov 2024 23:55:03 +0100 Subject: [PATCH 0812/1070] Set single_config_entry in cpuspeed (#131486) * Set single_config_entry in cpuspeed * Adjust tests --- homeassistant/components/cpuspeed/config_flow.py | 1 - homeassistant/components/cpuspeed/manifest.json | 3 ++- homeassistant/components/cpuspeed/strings.json | 1 - homeassistant/generated/integrations.json | 3 ++- tests/components/cpuspeed/test_config_flow.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cpuspeed/config_flow.py b/homeassistant/components/cpuspeed/config_flow.py index ac35cc0fc4f5a..21dc577b5bf55 100644 --- a/homeassistant/components/cpuspeed/config_flow.py +++ b/homeassistant/components/cpuspeed/config_flow.py @@ -23,7 +23,6 @@ async def async_step_user( ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" await self.async_set_unique_id(DOMAIN) - self._abort_if_unique_id_configured() if user_input is None: return self.async_show_form(step_id="user") diff --git a/homeassistant/components/cpuspeed/manifest.json b/homeassistant/components/cpuspeed/manifest.json index ff3a41d9c095e..0c7f549a1b961 100644 --- a/homeassistant/components/cpuspeed/manifest.json +++ b/homeassistant/components/cpuspeed/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/cpuspeed", "integration_type": "device", "iot_class": "local_push", - "requirements": ["py-cpuinfo==9.0.0"] + "requirements": ["py-cpuinfo==9.0.0"], + "single_config_entry": true } diff --git a/homeassistant/components/cpuspeed/strings.json b/homeassistant/components/cpuspeed/strings.json index e82c6a0db1260..6f4b3133b1bb5 100644 --- a/homeassistant/components/cpuspeed/strings.json +++ b/homeassistant/components/cpuspeed/strings.json @@ -8,7 +8,6 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::single_instance_allowed%]", "not_compatible": "Unable to get CPU information, this integration is not compatible with your system" } } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b2e09587c5fb3..1016c862f0a18 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1057,7 +1057,8 @@ "cpuspeed": { "integration_type": "device", "config_flow": true, - "iot_class": "local_push" + "iot_class": "local_push", + "single_config_entry": true }, "cribl": { "name": "Cribl", diff --git a/tests/components/cpuspeed/test_config_flow.py b/tests/components/cpuspeed/test_config_flow.py index 0ebb8aede4945..1a68d6f9396d4 100644 --- a/tests/components/cpuspeed/test_config_flow.py +++ b/tests/components/cpuspeed/test_config_flow.py @@ -50,7 +50,7 @@ async def test_already_configured( ) assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result.get("reason") == "single_instance_allowed" assert len(mock_setup_entry.mock_calls) == 0 assert len(mock_cpuinfo_config_flow.mock_calls) == 0 From dc4a2d6f33b42b0b4e8737d15f6767275c12c157 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Sun, 24 Nov 2024 18:35:34 -0500 Subject: [PATCH 0813/1070] Bump aiostreammagic to 2.10.0 (#131415) --- homeassistant/components/cambridge_audio/__init__.py | 3 ++- homeassistant/components/cambridge_audio/config_flow.py | 7 +++++-- homeassistant/components/cambridge_audio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cambridge_audio/__init__.py b/homeassistant/components/cambridge_audio/__init__.py index a584f0db6c19f..8b910bb81bba9 100644 --- a/homeassistant/components/cambridge_audio/__init__.py +++ b/homeassistant/components/cambridge_audio/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS @@ -27,7 +28,7 @@ async def async_setup_entry( ) -> bool: """Set up Cambridge Audio integration from a config entry.""" - client = StreamMagicClient(entry.data[CONF_HOST]) + client = StreamMagicClient(entry.data[CONF_HOST], async_get_clientsession(hass)) async def _connection_update_callback( _client: StreamMagicClient, _callback_type: CallbackType diff --git a/homeassistant/components/cambridge_audio/config_flow.py b/homeassistant/components/cambridge_audio/config_flow.py index 201e531608d55..ca587ee9a48b0 100644 --- a/homeassistant/components/cambridge_audio/config_flow.py +++ b/homeassistant/components/cambridge_audio/config_flow.py @@ -9,6 +9,7 @@ from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS @@ -30,7 +31,7 @@ async def async_step_zeroconf( await self.async_set_unique_id(discovery_info.properties["serial"]) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) - client = StreamMagicClient(host) + client = StreamMagicClient(host, async_get_clientsession(self.hass)) try: async with asyncio.timeout(CONNECT_TIMEOUT): await client.connect() @@ -69,7 +70,9 @@ async def async_step_user( """Handle a flow initialized by the user.""" errors: dict[str, str] = {} if user_input: - client = StreamMagicClient(user_input[CONF_HOST]) + client = StreamMagicClient( + user_input[CONF_HOST], async_get_clientsession(self.hass) + ) try: async with asyncio.timeout(CONNECT_TIMEOUT): await client.connect() diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index 2125586fa2b1d..7b7e341e3c6e2 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aiostreammagic"], - "requirements": ["aiostreammagic==2.8.6"], + "requirements": ["aiostreammagic==2.10.0"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f0fb753f9d7db..16ee45fe4cb90 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -384,7 +384,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.8.6 +aiostreammagic==2.10.0 # homeassistant.components.switcher_kis aioswitcher==5.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c1b3e9446fd8..ce529f9243f96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -366,7 +366,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.8.6 +aiostreammagic==2.10.0 # homeassistant.components.switcher_kis aioswitcher==5.0.0 From 8b71362ae1f3ce21bb5cead320fe0ddcc710c645 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 25 Nov 2024 00:55:11 +0100 Subject: [PATCH 0814/1070] Set parallelism for Habitica (#131480) * Set parallelism for Habitica * remove from coordinator --- homeassistant/components/habitica/button.py | 2 ++ homeassistant/components/habitica/switch.py | 2 ++ homeassistant/components/habitica/todo.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py index 8b41fb8c987cc..30e326f79a07f 100644 --- a/homeassistant/components/habitica/button.py +++ b/homeassistant/components/habitica/button.py @@ -25,6 +25,8 @@ from .entity import HabiticaBase from .types import HabiticaConfigEntry +PARALLEL_UPDATES = 1 + @dataclass(kw_only=True, frozen=True) class HabiticaButtonEntityDescription(ButtonEntityDescription): diff --git a/homeassistant/components/habitica/switch.py b/homeassistant/components/habitica/switch.py index 6682911e8928a..de0cc53305057 100644 --- a/homeassistant/components/habitica/switch.py +++ b/homeassistant/components/habitica/switch.py @@ -19,6 +19,8 @@ from .entity import HabiticaBase from .types import HabiticaConfigEntry +PARALLEL_UPDATES = 1 + @dataclass(kw_only=True, frozen=True) class HabiticaSwitchEntityDescription(SwitchEntityDescription): diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index 0fff7b66605b8..0ca5f723c4576 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -27,6 +27,8 @@ from .types import HabiticaConfigEntry, HabiticaTaskType from .util import next_due_date +PARALLEL_UPDATES = 1 + class HabiticaTodoList(StrEnum): """Habitica Entities.""" From 43e467a3095b11d5953bb8a142ae5349350936ed Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 25 Nov 2024 00:55:33 +0100 Subject: [PATCH 0815/1070] Set single_config_entry in canary (#131485) --- homeassistant/components/canary/config_flow.py | 3 --- homeassistant/components/canary/manifest.json | 3 ++- homeassistant/components/canary/strings.json | 1 - homeassistant/generated/integrations.json | 3 ++- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/canary/config_flow.py b/homeassistant/components/canary/config_flow.py index 2dd3a678b5da1..17e660e96acee 100644 --- a/homeassistant/components/canary/config_flow.py +++ b/homeassistant/components/canary/config_flow.py @@ -62,9 +62,6 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - errors = {} default_username = "" diff --git a/homeassistant/components/canary/manifest.json b/homeassistant/components/canary/manifest.json index 4d5adf4a32be8..9383bc91556d6 100644 --- a/homeassistant/components/canary/manifest.json +++ b/homeassistant/components/canary/manifest.json @@ -7,5 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/canary", "iot_class": "cloud_polling", "loggers": ["canary"], - "requirements": ["py-canary==0.5.4"] + "requirements": ["py-canary==0.5.4"], + "single_config_entry": true } diff --git a/homeassistant/components/canary/strings.json b/homeassistant/components/canary/strings.json index 9555756deff3a..699e8b25e11b4 100644 --- a/homeassistant/components/canary/strings.json +++ b/homeassistant/components/canary/strings.json @@ -14,7 +14,6 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1016c862f0a18..8238a09072bd9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -872,7 +872,8 @@ "name": "Canary", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "single_config_entry": true }, "ccm15": { "name": "Midea ccm15 AC Controller", From 1c2e86d824ad096c2cdb1058563142136e783a5c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 25 Nov 2024 00:56:05 +0100 Subject: [PATCH 0816/1070] Deprecate async_register_rtsp_to_web_rtc_provider (#131462) --- homeassistant/components/camera/webrtc.py | 2 ++ tests/components/camera/test_webrtc.py | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index 6a7f70ea48b45..3630acf1cfeac 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -23,6 +23,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.deprecation import deprecated_function from homeassistant.util.hass_dict import HassKey from homeassistant.util.ulid import ulid @@ -445,6 +446,7 @@ async def async_handle_web_rtc_offer( return await self._fn(stream_source, offer_sdp, camera.entity_id) +@deprecated_function("async_register_webrtc_provider", breaks_in_ha_version="2025.6") def async_register_rtsp_to_web_rtc_provider( hass: HomeAssistant, domain: str, diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 76d7b15c286b9..a7c6d889409d2 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -428,10 +428,16 @@ async def provide_webrtc_answer(stream_source: str, offer: str, stream_id: str) @pytest.fixture(name="mock_rtsp_to_webrtc") -def mock_rtsp_to_webrtc_fixture(hass: HomeAssistant) -> Generator[Mock]: +def mock_rtsp_to_webrtc_fixture( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> Generator[Mock]: """Fixture that registers a mock rtsp to webrtc provider.""" mock_provider = Mock(side_effect=provide_webrtc_answer) unsub = async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", mock_provider) + assert ( + "async_register_rtsp_to_web_rtc_provider is a deprecated function which will" + " be removed in HA Core 2025.6. Use async_register_webrtc_provider instead" + ) in caplog.text yield mock_provider unsub() From 69cc856d57c4877a32236a8ddf31fb4f5dc7089c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 25 Nov 2024 01:01:35 +0100 Subject: [PATCH 0817/1070] Fix incorrect already_configured string in bang olufsen (#131484) --- homeassistant/components/bang_olufsen/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index aef6f953524fa..6e75d2f26c834 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -11,7 +11,7 @@ "invalid_ip": "Invalid IPv4 address" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::single_instance_allowed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" }, "flow_title": "{name}", From cb4636ada1feac832f2223ee3cc627ffd7082972 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 25 Nov 2024 01:48:05 +0100 Subject: [PATCH 0818/1070] Bump uiprotect to 6.6.2 (#131475) * Bump uiprotect to 6.6.2 * test(data): update test data to include readLive permissions --------- Co-authored-by: J. Nick Koston --- .../components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fixtures/sample_bootstrap.json | 24 +++++++++---------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 96fc30b240421..a8ad956a66786 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==6.6.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==6.6.2", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 16ee45fe4cb90..cd22c50ef09b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2897,7 +2897,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.1 +uiprotect==6.6.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce529f9243f96..f858d48cec91b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2310,7 +2310,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.1 +uiprotect==6.6.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/tests/components/unifiprotect/fixtures/sample_bootstrap.json b/tests/components/unifiprotect/fixtures/sample_bootstrap.json index 2b7326831ebca..240a9938b6496 100644 --- a/tests/components/unifiprotect/fixtures/sample_bootstrap.json +++ b/tests/components/unifiprotect/fixtures/sample_bootstrap.json @@ -57,7 +57,7 @@ "schedule:create,read,write,delete:*", "legacyUFV:read,write,delete:*", "bridge:create,read,write,delete:*", - "camera:create,read,write,delete,readmedia,deletemedia:*", + "camera:create,read,write,delete,readmedia,readlive,deletemedia:*", "light:create,read,write,delete:*", "sensor:create,read,write,delete:*", "doorlock:create,read,write,delete:*", @@ -118,7 +118,7 @@ "liveview:create", "user:read,write,delete:$", "bridge:read:*", - "camera:read,readmedia:*", + "camera:read,readmedia,readlive:*", "doorlock:read:*", "light:read:*", "sensor:read:*", @@ -134,7 +134,7 @@ "schedule:create,read,write,delete:*", "legacyUFV:read,write,delete:*", "bridge:create,read,write,delete:*", - "camera:create,read,write,delete,readmedia,deletemedia:*", + "camera:create,read,write,delete,readmedia,readlive,deletemedia:*", "light:create,read,write,delete:*", "sensor:create,read,write,delete:*", "doorlock:create,read,write,delete:*", @@ -246,7 +246,7 @@ "schedule:create,read,write,delete:*", "legacyUFV:read,write,delete:*", "bridge:create,read,write,delete:*", - "camera:create,read,write,delete,readmedia,deletemedia:*", + "camera:create,read,write,delete,readmedia,readlive,deletemedia:*", "light:create,read,write,delete:*", "sensor:create,read,write,delete:*", "doorlock:create,read,write,delete:*", @@ -314,7 +314,7 @@ "liveview:create", "user:read,write,delete:$", "bridge:read:*", - "camera:read,readmedia:*", + "camera:read,readmedia,readlive:*", "doorlock:read:*", "light:read:*", "sensor:read:*", @@ -365,7 +365,7 @@ "liveview:create", "user:read,write,delete:$", "bridge:read:*", - "camera:read,readmedia:*", + "camera:read,readmedia,readlive:*", "doorlock:read:*", "light:read:*", "sensor:read:*", @@ -381,7 +381,7 @@ "schedule:create,read,write,delete:*", "legacyUFV:read,write,delete:*", "bridge:create,read,write,delete:*", - "camera:create,read,write,delete,readmedia,deletemedia:*", + "camera:create,read,write,delete,readmedia,readlive,deletemedia:*", "light:create,read,write,delete:*", "sensor:create,read,write,delete:*", "doorlock:create,read,write,delete:*", @@ -432,7 +432,7 @@ "liveview:create", "user:read,write,delete:$", "bridge:read:*", - "camera:read,readmedia:*", + "camera:read,readmedia,readlive:*", "doorlock:read:*", "light:read:*", "sensor:read:*", @@ -448,7 +448,7 @@ "schedule:create,read,write,delete:*", "legacyUFV:read,write,delete:*", "bridge:create,read,write,delete:*", - "camera:create,read,write,delete,readmedia,deletemedia:*", + "camera:create,read,write,delete,readmedia,readlive,deletemedia:*", "light:create,read,write,delete:*", "sensor:create,read,write,delete:*", "doorlock:create,read,write,delete:*", @@ -496,7 +496,7 @@ "liveview:create", "user:read,write,delete:$", "bridge:read:*", - "camera:read,readmedia:*", + "camera:read,readmedia,readlive:*", "doorlock:read:*", "light:read:*", "sensor:read:*", @@ -526,7 +526,7 @@ "schedule:create,read,write,delete:*", "legacyUFV:read,write,delete:*", "bridge:create,read,write,delete:*", - "camera:create,read,write,delete,readmedia,deletemedia:*", + "camera:create,read,write,delete,readmedia,readlive,deletemedia:*", "light:create,read,write,delete:*", "sensor:create,read,write,delete:*", "doorlock:create,read,write,delete:*", @@ -546,7 +546,7 @@ "liveview:create", "user:read,write,delete:$", "bridge:read:*", - "camera:read,readmedia:*", + "camera:read,readmedia,readlive:*", "doorlock:read:*", "light:read:*", "sensor:read:*", From d4071e7123d05acbda07864506541f879fb563cd Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sun, 24 Nov 2024 19:52:21 -0600 Subject: [PATCH 0819/1070] Hide TTS filename behind random token (#131192) * Hide TTS filename behind random token * Clean up and fix test snapshots * Fix tests * Fix cloud tests --- homeassistant/components/tts/__init__.py | 24 +- .../assist_pipeline/snapshots/test_init.ambr | 8 +- .../snapshots/test_websocket.ambr | 8 +- tests/components/assist_pipeline/test_init.py | 148 ++-- .../assist_pipeline/test_websocket.py | 683 +++++++++--------- tests/components/cloud/test_tts.py | 240 +++--- tests/components/tts/test_init.py | 254 ++++--- 7 files changed, 701 insertions(+), 664 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index ad267b9106b9d..e7d1091719bf5 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -13,6 +13,7 @@ import mimetypes import os import re +import secrets import subprocess import tempfile from typing import Any, Final, TypedDict, final @@ -540,6 +541,10 @@ def __init__( self.file_cache: dict[str, str] = {} self.mem_cache: dict[str, TTSCache] = {} + # filename <-> token + self.filename_to_token: dict[str, str] = {} + self.token_to_filename: dict[str, str] = {} + def _init_cache(self) -> dict[str, str]: """Init cache folder and fetch files.""" try: @@ -656,7 +661,17 @@ async def async_get_url_path( engine_instance, cache_key, message, use_cache, language, options ) - return f"/api/tts_proxy/{filename}" + # Use a randomly generated token instead of exposing the filename + token = self.filename_to_token.get(filename) + if not token: + # Keep extension (.mp3, etc.) + token = secrets.token_urlsafe(16) + os.path.splitext(filename)[1] + + # Map token <-> filename + self.filename_to_token[filename] = token + self.token_to_filename[token] = filename + + return f"/api/tts_proxy/{token}" async def async_get_tts_audio( self, @@ -910,11 +925,15 @@ def async_remove_from_mem(_: datetime) -> None: ), ) - async def async_read_tts(self, filename: str) -> tuple[str | None, bytes]: + async def async_read_tts(self, token: str) -> tuple[str | None, bytes]: """Read a voice file and return binary. This method is a coroutine. """ + filename = self.token_to_filename.get(token) + if not filename: + raise HomeAssistantError(f"{token} was not recognized!") + if not (record := _RE_VOICE_FILE.match(filename.lower())) and not ( record := _RE_LEGACY_VOICE_FILE.match(filename.lower()) ): @@ -1076,6 +1095,7 @@ def __init__(self, tts: SpeechManager) -> None: async def get(self, request: web.Request, filename: str) -> web.Response: """Start a get request.""" try: + # filename is actually token, but we keep its name for compatibility content, data = await self.tts.async_read_tts(filename) except HomeAssistantError as err: _LOGGER.error("Error on load tts: %s", err) diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 7f77dada3be98..c70d3944f88b6 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -77,7 +77,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/test_token.mp3', }), }), 'type': , @@ -166,7 +166,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', + 'url': '/api/tts_proxy/test_token.mp3', }), }), 'type': , @@ -255,7 +255,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', + 'url': '/api/tts_proxy/test_token.mp3', }), }), 'type': , @@ -368,7 +368,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/test_token.mp3', }), }), 'type': , diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index b806c6faf23eb..566fb129959d2 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -73,7 +73,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/test_token.mp3', }), }) # --- @@ -154,7 +154,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/test_token.mp3', }), }) # --- @@ -247,7 +247,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/test_token.mp3', }), }) # --- @@ -350,7 +350,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/test_token.mp3', }), }) # --- diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index a8ada11fb4531..b177530219e41 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -70,21 +70,24 @@ async def audio_data(): yield make_10ms_chunk(b"part2") yield b"" - await assist_pipeline.async_pipeline_from_audio_stream( - hass, - context=Context(), - event_callback=events.append, - stt_metadata=stt.SpeechMetadata( - language="", - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=audio_data(), - audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), - ) + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), + ) assert process_events(events) == snapshot assert len(mock_stt_provider_entity.received) == 2 @@ -133,23 +136,26 @@ async def audio_data(): assert msg["success"] pipeline_id = msg["result"]["id"] - # Use the created pipeline - await assist_pipeline.async_pipeline_from_audio_stream( - hass, - context=Context(), - event_callback=events.append, - stt_metadata=stt.SpeechMetadata( - language="en-UK", - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=audio_data(), - pipeline_id=pipeline_id, - audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), - ) + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + # Use the created pipeline + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( + language="en-UK", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), + ) assert process_events(events) == snapshot assert len(mock_stt_provider.received) == 2 @@ -198,23 +204,26 @@ async def audio_data(): assert msg["success"] pipeline_id = msg["result"]["id"] - # Use the created pipeline - await assist_pipeline.async_pipeline_from_audio_stream( - hass, - context=Context(), - event_callback=events.append, - stt_metadata=stt.SpeechMetadata( - language="en-UK", - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=audio_data(), - pipeline_id=pipeline_id, - audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), - ) + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + # Use the created pipeline + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( + language="en-UK", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), + ) assert process_events(events) == snapshot assert len(mock_stt_provider_entity.received) == 2 @@ -362,25 +371,28 @@ async def audio_data(): yield b"" - await assist_pipeline.async_pipeline_from_audio_stream( - hass, - context=Context(), - event_callback=events.append, - stt_metadata=stt.SpeechMetadata( - language="", - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=audio_data(), - start_stage=assist_pipeline.PipelineStage.WAKE_WORD, - wake_word_settings=assist_pipeline.WakeWordSettings( - audio_seconds_to_buffer=1.5 - ), - audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), - ) + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + wake_word_settings=assist_pipeline.WakeWordSettings( + audio_seconds_to_buffer=1.5 + ), + audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), + ) assert process_events(events) == snapshot diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index c9bc3ef41de36..c1caf6f86a49f 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -119,85 +119,88 @@ async def test_audio_pipeline( events = [] client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "stt", - "end_stage": "tts", - "input": { - "sample_rate": 44100, - }, - } - ) + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "tts", + "input": { + "sample_rate": 44100, + }, + } + ) - # result - msg = await client.receive_json() - assert msg["success"] + # result + msg = await client.receive_json() + assert msg["success"] - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - msg["event"]["data"]["pipeline"] = ANY - assert msg["event"]["data"] == snapshot - handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] - events.append(msg["event"]) + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + events.append(msg["event"]) - # stt - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # End of audio stream (handler id + empty payload) - await client.send_bytes(bytes([handler_id])) + # End of audio stream (handler id + empty payload) + await client.send_bytes(bytes([handler_id])) - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # intent - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # text-to-speech - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # text-to-speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # run end - msg = await client.receive_json() - assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_debug)[0] - pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] - await client.send_json_auto_id( - { - "type": "assist_pipeline/pipeline_debug/get", - "pipeline_id": pipeline_id, - "pipeline_run_id": pipeline_run_id, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == {"events": events} + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} async def test_audio_pipeline_with_wake_word_timeout( @@ -210,49 +213,52 @@ async def test_audio_pipeline_with_wake_word_timeout( events = [] client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "wake_word", - "end_stage": "tts", - "input": { - "sample_rate": SAMPLE_RATE, - "timeout": 1, - }, - } - ) + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": SAMPLE_RATE, + "timeout": 1, + }, + } + ) - # result - msg = await client.receive_json() - assert msg["success"], msg + # result + msg = await client.receive_json() + assert msg["success"], msg - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - msg["event"]["data"]["pipeline"] = ANY - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # wake_word - msg = await client.receive_json() - assert msg["event"]["type"] == "wake_word-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # wake_word + msg = await client.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # 2 seconds of silence - await client.send_bytes(bytes([1]) + bytes(2 * BYTES_ONE_SECOND)) + # 2 seconds of silence + await client.send_bytes(bytes([1]) + bytes(2 * BYTES_ONE_SECOND)) - # Time out error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # Time out error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # run end - msg = await client.receive_json() - assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) async def test_audio_pipeline_with_wake_word_no_timeout( @@ -265,98 +271,101 @@ async def test_audio_pipeline_with_wake_word_no_timeout( events = [] client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "wake_word", - "end_stage": "tts", - "input": {"sample_rate": SAMPLE_RATE, "timeout": 0, "no_vad": True}, - } - ) + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "wake_word", + "end_stage": "tts", + "input": {"sample_rate": SAMPLE_RATE, "timeout": 0, "no_vad": True}, + } + ) - # result - msg = await client.receive_json() - assert msg["success"], msg + # result + msg = await client.receive_json() + assert msg["success"], msg - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - msg["event"]["data"]["pipeline"] = ANY - assert msg["event"]["data"] == snapshot - handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] - events.append(msg["event"]) + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + events.append(msg["event"]) - # wake_word - msg = await client.receive_json() - assert msg["event"]["type"] == "wake_word-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # wake_word + msg = await client.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # "audio" - await client.send_bytes(bytes([handler_id]) + make_10ms_chunk(b"wake word")) + # "audio" + await client.send_bytes(bytes([handler_id]) + make_10ms_chunk(b"wake word")) - async with asyncio.timeout(1): - msg = await client.receive_json() - assert msg["event"]["type"] == "wake_word-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + async with asyncio.timeout(1): + msg = await client.receive_json() + assert msg["event"]["type"] == "wake_word-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # stt - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # End of audio stream (handler id + empty payload) - await client.send_bytes(bytes([handler_id])) + # End of audio stream (handler id + empty payload) + await client.send_bytes(bytes([handler_id])) - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # intent - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # text-to-speech - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # text-to-speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # run end - msg = await client.receive_json() - assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_debug)[0] - pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] - await client.send_json_auto_id( - { - "type": "assist_pipeline/pipeline_debug/get", - "pipeline_id": pipeline_id, - "pipeline_run_id": pipeline_run_id, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == {"events": events} + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} async def test_audio_pipeline_no_wake_word_engine( @@ -1540,99 +1549,102 @@ async def test_audio_pipeline_debug( events = [] client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "stt", - "end_stage": "tts", - "input": { - "sample_rate": 44100, - }, - } - ) + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "tts", + "input": { + "sample_rate": 44100, + }, + } + ) - # result - msg = await client.receive_json() - assert msg["success"] + # result + msg = await client.receive_json() + assert msg["success"] - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - msg["event"]["data"]["pipeline"] = ANY - assert msg["event"]["data"] == snapshot - handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] - events.append(msg["event"]) + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + events.append(msg["event"]) - # stt - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # End of audio stream (handler id + empty payload) - await client.send_bytes(bytes([handler_id])) + # End of audio stream (handler id + empty payload) + await client.send_bytes(bytes([handler_id])) - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # intent - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # text-to-speech - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # text-to-speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # run end - msg = await client.receive_json() - assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # Get the id of the pipeline - await client.send_json_auto_id({"type": "assist_pipeline/pipeline/list"}) - msg = await client.receive_json() - assert msg["success"] - assert len(msg["result"]["pipelines"]) == 1 + # Get the id of the pipeline + await client.send_json_auto_id({"type": "assist_pipeline/pipeline/list"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["pipelines"]) == 1 - pipeline_id = msg["result"]["pipelines"][0]["id"] + pipeline_id = msg["result"]["pipelines"][0]["id"] - # Get the id for the run - await client.send_json_auto_id( - {"type": "assist_pipeline/pipeline_debug/list", "pipeline_id": pipeline_id} - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == {"pipeline_runs": [ANY]} + # Get the id for the run + await client.send_json_auto_id( + {"type": "assist_pipeline/pipeline_debug/list", "pipeline_id": pipeline_id} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"pipeline_runs": [ANY]} - pipeline_run_id = msg["result"]["pipeline_runs"][0]["pipeline_run_id"] + pipeline_run_id = msg["result"]["pipeline_runs"][0]["pipeline_run_id"] - await client.send_json_auto_id( - { - "type": "assist_pipeline/pipeline_debug/get", - "pipeline_id": pipeline_id, - "pipeline_run_id": pipeline_run_id, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == {"events": events} + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} async def test_pipeline_debug_list_runs_wrong_pipeline( @@ -1787,94 +1799,97 @@ async def test_audio_pipeline_with_enhancements( events = [] client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "stt", - "end_stage": "tts", - "input": { - "sample_rate": SAMPLE_RATE, - # Enhancements - "noise_suppression_level": 2, - "auto_gain_dbfs": 15, - "volume_multiplier": 2.0, - }, - } - ) + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "tts", + "input": { + "sample_rate": SAMPLE_RATE, + # Enhancements + "noise_suppression_level": 2, + "auto_gain_dbfs": 15, + "volume_multiplier": 2.0, + }, + } + ) - # result - msg = await client.receive_json() - assert msg["success"] + # result + msg = await client.receive_json() + assert msg["success"] - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - msg["event"]["data"]["pipeline"] = ANY - assert msg["event"]["data"] == snapshot - handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] - events.append(msg["event"]) + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + events.append(msg["event"]) - # stt - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # One second of silence. - # This will pass through the audio enhancement pipeline, but we don't test - # the actual output. - await client.send_bytes(bytes([handler_id]) + bytes(BYTES_ONE_SECOND)) + # One second of silence. + # This will pass through the audio enhancement pipeline, but we don't test + # the actual output. + await client.send_bytes(bytes([handler_id]) + bytes(BYTES_ONE_SECOND)) - # End of audio stream (handler id + empty payload) - await client.send_bytes(bytes([handler_id])) + # End of audio stream (handler id + empty payload) + await client.send_bytes(bytes([handler_id])) - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # intent - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - msg = await client.receive_json() - assert msg["event"]["type"] == "intent-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # text-to-speech - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # text-to-speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - # run end - msg = await client.receive_json() - assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) - pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_debug)[0] - pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] - await client.send_json_auto_id( - { - "type": "assist_pipeline/pipeline_debug/get", - "pipeline_id": pipeline_id, - "pipeline_run_id": pipeline_run_id, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == {"events": events} + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} async def test_wake_word_cooldown_same_id( diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index 499981c643d00..bf9fd7302ae77 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -227,25 +227,21 @@ async def test_get_tts_audio( await on_start_callback() client = await hass_client() - url = "/api/tts_get_url" - data |= {"message": "There is someone at the door."} - - req = await client.post(url, json=data) - assert req.status == HTTPStatus.OK - response = await req.json() - - assert response == { - "url": ( - "http://example.local:8123/api/tts_proxy/" - "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_6e8b81ac47_{expected_url_suffix}.mp3" - ), - "path": ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_6e8b81ac47_{expected_url_suffix}.mp3" - ), - } - await hass.async_block_till_done() + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + url = "/api/tts_get_url" + data |= {"message": "There is someone at the door."} + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"), + "path": ("/api/tts_proxy/test_token.mp3"), + } + await hass.async_block_till_done() assert mock_process_tts.call_count == 1 assert mock_process_tts.call_args is not None @@ -280,25 +276,21 @@ async def test_get_tts_audio_logged_out( await hass.async_block_till_done() client = await hass_client() - url = "/api/tts_get_url" - data |= {"message": "There is someone at the door."} + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + url = "/api/tts_get_url" + data |= {"message": "There is someone at the door."} - req = await client.post(url, json=data) - assert req.status == HTTPStatus.OK - response = await req.json() + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() - assert response == { - "url": ( - "http://example.local:8123/api/tts_proxy/" - "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_6e8b81ac47_{expected_url_suffix}.mp3" - ), - "path": ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_6e8b81ac47_{expected_url_suffix}.mp3" - ), - } - await hass.async_block_till_done() + assert response == { + "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"), + "path": ("/api/tts_proxy/test_token.mp3"), + } + await hass.async_block_till_done() assert mock_process_tts.call_count == 1 assert mock_process_tts.call_args is not None @@ -342,28 +334,24 @@ async def test_tts_entity( assert state assert state.state == STATE_UNKNOWN - url = "/api/tts_get_url" - data = { - "engine_id": entity_id, - "message": "There is someone at the door.", - } - - req = await client.post(url, json=data) - assert req.status == HTTPStatus.OK - response = await req.json() - - assert response == { - "url": ( - "http://example.local:8123/api/tts_proxy/" - "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_6e8b81ac47_{entity_id}.mp3" - ), - "path": ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_6e8b81ac47_{entity_id}.mp3" - ), - } - await hass.async_block_till_done() + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + url = "/api/tts_get_url" + data = { + "engine_id": entity_id, + "message": "There is someone at the door.", + } + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"), + "path": ("/api/tts_proxy/test_token.mp3"), + } + await hass.async_block_till_done() assert mock_process_tts.call_count == 1 assert mock_process_tts.call_args is not None @@ -482,29 +470,25 @@ async def test_deprecated_voice( client = await hass_client() # Test with non deprecated voice. - url = "/api/tts_get_url" - data |= { - "message": "There is someone at the door.", - "language": language, - "options": {"voice": replacement_voice}, - } - - req = await client.post(url, json=data) - assert req.status == HTTPStatus.OK - response = await req.json() - - assert response == { - "url": ( - "http://example.local:8123/api/tts_proxy/" - "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_{language.lower()}_87567e3e29_{expected_url_suffix}.mp3" - ), - "path": ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_{language.lower()}_87567e3e29_{expected_url_suffix}.mp3" - ), - } - await hass.async_block_till_done() + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + url = "/api/tts_get_url" + data |= { + "message": "There is someone at the door.", + "language": language, + "options": {"voice": replacement_voice}, + } + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"), + "path": ("/api/tts_proxy/test_token.mp3"), + } + await hass.async_block_till_done() assert mock_process_tts.call_count == 1 assert mock_process_tts.call_args is not None @@ -522,22 +506,18 @@ async def test_deprecated_voice( # Test with deprecated voice. data["options"] = {"voice": deprecated_voice} - req = await client.post(url, json=data) - assert req.status == HTTPStatus.OK - response = await req.json() + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() - assert response == { - "url": ( - "http://example.local:8123/api/tts_proxy/" - "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_{language.lower()}_13646b7d32_{expected_url_suffix}.mp3" - ), - "path": ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_{language.lower()}_13646b7d32_{expected_url_suffix}.mp3" - ), - } - await hass.async_block_till_done() + assert response == { + "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"), + "path": ("/api/tts_proxy/test_token.mp3"), + } + await hass.async_block_till_done() issue_id = f"deprecated_voice_{deprecated_voice}" @@ -631,28 +611,24 @@ async def test_deprecated_gender( client = await hass_client() # Test without deprecated gender option. - url = "/api/tts_get_url" - data |= { - "message": "There is someone at the door.", - "language": language, - } - - req = await client.post(url, json=data) - assert req.status == HTTPStatus.OK - response = await req.json() - - assert response == { - "url": ( - "http://example.local:8123/api/tts_proxy/" - "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_{language.lower()}_6e8b81ac47_{expected_url_suffix}.mp3" - ), - "path": ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_{language.lower()}_6e8b81ac47_{expected_url_suffix}.mp3" - ), - } - await hass.async_block_till_done() + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + url = "/api/tts_get_url" + data |= { + "message": "There is someone at the door.", + "language": language, + } + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"), + "path": ("/api/tts_proxy/test_token.mp3"), + } + await hass.async_block_till_done() assert mock_process_tts.call_count == 1 assert mock_process_tts.call_args is not None @@ -667,22 +643,18 @@ async def test_deprecated_gender( # Test with deprecated gender option. data["options"] = {"gender": gender_option} - req = await client.post(url, json=data) - assert req.status == HTTPStatus.OK - response = await req.json() - - assert response == { - "url": ( - "http://example.local:8123/api/tts_proxy/" - "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_{language.lower()}_dd0e95eb04_{expected_url_suffix}.mp3" - ), - "path": ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_{language.lower()}_dd0e95eb04_{expected_url_suffix}.mp3" - ), - } - await hass.async_block_till_done() + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"), + "path": ("/api/tts_proxy/test_token.mp3"), + } + await hass.async_block_till_done() issue_id = "deprecated_gender" diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 2ab6dc166296f..80dff87eb9bf4 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -204,18 +204,20 @@ async def test_service( blocking=True, ) - assert len(calls) == 1 - assert calls[0].data[ATTR_MEDIA_ANNOUNCE] is True - assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_-_{expected_url_suffix}.mp3" - ) - await hass.async_block_till_done() - assert ( - mock_tts_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" - ).is_file() + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_ANNOUNCE] is True + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC + assert await get_media_source_url( + hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) == ("/api/tts_proxy/test_token.mp3") + await hass.async_block_till_done() + assert ( + mock_tts_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" + ).is_file() @pytest.mark.parametrize( @@ -266,17 +268,20 @@ async def test_service_default_language( ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_de-de_-_{expected_url_suffix}.mp3" - ) - await hass.async_block_till_done() - assert ( - mock_tts_cache_dir - / ( - f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3" - ) - ).is_file() + + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + assert await get_media_source_url( + hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) == ("/api/tts_proxy/test_token.mp3") + await hass.async_block_till_done() + assert ( + mock_tts_cache_dir + / ( + f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3" + ) + ).is_file() @pytest.mark.parametrize( @@ -327,15 +332,18 @@ async def test_service_default_special_language( ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_-_{expected_url_suffix}.mp3" - ) - await hass.async_block_till_done() - assert ( - mock_tts_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" - ).is_file() + + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + assert await get_media_source_url( + hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) == ("/api/tts_proxy/test_token.mp3") + await hass.async_block_till_done() + assert ( + mock_tts_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" + ).is_file() @pytest.mark.parametrize( @@ -384,15 +392,18 @@ async def test_service_language( ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_de-de_-_{expected_url_suffix}.mp3" - ) - await hass.async_block_till_done() - assert ( - mock_tts_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3" - ).is_file() + + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + assert await get_media_source_url( + hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) == ("/api/tts_proxy/test_token.mp3") + await hass.async_block_till_done() + assert ( + mock_tts_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3" + ).is_file() @pytest.mark.parametrize( @@ -497,18 +508,21 @@ async def test_service_options( assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" - ) - await hass.async_block_till_done() - assert ( - mock_tts_cache_dir - / ( - "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" - ) - ).is_file() + + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + assert await get_media_source_url( + hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) == ("/api/tts_proxy/test_token.mp3") + await hass.async_block_till_done() + assert ( + mock_tts_cache_dir + / ( + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" + ) + ).is_file() class MockProviderWithDefaults(MockTTSProvider): @@ -578,18 +592,21 @@ async def test_service_default_options( assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" - ) - await hass.async_block_till_done() - assert ( - mock_tts_cache_dir - / ( - "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" - ) - ).is_file() + + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + assert await get_media_source_url( + hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) == ("/api/tts_proxy/test_token.mp3") + await hass.async_block_till_done() + assert ( + mock_tts_cache_dir + / ( + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" + ) + ).is_file() @pytest.mark.parametrize( @@ -649,18 +666,21 @@ async def test_merge_default_service_options( assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" - ) - await hass.async_block_till_done() - assert ( - mock_tts_cache_dir - / ( - "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" - ) - ).is_file() + + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + assert await get_media_source_url( + hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) == ("/api/tts_proxy/test_token.mp3") + await hass.async_block_till_done() + assert ( + mock_tts_cache_dir + / ( + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" + ) + ).is_file() @pytest.mark.parametrize( @@ -1065,10 +1085,14 @@ async def test_setup_legacy_cache_dir( ) assert len(calls) == 1 - assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" - ) - await hass.async_block_till_done() + + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + assert await get_media_source_url( + hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) == ("/api/tts_proxy/test_token.mp3") + await hass.async_block_till_done() @pytest.mark.parametrize("mock_tts_entity", [MockEntityBoom(DEFAULT_LANG)]) @@ -1100,10 +1124,13 @@ async def test_setup_cache_dir( ) assert len(calls) == 1 - assert await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" - ) - await hass.async_block_till_done() + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + assert await get_media_source_url( + hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] + ) == ("/api/tts_proxy/test_token.mp3") + await hass.async_block_till_done() class MockProviderEmpty(MockTTSProvider): @@ -1176,13 +1203,13 @@ async def test_service_get_tts_error( ) -async def test_load_cache_legacy_retrieve_without_mem_cache( +async def test_legacy_cannot_retrieve_without_token( hass: HomeAssistant, mock_provider: MockTTSProvider, mock_tts_cache_dir: Path, hass_client: ClientSessionGenerator, ) -> None: - """Set up component and load cache and get without mem cache.""" + """Verify that a TTS cannot be retrieved by filename directly.""" tts_data = b"" cache_file = ( mock_tts_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" @@ -1196,17 +1223,16 @@ async def test_load_cache_legacy_retrieve_without_mem_cache( url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" req = await client.get(url) - assert req.status == HTTPStatus.OK - assert await req.read() == tts_data + assert req.status == HTTPStatus.NOT_FOUND -async def test_load_cache_retrieve_without_mem_cache( +async def test_cannot_retrieve_without_token( hass: HomeAssistant, mock_tts_entity: MockTTSEntity, mock_tts_cache_dir: Path, hass_client: ClientSessionGenerator, ) -> None: - """Set up component and load cache and get without mem cache.""" + """Verify that a TTS cannot be retrieved by filename directly.""" tts_data = b"" cache_file = mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" @@ -1220,45 +1246,37 @@ async def test_load_cache_retrieve_without_mem_cache( url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" req = await client.get(url) - assert req.status == HTTPStatus.OK - assert await req.read() == tts_data + assert req.status == HTTPStatus.NOT_FOUND @pytest.mark.parametrize( - ("setup", "data", "expected_url_suffix"), + ("setup", "data"), [ - ("mock_setup", {"platform": "test"}, "test"), - ("mock_setup", {"engine_id": "test"}, "test"), - ("mock_config_entry_setup", {"engine_id": "tts.test"}, "tts.test"), + ("mock_setup", {"platform": "test"}), + ("mock_setup", {"engine_id": "test"}), + ("mock_config_entry_setup", {"engine_id": "tts.test"}), ], indirect=["setup"], ) async def test_web_get_url( - hass_client: ClientSessionGenerator, - setup: str, - data: dict[str, Any], - expected_url_suffix: str, + hass_client: ClientSessionGenerator, setup: str, data: dict[str, Any] ) -> None: """Set up a TTS platform and receive file from web.""" client = await hass_client() - url = "/api/tts_get_url" - data |= {"message": "There is someone at the door."} - - req = await client.post(url, json=data) - assert req.status == HTTPStatus.OK - response = await req.json() - assert response == { - "url": ( - "http://example.local:8123/api/tts_proxy/" - "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_-_{expected_url_suffix}.mp3" - ), - "path": ( - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_-_{expected_url_suffix}.mp3" - ), - } + with patch( + "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token" + ): + url = "/api/tts_get_url" + data |= {"message": "There is someone at the door."} + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + assert response == { + "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"), + "path": ("/api/tts_proxy/test_token.mp3"), + } @pytest.mark.parametrize( From 904c3291d98f3368255585d25c98839d7b6d73b7 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Mon, 25 Nov 2024 10:23:07 +0100 Subject: [PATCH 0820/1070] Add exception translation in HomeWizard coordinator (#131404) --- homeassistant/components/homewizard/coordinator.py | 8 ++++++-- homeassistant/components/homewizard/quality_scale.yaml | 7 +------ homeassistant/components/homewizard/strings.json | 4 ++-- tests/components/homewizard/test_button.py | 2 +- tests/components/homewizard/test_number.py | 2 +- tests/components/homewizard/test_switch.py | 4 ++-- 6 files changed, 13 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index 4a6b8edbca49e..8f5045d3b94ce 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -66,7 +66,9 @@ async def _async_update_data(self) -> DeviceResponseEntry: ) except RequestError as ex: - raise UpdateFailed(ex) from ex + raise UpdateFailed( + ex, translation_domain=DOMAIN, translation_key="communication_error" + ) from ex except DisabledError as ex: if not self.api_disabled: @@ -79,7 +81,9 @@ async def _async_update_data(self) -> DeviceResponseEntry: self.config_entry.entry_id ) - raise UpdateFailed(ex) from ex + raise UpdateFailed( + ex, translation_domain=DOMAIN, translation_key="api_disabled" + ) from ex self.api_disabled = False diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml index 175a5b6c79779..281157465fcb8 100644 --- a/homeassistant/components/homewizard/quality_scale.yaml +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -67,12 +67,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: - status: todo - comment: | - While the integration provides some of the exception translations, the - translation for the error raised in the update error of the coordinator - is missing. + exception-translations: done icon-translations: done reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 06959fa47a5a9..b3fd5a1fef2d0 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -20,7 +20,7 @@ } }, "error": { - "api_not_enabled": "The API is not enabled. Enable API in the HomeWizard Energy App under settings", + "api_not_enabled": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings.", "network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network" }, "abort": { @@ -123,7 +123,7 @@ }, "exceptions": { "api_disabled": { - "message": "The local API of the HomeWizard device is disabled" + "message": "The local API is disabled." }, "communication_error": { "message": "An error occurred while communicating with HomeWizard device" diff --git a/tests/components/homewizard/test_button.py b/tests/components/homewizard/test_button.py index 928e6f21901d8..d0a6d92b36f46 100644 --- a/tests/components/homewizard/test_button.py +++ b/tests/components/homewizard/test_button.py @@ -79,7 +79,7 @@ async def test_identify_button( with pytest.raises( HomeAssistantError, - match=r"^The local API of the HomeWizard device is disabled$", + match=r"^The local API is disabled$", ): await hass.services.async_call( button.DOMAIN, diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index ff27fb1b257e7..ddadf09bb6ec1 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -85,7 +85,7 @@ async def test_number_entities( mock_homewizardenergy.state_set.side_effect = DisabledError with pytest.raises( HomeAssistantError, - match=r"^The local API of the HomeWizard device is disabled$", + match=r"^The local API is disabled$", ): await hass.services.async_call( number.DOMAIN, diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index b9e812620e89a..d9f1ac26b4f4b 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -174,7 +174,7 @@ async def test_switch_entities( with pytest.raises( HomeAssistantError, - match=r"^The local API of the HomeWizard device is disabled$", + match=r"^The local API is disabled$", ): await hass.services.async_call( switch.DOMAIN, @@ -185,7 +185,7 @@ async def test_switch_entities( with pytest.raises( HomeAssistantError, - match=r"^The local API of the HomeWizard device is disabled$", + match=r"^The local API is disabled$", ): await hass.services.async_call( switch.DOMAIN, From f5b2002057164247aac0fe23362d68fb9fa4fd34 Mon Sep 17 00:00:00 2001 From: dotvav Date: Mon, 25 Nov 2024 10:37:05 +0100 Subject: [PATCH 0821/1070] Make every palazzetti entity unavailable if appropriate (#131385) --- homeassistant/components/palazzetti/climate.py | 5 ----- homeassistant/components/palazzetti/entity.py | 5 +++++ homeassistant/components/palazzetti/quality_scale.yaml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py index 95e267301bc3d..356f3a7306fca 100644 --- a/homeassistant/components/palazzetti/climate.py +++ b/homeassistant/components/palazzetti/climate.py @@ -64,11 +64,6 @@ def __init__(self, coordinator: PalazzettiDataUpdateCoordinator) -> None: if client.has_fan_auto: self._attr_fan_modes.append(FAN_AUTO) - @property - def available(self) -> bool: - """Is the entity available.""" - return super().available and self.coordinator.client.connected - @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat or off mode.""" diff --git a/homeassistant/components/palazzetti/entity.py b/homeassistant/components/palazzetti/entity.py index ec850848154b1..677c6ccbdc429 100644 --- a/homeassistant/components/palazzetti/entity.py +++ b/homeassistant/components/palazzetti/entity.py @@ -25,3 +25,8 @@ def __init__(self, coordinator: PalazzettiDataUpdateCoordinator) -> None: sw_version=client.sw_version, hw_version=client.hw_version, ) + + @property + def available(self) -> bool: + """Is the entity available.""" + return super().available and self.coordinator.client.connected diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml index da51f1002dcc7..c8e19920dbe88 100644 --- a/homeassistant/components/palazzetti/quality_scale.yaml +++ b/homeassistant/components/palazzetti/quality_scale.yaml @@ -36,7 +36,7 @@ rules: comment: | This integration does not have configuration. docs-installation-parameters: todo - entity-unavailable: todo + entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: todo From 2bf7518dab99ce85cf76dbefc6eb3904ba7fe38c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 25 Nov 2024 13:31:31 +0100 Subject: [PATCH 0822/1070] Bump deebot-client to 9.0.0 (#131525) --- .../components/ecovacs/controller.py | 46 +++++++++++-------- .../components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index ec67845cf9f74..3a70ab2af5bb6 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -13,7 +13,6 @@ from deebot_client.const import UNDEFINED, UndefinedType from deebot_client.device import Device from deebot_client.exceptions import DeebotError, InvalidAuthenticationError -from deebot_client.models import DeviceInfo from deebot_client.mqtt_client import MqttClient, create_mqtt_config from deebot_client.util import md5 from deebot_client.util.continents import get_continent @@ -81,25 +80,32 @@ async def initialize(self) -> None: try: devices = await self._api_client.get_devices() credentials = await self._authenticator.authenticate() - for device_config in devices: - if isinstance(device_config, DeviceInfo): - # MQTT device - device = Device(device_config, self._authenticator) - mqtt = await self._get_mqtt_client() - await device.initialize(mqtt) - self._devices.append(device) - else: - # Legacy device - bot = VacBot( - credentials.user_id, - EcoVacsAPI.REALM, - self._device_id[0:8], - credentials.token, - device_config, - self._continent, - monitor=True, - ) - self._legacy_devices.append(bot) + for device_info in devices.mqtt: + device = Device(device_info, self._authenticator) + mqtt = await self._get_mqtt_client() + await device.initialize(mqtt) + self._devices.append(device) + for device_config in devices.xmpp: + bot = VacBot( + credentials.user_id, + EcoVacsAPI.REALM, + self._device_id[0:8], + credentials.token, + device_config, + self._continent, + monitor=True, + ) + self._legacy_devices.append(bot) + for device_config in devices.not_supported: + _LOGGER.warning( + ( + 'Device "%s" not supported. Please add support for it to ' + "https://github.com/DeebotUniverse/client.py: %s" + ), + device_config["deviceName"], + device_config, + ) + except InvalidAuthenticationError as ex: raise ConfigEntryError("Invalid credentials") from ex except DeebotError as ex: diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 0ab9f9a461271..4a43489ff2451 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==8.4.1"] + "requirements": ["py-sucks==0.9.10", "deebot-client==9.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cd22c50ef09b4..a96b90616c1cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -738,7 +738,7 @@ debugpy==1.8.6 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==8.4.1 +deebot-client==9.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f858d48cec91b..d4d4d8b69d90a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -628,7 +628,7 @@ dbus-fast==2.24.3 debugpy==1.8.6 # homeassistant.components.ecovacs -deebot-client==8.4.1 +deebot-client==9.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 9b8316df3f78d136ae73c096168bd73ffebc4465 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 25 Nov 2024 13:52:32 +0100 Subject: [PATCH 0823/1070] Revert "Make WS command backup/generate send events" (#131530) Revert "Make WS command backup/generate send events (#130524)" This reverts commit 093b16c7235a0ee69d88ff102e2838a747a96692. --- homeassistant/components/backup/__init__.py | 4 +- homeassistant/components/backup/manager.py | 62 ++--------- homeassistant/components/backup/websocket.py | 11 +- tests/components/backup/conftest.py | 73 ------------- .../backup/snapshots/test_websocket.ambr | 17 +-- tests/components/backup/test_manager.py | 101 ++++++++++-------- tests/components/backup/test_websocket.py | 18 ++-- 7 files changed, 86 insertions(+), 200 deletions(-) delete mode 100644 tests/components/backup/conftest.py diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 907fda4c7f8dd..200cb4a3f6559 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -32,9 +32,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_handle_create_service(call: ServiceCall) -> None: """Service handler for creating backups.""" - await backup_manager.async_create_backup(on_progress=None) - if backup_task := backup_manager.backup_task: - await backup_task + await backup_manager.async_create_backup() hass.services.async_register(DOMAIN, "create", async_handle_create_service) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index ddc0a1eac3fb6..4300f75eed0be 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -4,7 +4,6 @@ import abc import asyncio -from collections.abc import Callable from dataclasses import asdict, dataclass import hashlib import io @@ -35,13 +34,6 @@ BUF_SIZE = 2**20 * 4 # 4MB -@dataclass(slots=True) -class NewBackup: - """New backup class.""" - - slug: str - - @dataclass(slots=True) class Backup: """Backup class.""" @@ -57,15 +49,6 @@ def as_dict(self) -> dict: return {**asdict(self), "path": self.path.as_posix()} -@dataclass(slots=True) -class BackupProgress: - """Backup progress class.""" - - done: bool - stage: str | None - success: bool | None - - class BackupPlatformProtocol(Protocol): """Define the format that backup platforms can have.""" @@ -82,7 +65,7 @@ class BaseBackupManager(abc.ABC): def __init__(self, hass: HomeAssistant) -> None: """Initialize the backup manager.""" self.hass = hass - self.backup_task: asyncio.Task | None = None + self.backing_up = False self.backups: dict[str, Backup] = {} self.loaded_platforms = False self.platforms: dict[str, BackupPlatformProtocol] = {} @@ -150,12 +133,7 @@ async def async_restore_backup(self, slug: str, **kwargs: Any) -> None: """Restore a backup.""" @abc.abstractmethod - async def async_create_backup( - self, - *, - on_progress: Callable[[BackupProgress], None] | None, - **kwargs: Any, - ) -> NewBackup: + async def async_create_backup(self, **kwargs: Any) -> Backup: """Generate a backup.""" @abc.abstractmethod @@ -314,36 +292,17 @@ def _move_and_cleanup() -> None: await self.hass.async_add_executor_job(_move_and_cleanup) await self.load_backups() - async def async_create_backup( - self, - *, - on_progress: Callable[[BackupProgress], None] | None, - **kwargs: Any, - ) -> NewBackup: + async def async_create_backup(self, **kwargs: Any) -> Backup: """Generate a backup.""" - if self.backup_task: + if self.backing_up: raise HomeAssistantError("Backup already in progress") - backup_name = f"Core {HAVERSION}" - date_str = dt_util.now().isoformat() - slug = _generate_slug(date_str, backup_name) - self.backup_task = self.hass.async_create_task( - self._async_create_backup(backup_name, date_str, slug, on_progress), - name="backup_manager_create_backup", - eager_start=False, # To ensure the task is not started before we return - ) - return NewBackup(slug=slug) - async def _async_create_backup( - self, - backup_name: str, - date_str: str, - slug: str, - on_progress: Callable[[BackupProgress], None] | None, - ) -> Backup: - """Generate a backup.""" - success = False try: + self.backing_up = True await self.async_pre_backup_actions() + backup_name = f"Core {HAVERSION}" + date_str = dt_util.now().isoformat() + slug = _generate_slug(date_str, backup_name) backup_data = { "slug": slug, @@ -370,12 +329,9 @@ async def _async_create_backup( if self.loaded_backups: self.backups[slug] = backup LOGGER.debug("Generated new backup with slug %s", slug) - success = True return backup finally: - if on_progress: - on_progress(BackupProgress(done=True, stage=None, success=success)) - self.backup_task = None + self.backing_up = False await self.async_post_backup_actions() def _mkdir_and_generate_backup_contents( diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index a7c61b7c66c54..3ac8a7ace3e67 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -8,7 +8,6 @@ from homeassistant.core import HomeAssistant, callback from .const import DATA_MANAGER, LOGGER -from .manager import BackupProgress @callback @@ -41,7 +40,7 @@ async def handle_info( msg["id"], { "backups": list(backups.values()), - "backing_up": manager.backup_task is not None, + "backing_up": manager.backing_up, }, ) @@ -114,11 +113,7 @@ async def handle_create( msg: dict[str, Any], ) -> None: """Generate a backup.""" - - def on_progress(progress: BackupProgress) -> None: - connection.send_message(websocket_api.event_message(msg["id"], progress)) - - backup = await hass.data[DATA_MANAGER].async_create_backup(on_progress=on_progress) + backup = await hass.data[DATA_MANAGER].async_create_backup() connection.send_result(msg["id"], backup) @@ -132,6 +127,7 @@ async def handle_backup_start( ) -> None: """Backup start notification.""" manager = hass.data[DATA_MANAGER] + manager.backing_up = True LOGGER.debug("Backup start notification") try: @@ -153,6 +149,7 @@ async def handle_backup_end( ) -> None: """Backup end notification.""" manager = hass.data[DATA_MANAGER] + manager.backing_up = False LOGGER.debug("Backup end notification") try: diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py deleted file mode 100644 index 631c774e63cf6..0000000000000 --- a/tests/components/backup/conftest.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Test fixtures for the Backup integration.""" - -from __future__ import annotations - -from collections.abc import Generator -from pathlib import Path -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from homeassistant.core import HomeAssistant - - -@pytest.fixture(name="mocked_json_bytes") -def mocked_json_bytes_fixture() -> Generator[Mock]: - """Mock json_bytes.""" - with patch( - "homeassistant.components.backup.manager.json_bytes", - return_value=b"{}", # Empty JSON - ) as mocked_json_bytes: - yield mocked_json_bytes - - -@pytest.fixture(name="mocked_tarfile") -def mocked_tarfile_fixture() -> Generator[Mock]: - """Mock tarfile.""" - with patch( - "homeassistant.components.backup.manager.SecureTarFile" - ) as mocked_tarfile: - yield mocked_tarfile - - -@pytest.fixture(name="mock_backup_generation") -def mock_backup_generation_fixture( - hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock -) -> Generator[None]: - """Mock backup generator.""" - - def _mock_iterdir(path: Path) -> list[Path]: - if not path.name.endswith("testing_config"): - return [] - return [ - Path("test.txt"), - Path(".DS_Store"), - Path(".storage"), - ] - - with ( - patch("pathlib.Path.iterdir", _mock_iterdir), - patch("pathlib.Path.stat", MagicMock(st_size=123)), - patch("pathlib.Path.is_file", lambda x: x.name != ".storage"), - patch( - "pathlib.Path.is_dir", - lambda x: x.name == ".storage", - ), - patch( - "pathlib.Path.exists", - lambda x: x != Path(hass.config.path("backups")), - ), - patch( - "pathlib.Path.is_symlink", - lambda _: False, - ), - patch( - "pathlib.Path.mkdir", - MagicMock(), - ), - patch( - "homeassistant.components.backup.manager.HAVERSION", - "2025.1.0", - ), - ): - yield diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 42eb524e529dd..096df37d70477 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -210,23 +210,16 @@ dict({ 'id': 1, 'result': dict({ - 'slug': '27f5c632', + 'date': '1970-01-01T00:00:00.000Z', + 'name': 'Test', + 'path': 'abc123.tar', + 'size': 0.0, + 'slug': 'abc123', }), 'success': True, 'type': 'result', }) # --- -# name: test_generate[without_hassio].1 - dict({ - 'event': dict({ - 'done': True, - 'stage': None, - 'success': True, - }), - 'id': 1, - 'type': 'event', - }) -# --- # name: test_info[with_hassio] dict({ 'error': dict({ diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 9d24964aedf7b..a3f70267643b0 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -2,7 +2,7 @@ from __future__ import annotations -import asyncio +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch import aiohttp @@ -10,10 +10,7 @@ import pytest from homeassistant.components.backup import BackupManager -from homeassistant.components.backup.manager import ( - BackupPlatformProtocol, - BackupProgress, -) +from homeassistant.components.backup.manager import BackupPlatformProtocol from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -23,30 +20,59 @@ from tests.common import MockPlatform, mock_platform -async def _mock_backup_generation( - manager: BackupManager, mocked_json_bytes: Mock, mocked_tarfile: Mock -) -> None: +async def _mock_backup_generation(manager: BackupManager): """Mock backup generator.""" - progress: list[BackupProgress] = [] - - def on_progress(_progress: BackupProgress) -> None: - """Mock progress callback.""" - progress.append(_progress) - - assert manager.backup_task is None - await manager.async_create_backup(on_progress=on_progress) - assert manager.backup_task is not None - assert progress == [] + def _mock_iterdir(path: Path) -> list[Path]: + if not path.name.endswith("testing_config"): + return [] + return [ + Path("test.txt"), + Path(".DS_Store"), + Path(".storage"), + ] - await manager.backup_task - assert progress == [BackupProgress(done=True, stage=None, success=True)] - - assert mocked_json_bytes.call_count == 1 - backup_json_dict = mocked_json_bytes.call_args[0][0] - assert isinstance(backup_json_dict, dict) - assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"} - assert manager.backup_dir.as_posix() in str(mocked_tarfile.call_args_list[0][0][0]) + with ( + patch( + "homeassistant.components.backup.manager.SecureTarFile" + ) as mocked_tarfile, + patch("pathlib.Path.iterdir", _mock_iterdir), + patch("pathlib.Path.stat", MagicMock(st_size=123)), + patch("pathlib.Path.is_file", lambda x: x.name != ".storage"), + patch( + "pathlib.Path.is_dir", + lambda x: x.name == ".storage", + ), + patch( + "pathlib.Path.exists", + lambda x: x != manager.backup_dir, + ), + patch( + "pathlib.Path.is_symlink", + lambda _: False, + ), + patch( + "pathlib.Path.mkdir", + MagicMock(), + ), + patch( + "homeassistant.components.backup.manager.json_bytes", + return_value=b"{}", # Empty JSON + ) as mocked_json_bytes, + patch( + "homeassistant.components.backup.manager.HAVERSION", + "2025.1.0", + ), + ): + await manager.async_create_backup() + + assert mocked_json_bytes.call_count == 1 + backup_json_dict = mocked_json_bytes.call_args[0][0] + assert isinstance(backup_json_dict, dict) + assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"} + assert manager.backup_dir.as_posix() in str( + mocked_tarfile.call_args_list[0][0][0] + ) async def _setup_mock_domain( @@ -150,26 +176,21 @@ async def test_getting_backup_that_does_not_exist( async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None: """Test generate backup.""" - event = asyncio.Event() manager = BackupManager(hass) - manager.backup_task = hass.async_create_task(event.wait()) + manager.backing_up = True with pytest.raises(HomeAssistantError, match="Backup already in progress"): - await manager.async_create_backup(on_progress=None) - event.set() + await manager.async_create_backup() -@pytest.mark.usefixtures("mock_backup_generation") async def test_async_create_backup( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mocked_json_bytes: Mock, - mocked_tarfile: Mock, ) -> None: """Test generate backup.""" manager = BackupManager(hass) manager.loaded_backups = True - await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) + await _mock_backup_generation(manager) assert "Generated new backup with slug " in caplog.text assert "Creating backup directory" in caplog.text @@ -226,9 +247,7 @@ async def test_not_loading_bad_platforms( ) -async def test_exception_plaform_pre( - hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock -) -> None: +async def test_exception_plaform_pre(hass: HomeAssistant) -> None: """Test exception in pre step.""" manager = BackupManager(hass) manager.loaded_backups = True @@ -245,12 +264,10 @@ async def _mock_step(hass: HomeAssistant) -> None: ) with pytest.raises(HomeAssistantError): - await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) + await _mock_backup_generation(manager) -async def test_exception_plaform_post( - hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock -) -> None: +async def test_exception_plaform_post(hass: HomeAssistant) -> None: """Test exception in post step.""" manager = BackupManager(hass) manager.loaded_backups = True @@ -267,7 +284,7 @@ async def _mock_step(hass: HomeAssistant) -> None: ) with pytest.raises(HomeAssistantError): - await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) + await _mock_backup_generation(manager) async def test_loading_platforms_when_running_async_pre_backup_actions( diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 3e031f172aee9..125ba8adaad1c 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -2,7 +2,6 @@ from unittest.mock import patch -from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -116,30 +115,29 @@ async def test_remove( @pytest.mark.parametrize( - ("with_hassio", "number_of_messages"), + "with_hassio", [ - pytest.param(True, 1, id="with_hassio"), - pytest.param(False, 2, id="without_hassio"), + pytest.param(True, id="with_hassio"), + pytest.param(False, id="without_hassio"), ], ) -@pytest.mark.usefixtures("mock_backup_generation") async def test_generate( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, with_hassio: bool, - number_of_messages: int, ) -> None: """Test generating a backup.""" await setup_backup_integration(hass, with_hassio=with_hassio) client = await hass_ws_client(hass) - freezer.move_to("2024-11-13 12:01:00+01:00") await hass.async_block_till_done() - await client.send_json_auto_id({"type": "backup/generate"}) - for _ in range(number_of_messages): + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + return_value=TEST_BACKUP, + ): + await client.send_json_auto_id({"type": "backup/generate"}) assert snapshot == await client.receive_json() From 2a52de48c5bd3fa4991c1a5d1f373ec78b6c9229 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 25 Nov 2024 14:29:35 +0100 Subject: [PATCH 0824/1070] Remove deprecated v2 api from glances (#131427) --- homeassistant/components/glances/__init__.py | 16 ++--------- homeassistant/components/glances/strings.json | 6 ---- tests/components/glances/test_config_flow.py | 18 ++++++------ tests/components/glances/test_init.py | 28 ++----------------- 4 files changed, 13 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py index 0ddd8a8697904..9d09e63606e3c 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -28,9 +28,7 @@ HomeAssistantError, ) from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import DOMAIN from .coordinator import GlancesDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -71,7 +69,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: GlancesConfigEntry) -> async def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances: """Return the api from glances_api.""" httpx_client = get_async_client(hass, verify_ssl=entry_data[CONF_VERIFY_SSL]) - for version in (4, 3, 2): + for version in (4, 3): api = Glances( host=entry_data[CONF_HOST], port=entry_data[CONF_PORT], @@ -86,19 +84,9 @@ async def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances: except GlancesApiNoDataAvailable as err: _LOGGER.debug("Failed to connect to Glances API v%s: %s", version, err) continue - if version == 2: - async_create_issue( - hass, - DOMAIN, - "deprecated_version", - breaks_in_ha_version="2024.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_version", - ) _LOGGER.debug("Connected to Glances API v%s", version) return api - raise ServerVersionMismatch("Could not connect to Glances API version 2, 3 or 4") + raise ServerVersionMismatch("Could not connect to Glances API version 3 or 4") class ServerVersionMismatch(HomeAssistantError): diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index 11735601ce94e..92aa1b47e3134 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -123,11 +123,5 @@ "name": "{sensor_label} TX" } } - }, - "issues": { - "deprecated_version": { - "title": "Glances servers with version 2 is deprecated", - "description": "Glances servers with version 2 is deprecated and will not be supported in future versions of HA. It is recommended to update your server to Glances version 3 then reload the integration." - } } } diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index ae8c2e1d51ea3..b8d376d652f5c 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -1,6 +1,6 @@ """Tests for Glances config flow.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from glances_api.exceptions import ( GlancesApiAuthorizationError, @@ -10,14 +10,14 @@ import pytest from homeassistant import config_entries -from homeassistant.components import glances +from homeassistant.components.glances.const import DOMAIN from homeassistant.const import CONF_NAME, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import HA_SENSOR_DATA, MOCK_USER_INPUT -from tests.common import MockConfigEntry, patch +from tests.common import MockConfigEntry @pytest.fixture(autouse=True) @@ -31,7 +31,7 @@ async def test_form(hass: HomeAssistant) -> None: """Test config entry configured successfully.""" result = await hass.config_entries.flow.async_init( - glances.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -60,7 +60,7 @@ async def test_form_fails( mock_api.return_value.get_ha_sensor_data.side_effect = error result = await hass.config_entries.flow.async_init( - glances.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_INPUT @@ -72,11 +72,11 @@ async def test_form_fails( async def test_form_already_configured(hass: HomeAssistant) -> None: """Test host is already configured.""" - entry = MockConfigEntry(domain=glances.DOMAIN, data=MOCK_USER_INPUT) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - glances.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_INPUT @@ -87,7 +87,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: async def test_reauth_success(hass: HomeAssistant) -> None: """Test we can reauth.""" - entry = MockConfigEntry(domain=glances.DOMAIN, data=MOCK_USER_INPUT) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -120,7 +120,7 @@ async def test_reauth_fails( hass: HomeAssistant, error: Exception, message: str, mock_api: MagicMock ) -> None: """Test we can reauth.""" - entry = MockConfigEntry(domain=glances.DOMAIN, data=MOCK_USER_INPUT) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) entry.add_to_hass(hass) mock_api.return_value.get_ha_sensor_data.side_effect = [error, HA_SENSOR_DATA] diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py index 553bd6f2089a3..16d4d9d371bb1 100644 --- a/tests/components/glances/test_init.py +++ b/tests/components/glances/test_init.py @@ -1,6 +1,6 @@ """Tests for Glances integration.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock from glances_api.exceptions import ( GlancesApiAuthorizationError, @@ -12,9 +12,8 @@ from homeassistant.components.glances.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from . import HA_SENSOR_DATA, MOCK_USER_INPUT +from . import MOCK_USER_INPUT from tests.common import MockConfigEntry @@ -30,29 +29,6 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.LOADED -async def test_entry_deprecated_version( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_api: AsyncMock -) -> None: - """Test creating an issue if glances server is version 2.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) - entry.add_to_hass(hass) - - mock_api.return_value.get_ha_sensor_data.side_effect = [ - GlancesApiNoDataAvailable("endpoint: 'all' is not valid"), # fail v4 - GlancesApiNoDataAvailable("endpoint: 'all' is not valid"), # fail v3 - HA_SENSOR_DATA, # success v2 - HA_SENSOR_DATA, - ] - - await hass.config_entries.async_setup(entry.entry_id) - - assert entry.state is ConfigEntryState.LOADED - - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_version") - assert issue is not None - assert issue.severity == ir.IssueSeverity.WARNING - - @pytest.mark.parametrize( ("error", "entry_state"), [ From 5c56275310e5b6b76ba72497f9230ce7c19e557e Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:46:13 +0100 Subject: [PATCH 0825/1070] Bump aioacaia to 0.1.9 (#131533) --- homeassistant/components/acaia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json index afe4490a41745..49b3489cf9ad6 100644 --- a/homeassistant/components/acaia/manifest.json +++ b/homeassistant/components/acaia/manifest.json @@ -25,5 +25,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioacaia"], - "requirements": ["aioacaia==0.1.8"] + "requirements": ["aioacaia==0.1.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index a96b90616c1cf..5f2954484da0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -173,7 +173,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.8 +aioacaia==0.1.9 # homeassistant.components.airq aioairq==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4d4d8b69d90a..3b2eb7dc046ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -161,7 +161,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.8 +aioacaia==0.1.9 # homeassistant.components.airq aioairq==0.4.3 From 5ef5838b20d4d6f8d340a008cb7004bb4b580228 Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Mon, 25 Nov 2024 14:55:16 +0000 Subject: [PATCH 0826/1070] Bump aio-geojson-generic-client to 0.5 (#131514) --- homeassistant/components/geo_json_events/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json index 8f4b36657dd6b..c41796514a5e0 100644 --- a/homeassistant/components/geo_json_events/manifest.json +++ b/homeassistant/components/geo_json_events/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_generic_client"], - "requirements": ["aio-geojson-generic-client==0.4"] + "requirements": ["aio-geojson-generic-client==0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5f2954484da0e..00441915719ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -155,7 +155,7 @@ afsapi==0.2.7 agent-py==0.0.24 # homeassistant.components.geo_json_events -aio-geojson-generic-client==0.4 +aio-geojson-generic-client==0.5 # homeassistant.components.geonetnz_quakes aio-geojson-geonetnz-quakes==0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b2eb7dc046ee..4bb0102e10aa0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -143,7 +143,7 @@ afsapi==0.2.7 agent-py==0.0.24 # homeassistant.components.geo_json_events -aio-geojson-generic-client==0.4 +aio-geojson-generic-client==0.5 # homeassistant.components.geonetnz_quakes aio-geojson-geonetnz-quakes==0.16 From fe3cdad06fad862b3e7a12c2577538e81c516ad6 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 25 Nov 2024 19:43:03 +0100 Subject: [PATCH 0827/1070] Bump velbusaio to 2024.11.1 (#131506) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index cd93b07f748de..84262ebd61c2a 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2024.11.0"], + "requirements": ["velbus-aio==2024.11.1"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index 00441915719ec..153fdf9358e4a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2938,7 +2938,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.11.0 +velbus-aio==2024.11.1 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4bb0102e10aa0..d45e465bb2c2d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2345,7 +2345,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.11.0 +velbus-aio==2024.11.1 # homeassistant.components.venstar venstarcolortouch==0.19 From 19c42774a478a59ac069396edcc791734ca22ed1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 25 Nov 2024 19:57:48 +0100 Subject: [PATCH 0828/1070] Update pytest-cov to 6.0.0 (#131518) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 73874e3a63119..5b6af4b9a6223 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -20,7 +20,7 @@ pylint-per-file-ignores==1.3.2 pipdeptree==2.23.4 pytest-asyncio==0.24.0 pytest-aiohttp==1.0.5 -pytest-cov==5.0.0 +pytest-cov==6.0.0 pytest-freezer==0.4.8 pytest-github-actions-annotate-failures==0.2.0 pytest-socket==0.7.0 From 7aa30758f996af3ebb980eed737a2ac8bf9b8cd3 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 25 Nov 2024 19:58:12 +0100 Subject: [PATCH 0829/1070] Bump pyoverkiz 1.15.0 (#131478) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 52fd1dfc669cc..8c750aec6bd9e 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -20,7 +20,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.14.1"], + "requirements": ["pyoverkiz==1.15.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 153fdf9358e4a..c8c06c4711a6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2149,7 +2149,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.14.1 +pyoverkiz==1.15.0 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d45e465bb2c2d..fb83d2adff2a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1736,7 +1736,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.14.1 +pyoverkiz==1.15.0 # homeassistant.components.onewire pyownet==0.10.0.post1 From 1b62e122617aa9111b9e9b7478dff3c49354173c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 25 Nov 2024 21:17:19 +0100 Subject: [PATCH 0830/1070] Deprecate RTSPtoWebRTC (#131467) * Deprecate RTSPtoWebRTC * Update homeassistant/components/rtsp_to_webrtc/strings.json Co-authored-by: Allen Porter * Updated text --------- Co-authored-by: Allen Porter --- .../components/rtsp_to_webrtc/__init__.py | 16 ++++++++++++++++ .../components/rtsp_to_webrtc/strings.json | 6 ++++++ tests/components/rtsp_to_webrtc/test_init.py | 19 +++++++++++++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rtsp_to_webrtc/__init__.py b/homeassistant/components/rtsp_to_webrtc/__init__.py index 59b8077e398b7..0fc257c463f66 100644 --- a/homeassistant/components/rtsp_to_webrtc/__init__.py +++ b/homeassistant/components/rtsp_to_webrtc/__init__.py @@ -30,6 +30,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession _LOGGER = logging.getLogger(__name__) @@ -40,10 +41,24 @@ TIMEOUT = 10 CONF_STUN_SERVER = "stun_server" +_DEPRECATED = "deprecated" + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up RTSPtoWebRTC from a config entry.""" hass.data.setdefault(DOMAIN, {}) + ir.async_create_issue( + hass, + DOMAIN, + _DEPRECATED, + breaks_in_ha_version="2025.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key=_DEPRECATED, + translation_placeholders={ + "go2rtc": "[go2rtc](https://www.home-assistant.io/integrations/go2rtc/)", + }, + ) client: WebRTCClientInterface try: @@ -98,6 +113,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if DOMAIN in hass.data: del hass.data[DOMAIN] + ir.async_delete_issue(hass, DOMAIN, _DEPRECATED) return True diff --git a/homeassistant/components/rtsp_to_webrtc/strings.json b/homeassistant/components/rtsp_to_webrtc/strings.json index e52ab554473fa..c8dcbb7f46293 100644 --- a/homeassistant/components/rtsp_to_webrtc/strings.json +++ b/homeassistant/components/rtsp_to_webrtc/strings.json @@ -24,6 +24,12 @@ "server_unreachable": "[%key:component::rtsp_to_webrtc::config::error::server_unreachable%]" } }, + "issues": { + "deprecated": { + "title": "The RTSPtoWebRTC integration is deprecated", + "description": "The RTSPtoWebRTC integration is deprecated and will be removed. Please use the {go2rtc} integration instead, which is enabled by default and provides a better experience. You only need to remove the RTSPtoWebRTC config entry." + } + }, "options": { "step": { "init": { diff --git a/tests/components/rtsp_to_webrtc/test_init.py b/tests/components/rtsp_to_webrtc/test_init.py index 85155855a099b..985e76fa1d1c5 100644 --- a/tests/components/rtsp_to_webrtc/test_init.py +++ b/tests/components/rtsp_to_webrtc/test_init.py @@ -14,10 +14,12 @@ from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import SERVER_URL, STREAM_SOURCE, ComponentSetup +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -33,15 +35,28 @@ async def setup_homeassistant(hass: HomeAssistant): await async_setup_component(hass, "homeassistant", {}) +@pytest.mark.usefixtures("rtsp_to_webrtc_client") async def test_setup_success( - hass: HomeAssistant, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, ) -> None: """Test successful setup and unload.""" - await setup_integration() + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert issue_registry.async_get_issue(DOMAIN, "deprecated") entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert entries[0].state is ConfigEntryState.NOT_LOADED + assert not issue_registry.async_get_issue(DOMAIN, "deprecated") @pytest.mark.parametrize("config_entry_data", [{}]) From 4a8f3eea69bfb8d4f7fcfc2eb029fd5c7135be43 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 25 Nov 2024 21:33:47 +0100 Subject: [PATCH 0831/1070] Bump stookwijzer to v1.5.1 (#131567) --- .../components/stookwijzer/__init__.py | 48 +++++++++- .../components/stookwijzer/config_flow.py | 20 +++- homeassistant/components/stookwijzer/const.py | 9 -- .../components/stookwijzer/diagnostics.py | 12 +-- .../components/stookwijzer/manifest.json | 2 +- .../components/stookwijzer/sensor.py | 26 +++--- .../components/stookwijzer/strings.json | 18 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/stookwijzer/conftest.py | 92 +++++++++++++++++++ .../snapshots/test_diagnostics.ambr | 6 ++ .../stookwijzer/snapshots/test_sensor.ambr | 60 ++++++++++++ .../stookwijzer/test_config_flow.py | 82 ++++++++++++----- .../stookwijzer/test_diagnostics.py | 22 +++++ tests/components/stookwijzer/test_init.py | 55 +++++++++++ tests/components/stookwijzer/test_sensor.py | 20 ++++ 16 files changed, 401 insertions(+), 75 deletions(-) create mode 100644 tests/components/stookwijzer/conftest.py create mode 100644 tests/components/stookwijzer/snapshots/test_diagnostics.ambr create mode 100644 tests/components/stookwijzer/snapshots/test_sensor.ambr create mode 100644 tests/components/stookwijzer/test_diagnostics.py create mode 100644 tests/components/stookwijzer/test_init.py create mode 100644 tests/components/stookwijzer/test_sensor.py diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index a714e3bd36800..f121c8ab4bb23 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -7,8 +7,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .const import DOMAIN, LOGGER PLATFORMS = [Platform.SENSOR] @@ -16,8 +18,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Stookwijzer from a config entry.""" hass.data.setdefault(DOMAIN, {})[entry.entry_id] = Stookwijzer( - entry.data[CONF_LOCATION][CONF_LATITUDE], - entry.data[CONF_LOCATION][CONF_LONGITUDE], + async_get_clientsession(hass), + entry.data[CONF_LATITUDE], + entry.data[CONF_LONGITUDE], ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -28,3 +31,42 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): del hass.data[DOMAIN][entry.entry_id] return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(hass), + entry.data[CONF_LOCATION][CONF_LATITUDE], + entry.data[CONF_LOCATION][CONF_LONGITUDE], + ) + + if not latitude or not longitude: + ir.async_create_issue( + hass, + DOMAIN, + "location_migration_failed", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="location_migration_failed", + translation_placeholders={ + "entry_title": entry.title, + }, + ) + return False + + hass.config_entries.async_update_entry( + entry, + version=2, + data={ + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + }, + ) + + LOGGER.debug("Migration to version %s successful", entry.version) + + return True diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index be53ce56390e0..32b4836763f3e 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -4,10 +4,12 @@ from typing import Any +from stookwijzer import Stookwijzer import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -16,21 +18,29 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for Stookwijzer.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - + errors = {} if user_input is not None: - return self.async_create_entry( - title="Stookwijzer", - data=user_input, + latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(self.hass), + user_input[CONF_LOCATION][CONF_LATITUDE], + user_input[CONF_LOCATION][CONF_LONGITUDE], ) + if latitude and longitude: + return self.async_create_entry( + title="Stookwijzer", + data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, + ) + errors["base"] = "unknown" return self.async_show_form( step_id="user", + errors=errors, data_schema=vol.Schema( { vol.Required( diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index e8cb3d818e62d..1b0be86d375ce 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -1,16 +1,7 @@ """Constants for the Stookwijzer integration.""" -from enum import StrEnum import logging from typing import Final DOMAIN: Final = "stookwijzer" LOGGER = logging.getLogger(__package__) - - -class StookwijzerState(StrEnum): - """Stookwijzer states for sensor entity.""" - - BLUE = "blauw" - ORANGE = "oranje" - RED = "rood" diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py index c7bf4fad14d2b..59c28482541f9 100644 --- a/homeassistant/components/stookwijzer/diagnostics.py +++ b/homeassistant/components/stookwijzer/diagnostics.py @@ -17,16 +17,6 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" client: Stookwijzer = hass.data[DOMAIN][entry.entry_id] - - last_updated = None - if client.last_updated: - last_updated = client.last_updated.isoformat() - return { - "state": client.state, - "last_updated": last_updated, - "lqi": client.lqi, - "windspeed": client.windspeed, - "weather": client.weather, - "concentrations": client.concentrations, + "advice": client.advice, } diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index dbf902b1e1e69..3fe16fb3d33ab 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.3.0"] + "requirements": ["stookwijzer==1.5.1"] } diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index b8f9a660598af..8489da82c36b8 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -12,7 +12,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, StookwijzerState +from .const import DOMAIN SCAN_INTERVAL = timedelta(minutes=60) @@ -30,37 +30,33 @@ async def async_setup_entry( class StookwijzerSensor(SensorEntity): """Defines a Stookwijzer binary sensor.""" - _attr_attribution = "Data provided by stookwijzer.nu" + _attr_attribution = "Data provided by atlasleefomgeving.nl" _attr_device_class = SensorDeviceClass.ENUM _attr_has_entity_name = True - _attr_name = None - _attr_translation_key = "stookwijzer" + _attr_translation_key = "advice" def __init__(self, client: Stookwijzer, entry: ConfigEntry) -> None: """Initialize a Stookwijzer device.""" self._client = client - self._attr_options = [cls.value for cls in StookwijzerState] + self._attr_options = ["code_yellow", "code_orange", "code_red"] self._attr_unique_id = entry.entry_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{entry.entry_id}")}, - name="Stookwijzer", - manufacturer="stookwijzer.nu", + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Atlas Leefomgeving", entry_type=DeviceEntryType.SERVICE, - configuration_url="https://www.stookwijzer.nu", + configuration_url="https://www.atlasleefomgeving.nl/stookwijzer", ) - def update(self) -> None: + async def async_update(self) -> None: """Update the data from the Stookwijzer handler.""" - self._client.update() + await self._client.async_update() @property def available(self) -> bool: """Return if entity is available.""" - return self._client.state is not None + return self._client.advice is not None @property def native_value(self) -> str | None: """Return the state of the device.""" - if self._client.state is None: - return None - return StookwijzerState(self._client.state).value + return self._client.advice diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index 549673165ec3e..253b103456721 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -7,17 +7,27 @@ "location": "[%key:common::config_flow::data::location%]" } } + }, + "error": { + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "entity": { "sensor": { - "stookwijzer": { + "advice": { + "name": "Advice code", "state": { - "blauw": "Blue", - "oranje": "Orange", - "rood": "Red" + "code_yellow": "Yellow", + "code_orange": "Orange", + "code_red": "Red" } } } + }, + "issues": { + "location_migration_failed": { + "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integrations uses.\n\nMake sure you are connected to the internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", + "title": "Migration of your location failed" + } } } diff --git a/requirements_all.txt b/requirements_all.txt index c8c06c4711a6b..90aa3077ae834 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2746,7 +2746,7 @@ steamodd==4.21 stookalert==0.1.4 # homeassistant.components.stookwijzer -stookwijzer==1.3.0 +stookwijzer==1.5.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb83d2adff2a3..7ffd9d02f39c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2195,7 +2195,7 @@ steamodd==4.21 stookalert==0.1.4 # homeassistant.components.stookwijzer -stookwijzer==1.3.0 +stookwijzer==1.5.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py new file mode 100644 index 0000000000000..974b6ef564376 --- /dev/null +++ b/tests/components/stookwijzer/conftest.py @@ -0,0 +1,92 @@ +"""Fixtures for Stookwijzer integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.stookwijzer.const import DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Stookwijzer", + domain=DOMAIN, + data={ + CONF_LATITUDE: 200000.1234567890, + CONF_LONGITUDE: 450000.1234567890, + }, + version=2, + entry_id="12345", + ) + + +@pytest.fixture +def mock_v1_config_entry() -> MockConfigEntry: + """Return the default mocked version 1 config entry.""" + return MockConfigEntry( + title="Stookwijzer", + domain=DOMAIN, + data={ + CONF_LOCATION: { + CONF_LATITUDE: 1.0, + CONF_LONGITUDE: 1.1, + }, + }, + version=1, + entry_id="12345", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.stookwijzer.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_stookwijzer() -> Generator[MagicMock]: + """Return a mocked Stookwijzer client.""" + with ( + patch( + "homeassistant.components.stookwijzer.Stookwijzer", + autospec=True, + ) as stookwijzer_mock, + patch( + "homeassistant.components.stookwijzer.config_flow.Stookwijzer", + new=stookwijzer_mock, + ), + ): + stookwijzer_mock.async_transform_coordinates.return_value = ( + 200000.123456789, + 450000.123456789, + ) + + client = stookwijzer_mock.return_value + client.advice = "code_yellow" + + yield stookwijzer_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_stookwijzer: MagicMock, +) -> MockConfigEntry: + """Set up the Stookwijzer integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/stookwijzer/snapshots/test_diagnostics.ambr b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..f2b7815f753f5 --- /dev/null +++ b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr @@ -0,0 +1,6 @@ +# serializer version: 1 +# name: test_get_diagnostics + dict({ + 'advice': 'code_yellow', + }) +# --- diff --git a/tests/components/stookwijzer/snapshots/test_sensor.ambr b/tests/components/stookwijzer/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..bd7fcc6fef570 --- /dev/null +++ b/tests/components/stookwijzer/snapshots/test_sensor.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_entities[sensor.stookwijzer_advice_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'code_yellow', + 'code_orange', + 'code_red', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stookwijzer_advice_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Advice code', + 'platform': 'stookwijzer', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'advice', + 'unique_id': '12345', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.stookwijzer_advice_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by atlasleefomgeving.nl', + 'device_class': 'enum', + 'friendly_name': 'Stookwijzer Advice code', + 'options': list([ + 'code_yellow', + 'code_orange', + 'code_red', + ]), + }), + 'context': , + 'entity_id': 'sensor.stookwijzer_advice_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'code_yellow', + }) +# --- diff --git a/tests/components/stookwijzer/test_config_flow.py b/tests/components/stookwijzer/test_config_flow.py index 732e8abfc9846..6dddf83c27a10 100644 --- a/tests/components/stookwijzer/test_config_flow.py +++ b/tests/components/stookwijzer/test_config_flow.py @@ -1,6 +1,8 @@ """Tests for the Stookwijzer config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock + +import pytest from homeassistant.components.stookwijzer.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -9,35 +11,65 @@ from homeassistant.data_entry_flow import FlowResultType -async def test_full_user_flow(hass: HomeAssistant) -> None: +async def test_full_user_flow( + hass: HomeAssistant, + mock_stookwijzer: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert "flow_id" in result - - with patch( - "homeassistant.components.stookwijzer.async_setup_entry", return_value=True - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_LOCATION: { - CONF_LATITUDE: 1.0, - CONF_LONGITUDE: 1.1, - } - }, - ) - - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("data") == { - "location": { - "latitude": 1.0, - "longitude": 1.1, - }, + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {CONF_LATITUDE: 1.0, CONF_LONGITUDE: 1.1}}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stookwijzer" + assert result["data"] == { + CONF_LATITUDE: 200000.123456789, + CONF_LONGITUDE: 450000.123456789, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_stookwijzer.async_transform_coordinates.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_connection_error( + hass: HomeAssistant, + mock_stookwijzer: MagicMock, +) -> None: + """Test user configuration flow while connection fails.""" + original_return_value = mock_stookwijzer.async_transform_coordinates.return_value + mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {CONF_LATITUDE: 1.0, CONF_LONGITUDE: 1.1}}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + # Ensure we can continue the flow, when it now works + mock_stookwijzer.async_transform_coordinates.return_value = original_return_value + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {CONF_LATITUDE: 1.0, CONF_LONGITUDE: 1.1}}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/stookwijzer/test_diagnostics.py b/tests/components/stookwijzer/test_diagnostics.py new file mode 100644 index 0000000000000..f40165020c19f --- /dev/null +++ b/tests/components/stookwijzer/test_diagnostics.py @@ -0,0 +1,22 @@ +"""Test the Stookwijzer diagnostics.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_get_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Stookwijzer diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/stookwijzer/test_init.py b/tests/components/stookwijzer/test_init.py new file mode 100644 index 0000000000000..1a84954c21aed --- /dev/null +++ b/tests/components/stookwijzer/test_init.py @@ -0,0 +1,55 @@ +"""Test the Stookwijzer init.""" + +from unittest.mock import MagicMock + +from homeassistant.components.stookwijzer.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_migrate_entry( + hass: HomeAssistant, + mock_v1_config_entry: MockConfigEntry, + mock_stookwijzer: MagicMock, +) -> None: + """Test successful migration of entry data.""" + assert mock_v1_config_entry.version == 1 + + mock_v1_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_v1_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_v1_config_entry.state is ConfigEntryState.LOADED + assert len(mock_stookwijzer.async_transform_coordinates.mock_calls) == 1 + + assert mock_v1_config_entry.version == 2 + assert mock_v1_config_entry.data == { + CONF_LATITUDE: 200000.123456789, + CONF_LONGITUDE: 450000.123456789, + } + + +async def test_entry_migration_failure( + hass: HomeAssistant, + mock_v1_config_entry: MockConfigEntry, + mock_stookwijzer: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test successful migration of entry data.""" + assert mock_v1_config_entry.version == 1 + + # Failed getting the transformed coordinates + mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + + mock_v1_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_v1_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_v1_config_entry.state is ConfigEntryState.MIGRATION_ERROR + assert issue_registry.async_get_issue(DOMAIN, "location_migration_failed") + + assert len(mock_stookwijzer.async_transform_coordinates.mock_calls) == 1 diff --git a/tests/components/stookwijzer/test_sensor.py b/tests/components/stookwijzer/test_sensor.py new file mode 100644 index 0000000000000..10eeef72d74a7 --- /dev/null +++ b/tests/components/stookwijzer/test_sensor.py @@ -0,0 +1,20 @@ +"""Tests for the Stookwijzer sensor platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("init_integration") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Stookwijzer entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From cf74532cc6d0315cbb158850ebef00b417bb37c7 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 25 Nov 2024 21:59:20 +0100 Subject: [PATCH 0832/1070] Bump uv to 0.5.4 (#131513) --- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1557419209390..61d64212b402a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.5.0 +RUN pip3 install uv==0.5.4 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 40232ffb24c55..765fbd89a2440 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -65,7 +65,7 @@ standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.5.0 +uv==0.5.4 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 6b0b920678ca2..e281a2429d05d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.5.0", + "uv==0.5.4", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", diff --git a/requirements.txt b/requirements.txt index ba4ed56a7a042..5ca035921078d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.5.0 +uv==0.5.4 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 89b10fa3027b5..b75477820ffeb 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.5.4,source=/uv,target=/bin/uv \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ From 4ba8db1de47552856207bd36a8c1d35c8094f1da Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 25 Nov 2024 22:15:23 +0100 Subject: [PATCH 0833/1070] Add data coordinator to Stookwijzer (#131574) --- .../components/stookwijzer/__init__.py | 25 ++++++----- .../components/stookwijzer/coordinator.py | 44 +++++++++++++++++++ .../components/stookwijzer/diagnostics.py | 9 ++-- .../components/stookwijzer/sensor.py | 30 ++++--------- .../components/stookwijzer/strings.json | 5 +++ tests/components/stookwijzer/conftest.py | 4 ++ 6 files changed, 77 insertions(+), 40 deletions(-) create mode 100644 homeassistant/components/stookwijzer/coordinator.py diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index f121c8ab4bb23..ee525c5323a36 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -4,36 +4,37 @@ from stookwijzer import Stookwijzer -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER +from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: StookwijzerConfigEntry) -> bool: """Set up Stookwijzer from a config entry.""" - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = Stookwijzer( - async_get_clientsession(hass), - entry.data[CONF_LATITUDE], - entry.data[CONF_LONGITUDE], - ) + coordinator = StookwijzerCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: StookwijzerConfigEntry +) -> bool: """Unload Stookwijzer config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, entry: StookwijzerConfigEntry +) -> bool: """Migrate old entry.""" LOGGER.debug("Migrating from version %s", entry.version) diff --git a/homeassistant/components/stookwijzer/coordinator.py b/homeassistant/components/stookwijzer/coordinator.py new file mode 100644 index 0000000000000..23092bed66e84 --- /dev/null +++ b/homeassistant/components/stookwijzer/coordinator.py @@ -0,0 +1,44 @@ +"""Class representing a Stookwijzer update coordinator.""" + +from datetime import timedelta + +from stookwijzer import Stookwijzer + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +SCAN_INTERVAL = timedelta(minutes=60) + +type StookwijzerConfigEntry = ConfigEntry[StookwijzerCoordinator] + + +class StookwijzerCoordinator(DataUpdateCoordinator[None]): + """Stookwijzer update coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: StookwijzerConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.client = Stookwijzer( + async_get_clientsession(hass), + entry.data[CONF_LATITUDE], + entry.data[CONF_LONGITUDE], + ) + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + await self.client.async_update() + if self.client.advice is None: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_data_received", + ) diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py index 59c28482541f9..c59a2e6175214 100644 --- a/homeassistant/components/stookwijzer/diagnostics.py +++ b/homeassistant/components/stookwijzer/diagnostics.py @@ -4,19 +4,16 @@ from typing import Any -from stookwijzer import Stookwijzer - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import StookwijzerConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: StookwijzerConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - client: Stookwijzer = hass.data[DOMAIN][entry.entry_id] + client = entry.runtime_data.client return { "advice": client.advice, } diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index 8489da82c36b8..25396639ecda3 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -2,32 +2,26 @@ from __future__ import annotations -from datetime import timedelta - -from stookwijzer import Stookwijzer - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN - -SCAN_INTERVAL = timedelta(minutes=60) +from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StookwijzerConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Stookwijzer sensor from a config entry.""" - client = hass.data[DOMAIN][entry.entry_id] - async_add_entities([StookwijzerSensor(client, entry)], update_before_add=True) + async_add_entities([StookwijzerSensor(entry)]) -class StookwijzerSensor(SensorEntity): +class StookwijzerSensor(CoordinatorEntity[StookwijzerCoordinator], SensorEntity): """Defines a Stookwijzer binary sensor.""" _attr_attribution = "Data provided by atlasleefomgeving.nl" @@ -35,9 +29,10 @@ class StookwijzerSensor(SensorEntity): _attr_has_entity_name = True _attr_translation_key = "advice" - def __init__(self, client: Stookwijzer, entry: ConfigEntry) -> None: + def __init__(self, entry: StookwijzerConfigEntry) -> None: """Initialize a Stookwijzer device.""" - self._client = client + super().__init__(entry.runtime_data) + self._client = entry.runtime_data.client self._attr_options = ["code_yellow", "code_orange", "code_red"] self._attr_unique_id = entry.entry_id self._attr_device_info = DeviceInfo( @@ -47,15 +42,6 @@ def __init__(self, client: Stookwijzer, entry: ConfigEntry) -> None: configuration_url="https://www.atlasleefomgeving.nl/stookwijzer", ) - async def async_update(self) -> None: - """Update the data from the Stookwijzer handler.""" - await self._client.async_update() - - @property - def available(self) -> bool: - """Return if entity is available.""" - return self._client.advice is not None - @property def native_value(self) -> str | None: """Return the state of the device.""" diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index 253b103456721..975b0dbfba3e4 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -29,5 +29,10 @@ "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integrations uses.\n\nMake sure you are connected to the internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", "title": "Migration of your location failed" } + }, + "exceptions": { + "no_data_received": { + "message": "No data received from Stookwijzer." + } } } diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 974b6ef564376..863becd720fbe 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -61,6 +61,10 @@ def mock_stookwijzer() -> Generator[MagicMock]: "homeassistant.components.stookwijzer.Stookwijzer", autospec=True, ) as stookwijzer_mock, + patch( + "homeassistant.components.stookwijzer.coordinator.Stookwijzer", + new=stookwijzer_mock, + ), patch( "homeassistant.components.stookwijzer.config_flow.Stookwijzer", new=stookwijzer_mock, From b60f981c3ebfcbee135daf809322192bb7b909b2 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Mon, 25 Nov 2024 16:44:37 -0500 Subject: [PATCH 0834/1070] Update Fully Kiosk quality scale progress (#131411) --- .../components/fully_kiosk/quality_scale.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fully_kiosk/quality_scale.yaml b/homeassistant/components/fully_kiosk/quality_scale.yaml index 615e4dfe79ae6..68fa7b9c3f9e1 100644 --- a/homeassistant/components/fully_kiosk/quality_scale.yaml +++ b/homeassistant/components/fully_kiosk/quality_scale.yaml @@ -21,15 +21,17 @@ rules: # Silver config-entry-unloading: done - log-when-unavailable: todo + log-when-unavailable: done entity-unavailable: done action-exceptions: todo reauthentication-flow: todo parallel-updates: todo - test-coverage: todo + test-coverage: done integration-owner: done - docs-installation-parameters: todo - docs-configuration-parameters: todo + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: This integration does not utilize an options flow. # Gold entity-translations: todo @@ -43,7 +45,7 @@ rules: comment: Each config entry maps to a single device diagnostics: done exception-translations: todo - icon-translations: todo + icon-translations: done reconfiguration-flow: todo dynamic-devices: status: exempt From 4e22da2a75091d9e28628477230ff3e9a0cf860b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 25 Nov 2024 23:05:26 +0100 Subject: [PATCH 0835/1070] Update climate strings for consistent names and descriptions (#130967) --- homeassistant/components/climate/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 26a06821d84e7..f607419195e18 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -161,19 +161,19 @@ }, "set_temperature": { "name": "Set target temperature", - "description": "Sets target temperature.", + "description": "Sets the temperature setpoint.", "fields": { "temperature": { - "name": "Temperature", - "description": "Target temperature." + "name": "Target temperature", + "description": "The temperature setpoint." }, "target_temp_high": { - "name": "Target temperature high", - "description": "High target temperature." + "name": "Upper target temperature", + "description": "The max temperature setpoint." }, "target_temp_low": { - "name": "Target temperature low", - "description": "Low target temperature." + "name": "Lower target temperature", + "description": "The min temperature setpoint." }, "hvac_mode": { "name": "HVAC mode", From 54d530c410185a8a40fad21bbe3506f10758b5d5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 25 Nov 2024 23:59:33 +0100 Subject: [PATCH 0836/1070] Update types packages (#131573) --- requirements_test.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 5b6af4b9a6223..fa15208ead345 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -36,15 +36,15 @@ syrupy==4.7.2 tqdm==4.66.5 types-aiofiles==24.1.0.20240626 types-atomicwrites==1.4.5.1 -types-croniter==2.0.0.20240423 -types-beautifulsoup4==4.12.0.20240907 -types-caldav==1.3.0.20240824 +types-croniter==4.0.0.20241030 +types-beautifulsoup4==4.12.0.20241020 +types-caldav==1.3.0.20241107 types-chardet==0.1.5 types-decorator==5.1.8.20240310 types-paho-mqtt==1.6.0.20240321 types-pillow==10.2.0.20240822 -types-protobuf==5.28.0.20240924 -types-psutil==6.0.0.20240901 +types-protobuf==5.28.3.20241030 +types-psutil==6.1.0.20241102 types-python-dateutil==2.9.0.20241003 types-python-slugify==8.0.2.20240310 types-pytz==2024.2.0.20241003 From 327aa8a51a2514413df0d24ea932bcd3ad7c9978 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 26 Nov 2024 00:09:31 +0100 Subject: [PATCH 0837/1070] Add entity descriptions to Stookwijzer (#131585) --- .../components/stookwijzer/__init__.py | 21 +++++++- .../components/stookwijzer/sensor.py | 49 +++++++++++++++---- .../stookwijzer/snapshots/test_sensor.ambr | 2 +- tests/components/stookwijzer/test_init.py | 46 ++++++++++++++++- 4 files changed, 105 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index ee525c5323a36..d8b9561bde900 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -2,11 +2,13 @@ from __future__ import annotations +from typing import Any + from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER @@ -17,6 +19,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: StookwijzerConfigEntry) -> bool: """Set up Stookwijzer from a config entry.""" + await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry) + coordinator = StookwijzerCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() @@ -71,3 +75,16 @@ async def async_migrate_entry( LOGGER.debug("Migration to version %s successful", entry.version) return True + + +@callback +def async_migrate_entity_entry(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: + """Migrate Stookwijzer entity entries. + + - Migrates unique ID for the old Stookwijzer sensors to the new unique ID. + """ + if entity_entry.unique_id == entity_entry.config_entry_id: + return {"new_unique_id": f"{entity_entry.config_entry_id}_advice"} + + # No migration needed + return None diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index 25396639ecda3..1027f8c0e98c4 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -2,7 +2,16 @@ from __future__ import annotations -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from collections.abc import Callable +from dataclasses import dataclass + +from stookwijzer import Stookwijzer + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -12,29 +21,51 @@ from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator +@dataclass(kw_only=True, frozen=True) +class StookwijzerSensorDescription(SensorEntityDescription): + """Class describing Stookwijzer sensor entities.""" + + value_fn: Callable[[Stookwijzer], str | None] + + +STOOKWIJZER_SENSORS = [ + StookwijzerSensorDescription( + key="advice", + translation_key="advice", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda client: client.advice, + options=["code_yellow", "code_orange", "code_red"], + ), +] + + async def async_setup_entry( hass: HomeAssistant, entry: StookwijzerConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Stookwijzer sensor from a config entry.""" - async_add_entities([StookwijzerSensor(entry)]) + async_add_entities( + StookwijzerSensor(description, entry) for description in STOOKWIJZER_SENSORS + ) class StookwijzerSensor(CoordinatorEntity[StookwijzerCoordinator], SensorEntity): """Defines a Stookwijzer binary sensor.""" + entity_description: StookwijzerSensorDescription _attr_attribution = "Data provided by atlasleefomgeving.nl" - _attr_device_class = SensorDeviceClass.ENUM _attr_has_entity_name = True - _attr_translation_key = "advice" - def __init__(self, entry: StookwijzerConfigEntry) -> None: + def __init__( + self, + description: StookwijzerSensorDescription, + entry: StookwijzerConfigEntry, + ) -> None: """Initialize a Stookwijzer device.""" super().__init__(entry.runtime_data) - self._client = entry.runtime_data.client - self._attr_options = ["code_yellow", "code_orange", "code_red"] - self._attr_unique_id = entry.entry_id + self.entity_description = description + self._attr_unique_id = f"{entry.entry_id}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Atlas Leefomgeving", @@ -45,4 +76,4 @@ def __init__(self, entry: StookwijzerConfigEntry) -> None: @property def native_value(self) -> str | None: """Return the state of the device.""" - return self._client.advice + return self.entity_description.value_fn(self.coordinator.client) diff --git a/tests/components/stookwijzer/snapshots/test_sensor.ambr b/tests/components/stookwijzer/snapshots/test_sensor.ambr index bd7fcc6fef570..195f34982252d 100644 --- a/tests/components/stookwijzer/snapshots/test_sensor.ambr +++ b/tests/components/stookwijzer/snapshots/test_sensor.ambr @@ -34,7 +34,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'advice', - 'unique_id': '12345', + 'unique_id': '12345_advice', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/stookwijzer/test_init.py b/tests/components/stookwijzer/test_init.py index 1a84954c21aed..0774def781391 100644 --- a/tests/components/stookwijzer/test_init.py +++ b/tests/components/stookwijzer/test_init.py @@ -2,11 +2,14 @@ from unittest.mock import MagicMock +import pytest + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.stookwijzer.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir from tests.common import MockConfigEntry @@ -53,3 +56,44 @@ async def test_entry_migration_failure( assert issue_registry.async_get_issue(DOMAIN, "location_migration_failed") assert len(mock_stookwijzer.async_transform_coordinates.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_stookwijzer") +async def test_entity_entry_migration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test successful migration of entry data.""" + entity = entity_registry.async_get_or_create( + suggested_object_id="advice", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=mock_config_entry.entry_id, + config_entry=mock_config_entry, + ) + + assert entity.unique_id == mock_config_entry.entry_id + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, + DOMAIN, + mock_config_entry.entry_id, + ) + is None + ) + + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, + DOMAIN, + f"{mock_config_entry.entry_id}_advice", + ) + == "sensor.advice" + ) From 442a270473581becb45566bda2f0c35306544dca Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 26 Nov 2024 00:18:36 +0100 Subject: [PATCH 0838/1070] Bump reolink-aio to 0.11.3 (#131568) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 0e2c918acc93c..4846ec8cb9454 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.11.2"] + "requirements": ["reolink-aio==0.11.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 90aa3077ae834..bac9805adb6e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2556,7 +2556,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.2 +reolink-aio==0.11.3 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ffd9d02f39c4..84175d4990307 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2047,7 +2047,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.2 +reolink-aio==0.11.3 # homeassistant.components.rflink rflink==0.0.66 From af29bfceb0b147c539db0f962aa3412475b4a43e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 26 Nov 2024 00:20:35 +0100 Subject: [PATCH 0839/1070] Add new sensors to Stookwijzer (#131587) --- .../components/stookwijzer/diagnostics.py | 2 + .../components/stookwijzer/sensor.py | 21 +++- tests/components/stookwijzer/conftest.py | 3 + .../snapshots/test_diagnostics.ambr | 2 + .../stookwijzer/snapshots/test_sensor.ambr | 109 ++++++++++++++++++ 5 files changed, 135 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py index c59a2e6175214..2849e0e976af3 100644 --- a/homeassistant/components/stookwijzer/diagnostics.py +++ b/homeassistant/components/stookwijzer/diagnostics.py @@ -16,4 +16,6 @@ async def async_get_config_entry_diagnostics( client = entry.runtime_data.client return { "advice": client.advice, + "air_quality_index": client.lki, + "windspeed_ms": client.windspeed_ms, } diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index 1027f8c0e98c4..2660ff2ddb268 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -11,7 +11,9 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) +from homeassistant.const import UnitOfSpeed from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,10 +27,25 @@ class StookwijzerSensorDescription(SensorEntityDescription): """Class describing Stookwijzer sensor entities.""" - value_fn: Callable[[Stookwijzer], str | None] + value_fn: Callable[[Stookwijzer], int | float | str | None] STOOKWIJZER_SENSORS = [ + StookwijzerSensorDescription( + key="windspeed", + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + suggested_unit_of_measurement=UnitOfSpeed.BEAUFORT, + device_class=SensorDeviceClass.WIND_SPEED, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda client: client.windspeed_ms, + ), + StookwijzerSensorDescription( + key="air_quality_index", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda client: client.lki, + ), StookwijzerSensorDescription( key="advice", translation_key="advice", @@ -74,6 +91,6 @@ def __init__( ) @property - def native_value(self) -> str | None: + def native_value(self) -> int | float | str | None: """Return the state of the device.""" return self.entity_description.value_fn(self.coordinator.client) diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 863becd720fbe..3f7303e97f666 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -76,6 +76,9 @@ def mock_stookwijzer() -> Generator[MagicMock]: ) client = stookwijzer_mock.return_value + client.lki = 2 + client.windspeed_ms = 2.5 + client.windspeed_bft = 2 client.advice = "code_yellow" yield stookwijzer_mock diff --git a/tests/components/stookwijzer/snapshots/test_diagnostics.ambr b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr index f2b7815f753f5..e2535d54466f1 100644 --- a/tests/components/stookwijzer/snapshots/test_diagnostics.ambr +++ b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr @@ -2,5 +2,7 @@ # name: test_get_diagnostics dict({ 'advice': 'code_yellow', + 'air_quality_index': 2, + 'windspeed_ms': 2.5, }) # --- diff --git a/tests/components/stookwijzer/snapshots/test_sensor.ambr b/tests/components/stookwijzer/snapshots/test_sensor.ambr index 195f34982252d..f6751a84f22f5 100644 --- a/tests/components/stookwijzer/snapshots/test_sensor.ambr +++ b/tests/components/stookwijzer/snapshots/test_sensor.ambr @@ -58,3 +58,112 @@ 'state': 'code_yellow', }) # --- +# name: test_entities[sensor.stookwijzer_air_quality_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stookwijzer_air_quality_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air quality index', + 'platform': 'stookwijzer', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345_air_quality_index', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.stookwijzer_air_quality_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by atlasleefomgeving.nl', + 'device_class': 'aqi', + 'friendly_name': 'Stookwijzer Air quality index', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.stookwijzer_air_quality_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_entities[sensor.stookwijzer_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stookwijzer_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'stookwijzer', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345_windspeed', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.stookwijzer_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by atlasleefomgeving.nl', + 'device_class': 'wind_speed', + 'friendly_name': 'Stookwijzer Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.stookwijzer_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- From 5868a4fa210968eb797ff581e757f501b7e7f97f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 26 Nov 2024 00:45:25 +0100 Subject: [PATCH 0840/1070] Add data description for Stookwijzer config flow (#131591) --- homeassistant/components/stookwijzer/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index 975b0dbfba3e4..189af89b282a8 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -5,6 +5,9 @@ "description": "Select the location you want to recieve the Stookwijzer information for.", "data": { "location": "[%key:common::config_flow::data::location%]" + }, + "data_description": { + "location": "Use the map to set the location for Stookwijzer." } } }, From 8e9b5eb4e19810e76b9050500c94fed7539e54fb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 26 Nov 2024 00:45:47 +0100 Subject: [PATCH 0841/1070] Extend tests for Stookwijzer init (#131589) --- tests/components/stookwijzer/test_init.py | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/components/stookwijzer/test_init.py b/tests/components/stookwijzer/test_init.py index 0774def781391..0df9b55d1a99f 100644 --- a/tests/components/stookwijzer/test_init.py +++ b/tests/components/stookwijzer/test_init.py @@ -14,6 +14,41 @@ from tests.common import MockConfigEntry +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_stookwijzer: MagicMock, +) -> None: + """Test the Stookwijzer configuration entry loading and unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_stookwijzer.return_value.async_update.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_stookwijzer: MagicMock, +) -> None: + """Test the Stookwijzer configuration entry loading and unloading.""" + mock_stookwijzer.return_value.advice = None + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert len(mock_stookwijzer.return_value.async_update.mock_calls) == 1 + + async def test_migrate_entry( hass: HomeAssistant, mock_v1_config_entry: MockConfigEntry, From ec8fe3db4eef754c70d8ff59d38ae7f2d4d74147 Mon Sep 17 00:00:00 2001 From: cedeherd <32846418+cedeherd@users.noreply.github.com> Date: Tue, 26 Nov 2024 07:08:55 +0100 Subject: [PATCH 0842/1070] Bump nibe to 2.13.0 (#131572) --- homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index b3e5597da7382..407cdfcfd57ee 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.11.0"] + "requirements": ["nibe==2.13.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index bac9805adb6e0..bbf8a11febd9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1457,7 +1457,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.nibe_heatpump -nibe==2.11.0 +nibe==2.13.0 # homeassistant.components.nice_go nice-go==0.3.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 84175d4990307..49c7a8960a5b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1217,7 +1217,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.nibe_heatpump -nibe==2.11.0 +nibe==2.13.0 # homeassistant.components.nice_go nice-go==0.3.10 From 44f90dca0c4b8f8ba75885dbdb9e968ba5549f3e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 26 Nov 2024 07:47:47 +0100 Subject: [PATCH 0843/1070] Fix logic for purge of recorder runs (#130378) * Fix logic for purge of recorder runs * Make test more explicit * Explicitly don't remove unclosed recorder runs in purge --- homeassistant/components/recorder/queries.py | 3 ++- tests/components/recorder/test_purge.py | 6 +++++- tests/components/recorder/test_purge_v32_schema.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 4acf43a491ef6..2e4b588a0b095 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -608,7 +608,8 @@ def delete_recorder_runs_rows( """Delete recorder_runs rows.""" return lambda_stmt( lambda: delete(RecorderRuns) - .filter(RecorderRuns.start < purge_before) + .filter(RecorderRuns.end.is_not(None)) + .filter(RecorderRuns.end < purge_before) .filter(RecorderRuns.run_id != current_run_id) .execution_options(synchronize_session=False) ) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index e0b3f7ca8a862..ca160e5201b02 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -352,6 +352,8 @@ async def test_purge_old_recorder_runs( with session_scope(hass=hass) as session: recorder_runs = session.query(RecorderRuns) assert recorder_runs.count() == 7 + # Make sure we have a run that is not closed + assert sum(run.end is None for run in recorder_runs) == 1 purge_before = dt_util.utcnow() @@ -376,7 +378,9 @@ async def test_purge_old_recorder_runs( with session_scope(hass=hass) as session: recorder_runs = session.query(RecorderRuns) - assert recorder_runs.count() == 1 + assert recorder_runs.count() == 3 + # Make sure we did not purge the unclosed run + assert sum(run.end is None for run in recorder_runs) == 1 async def test_purge_old_statistics_runs( diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 0754b2e911c4f..468fd38c85591 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -347,7 +347,7 @@ async def test_purge_old_recorder_runs( with session_scope(hass=hass) as session: recorder_runs = session.query(RecorderRuns) - assert recorder_runs.count() == 1 + assert recorder_runs.count() == 3 async def test_purge_old_statistics_runs( From 9a4613536741ac095412c25860c7e1c23395f20b Mon Sep 17 00:00:00 2001 From: Dave Leaver Date: Tue, 26 Nov 2024 20:26:53 +1300 Subject: [PATCH 0844/1070] Bump airtouch5py to 0.2.11 (#131436) --- homeassistant/components/airtouch5/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airtouch5/manifest.json b/homeassistant/components/airtouch5/manifest.json index 312a627d0e881..58ef8668ebe10 100644 --- a/homeassistant/components/airtouch5/manifest.json +++ b/homeassistant/components/airtouch5/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airtouch5", "iot_class": "local_push", "loggers": ["airtouch5py"], - "requirements": ["airtouch5py==0.2.10"] + "requirements": ["airtouch5py==0.2.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index bbf8a11febd9a..deb8a17b99c19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -441,7 +441,7 @@ airthings-cloud==0.2.0 airtouch4pyapi==1.0.5 # homeassistant.components.airtouch5 -airtouch5py==0.2.10 +airtouch5py==0.2.11 # homeassistant.components.alpha_vantage alpha-vantage==2.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49c7a8960a5b3..929ed282fe2bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -423,7 +423,7 @@ airthings-cloud==0.2.0 airtouch4pyapi==1.0.5 # homeassistant.components.airtouch5 -airtouch5py==0.2.10 +airtouch5py==0.2.11 # homeassistant.components.amberelectric amberelectric==2.0.12 From 4e9f03a5ca5ddd8fbdc476807a772cb6552ea890 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 26 Nov 2024 07:29:15 +0000 Subject: [PATCH 0845/1070] Add unit of measurement to translations for Mealie (#131345) --- homeassistant/components/mealie/sensor.py | 5 ----- homeassistant/components/mealie/strings.json | 15 ++++++++++----- .../components/mealie/snapshots/test_sensor.ambr | 15 +++++---------- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/mealie/sensor.py b/homeassistant/components/mealie/sensor.py index b4baac34ebef4..141a28ecdab83 100644 --- a/homeassistant/components/mealie/sensor.py +++ b/homeassistant/components/mealie/sensor.py @@ -28,31 +28,26 @@ class MealieStatisticsSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[MealieStatisticsSensorEntityDescription, ...] = ( MealieStatisticsSensorEntityDescription( key="recipes", - native_unit_of_measurement="recipes", state_class=SensorStateClass.TOTAL, value_fn=lambda statistics: statistics.total_recipes, ), MealieStatisticsSensorEntityDescription( key="users", - native_unit_of_measurement="users", state_class=SensorStateClass.TOTAL, value_fn=lambda statistics: statistics.total_users, ), MealieStatisticsSensorEntityDescription( key="categories", - native_unit_of_measurement="categories", state_class=SensorStateClass.TOTAL, value_fn=lambda statistics: statistics.total_categories, ), MealieStatisticsSensorEntityDescription( key="tags", - native_unit_of_measurement="tags", state_class=SensorStateClass.TOTAL, value_fn=lambda statistics: statistics.total_tags, ), MealieStatisticsSensorEntityDescription( key="tools", - native_unit_of_measurement="tools", state_class=SensorStateClass.TOTAL, value_fn=lambda statistics: statistics.total_tools, ), diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index b59399815ea03..5555d3ffa21f6 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -56,19 +56,24 @@ }, "sensor": { "recipes": { - "name": "Recipes" + "name": "Recipes", + "unit_of_measurement": "recipes" }, "users": { - "name": "Users" + "name": "Users", + "unit_of_measurement": "users" }, "categories": { - "name": "Categories" + "name": "Categories", + "unit_of_measurement": "categories" }, "tags": { - "name": "Tags" + "name": "Tags", + "unit_of_measurement": "tags" }, "tools": { - "name": "Tools" + "name": "Tools", + "unit_of_measurement": "tools" } } }, diff --git a/tests/components/mealie/snapshots/test_sensor.ambr b/tests/components/mealie/snapshots/test_sensor.ambr index e645cf4c45f3e..d52ffc9a79a4e 100644 --- a/tests/components/mealie/snapshots/test_sensor.ambr +++ b/tests/components/mealie/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'supported_features': 0, 'translation_key': 'categories', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_categories', - 'unit_of_measurement': 'categories', + 'unit_of_measurement': None, }) # --- # name: test_entities[sensor.mealie_categories-state] @@ -39,7 +39,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Categories', 'state_class': , - 'unit_of_measurement': 'categories', }), 'context': , 'entity_id': 'sensor.mealie_categories', @@ -81,7 +80,7 @@ 'supported_features': 0, 'translation_key': 'recipes', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_recipes', - 'unit_of_measurement': 'recipes', + 'unit_of_measurement': None, }) # --- # name: test_entities[sensor.mealie_recipes-state] @@ -89,7 +88,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Recipes', 'state_class': , - 'unit_of_measurement': 'recipes', }), 'context': , 'entity_id': 'sensor.mealie_recipes', @@ -131,7 +129,7 @@ 'supported_features': 0, 'translation_key': 'tags', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_tags', - 'unit_of_measurement': 'tags', + 'unit_of_measurement': None, }) # --- # name: test_entities[sensor.mealie_tags-state] @@ -139,7 +137,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Tags', 'state_class': , - 'unit_of_measurement': 'tags', }), 'context': , 'entity_id': 'sensor.mealie_tags', @@ -181,7 +178,7 @@ 'supported_features': 0, 'translation_key': 'tools', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_tools', - 'unit_of_measurement': 'tools', + 'unit_of_measurement': None, }) # --- # name: test_entities[sensor.mealie_tools-state] @@ -189,7 +186,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Tools', 'state_class': , - 'unit_of_measurement': 'tools', }), 'context': , 'entity_id': 'sensor.mealie_tools', @@ -231,7 +227,7 @@ 'supported_features': 0, 'translation_key': 'users', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_users', - 'unit_of_measurement': 'users', + 'unit_of_measurement': None, }) # --- # name: test_entities[sensor.mealie_users-state] @@ -239,7 +235,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Users', 'state_class': , - 'unit_of_measurement': 'users', }), 'context': , 'entity_id': 'sensor.mealie_users', From 4702d8ddb0c7c0487d958a3a3f96230706d8e350 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 26 Nov 2024 08:48:42 +0100 Subject: [PATCH 0846/1070] Enable strict typing for Stookwijzer (#131590) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index 1196f199c78b1..cb0cab984eee5 100644 --- a/.strict-typing +++ b/.strict-typing @@ -438,6 +438,7 @@ homeassistant.components.starlink.* homeassistant.components.statistics.* homeassistant.components.steamist.* homeassistant.components.stookalert.* +homeassistant.components.stookwijzer.* homeassistant.components.stream.* homeassistant.components.streamlabswater.* homeassistant.components.stt.* diff --git a/mypy.ini b/mypy.ini index 04c07d82afaf7..a71f980dac9ee 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4137,6 +4137,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.stookwijzer.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.stream.*] check_untyped_defs = true disallow_incomplete_defs = true From b1a540a772c83ea3d434e3ff578dc59330eb11f4 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Tue, 26 Nov 2024 02:56:11 -0500 Subject: [PATCH 0847/1070] Add action exceptions to Cambridge Audio (#131597) --- homeassistant/components/cambridge_audio/select.py | 3 ++- homeassistant/components/cambridge_audio/switch.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cambridge_audio/select.py b/homeassistant/components/cambridge_audio/select.py index c99abc853e552..5708425cfb04d 100644 --- a/homeassistant/components/cambridge_audio/select.py +++ b/homeassistant/components/cambridge_audio/select.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import CambridgeAudioEntity +from .entity import CambridgeAudioEntity, command @dataclass(frozen=True, kw_only=True) @@ -116,6 +116,7 @@ def current_option(self) -> str | None: """Return the state of the select.""" return self.entity_description.value_fn(self.client) + @command async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.set_value_fn(self.client, option) diff --git a/homeassistant/components/cambridge_audio/switch.py b/homeassistant/components/cambridge_audio/switch.py index 3209b275d4689..19a07682c5b1c 100644 --- a/homeassistant/components/cambridge_audio/switch.py +++ b/homeassistant/components/cambridge_audio/switch.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import CambridgeAudioEntity +from .entity import CambridgeAudioEntity, command @dataclass(frozen=True, kw_only=True) @@ -73,10 +73,12 @@ def is_on(self) -> bool: """Return the state of the switch.""" return self.entity_description.value_fn(self.client) + @command async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.entity_description.set_value_fn(self.client, True) + @command async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.entity_description.set_value_fn(self.client, False) From 2217fc45073eca3a2b078359e8fbf8b879e9f9c3 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Tue, 26 Nov 2024 02:58:10 -0500 Subject: [PATCH 0848/1070] Add parallel updates to Cambridge Audio (#131596) --- homeassistant/components/cambridge_audio/media_player.py | 2 ++ homeassistant/components/cambridge_audio/select.py | 2 ++ homeassistant/components/cambridge_audio/switch.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index 5e340cdd21ecc..805cf8ec7f672 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -57,6 +57,8 @@ TransportControl.STOP: MediaPlayerEntityFeature.STOP, } +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/cambridge_audio/select.py b/homeassistant/components/cambridge_audio/select.py index 5708425cfb04d..b1bc0f9e4df95 100644 --- a/homeassistant/components/cambridge_audio/select.py +++ b/homeassistant/components/cambridge_audio/select.py @@ -14,6 +14,8 @@ from .entity import CambridgeAudioEntity, command +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class CambridgeAudioSelectEntityDescription(SelectEntityDescription): diff --git a/homeassistant/components/cambridge_audio/switch.py b/homeassistant/components/cambridge_audio/switch.py index 19a07682c5b1c..72aa0d3cbeae1 100644 --- a/homeassistant/components/cambridge_audio/switch.py +++ b/homeassistant/components/cambridge_audio/switch.py @@ -14,6 +14,8 @@ from .entity import CambridgeAudioEntity, command +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class CambridgeAudioSwitchEntityDescription(SwitchEntityDescription): From 687a3149b9fb5ea964a953ffdb9ac9ec04a29578 Mon Sep 17 00:00:00 2001 From: Max R Date: Tue, 26 Nov 2024 02:59:04 -0500 Subject: [PATCH 0849/1070] Update instructions for setting up ecowitt (#131502) --- homeassistant/components/ecowitt/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ecowitt/strings.json b/homeassistant/components/ecowitt/strings.json index 95fcc3c3bb03c..aaacb5e03dd53 100644 --- a/homeassistant/components/ecowitt/strings.json +++ b/homeassistant/components/ecowitt/strings.json @@ -6,7 +6,7 @@ } }, "create_entry": { - "default": "To finish setting up the integration, use the Ecowitt App (on your phone) or access the Ecowitt WebUI in a browser at the station IP address.\n\nPick your station -> Menu Others -> DIY Upload Servers. Hit next and select 'Customized'\n\n- Server IP: `{server}`\n- Path: `{path}`\n- Port: `{port}`\n\nSelect **Save**." + "default": "To finish setting up the integration, you need to tell the Ecowitt station to send data to Home Assistant at the following address:\n\n- Server IP / Host Name: `{server}`\n- Path: `{path}`\n- Port: `{port}`\n\nYou can access the Ecowitt configuration in one of two ways:\n\n1. Use the Ecowitt App (on your phone):\n - Select the Menu Icon (☰) on the upper left, then **My Devices** → **Pick your station**\n - Select the Ellipsis Icon (⋯) → **Others**\n - Select **DIY Upload Servers** → **Customized**\n - Make sure to choose 'Protocol Type Same As: Ecowitt'\n - Enter the Server IP / Host Name, Path, and Port (printed above). _Note: The path has to match! Remove the first forward slash from the path, as the app will prepend one._\n - Save\n1. Navigate to the Ecowitt web UI in a browser at the station IP address:\n - Select **Weather Services** then scroll down to 'Customized'\n - Make sure to select 'Customized: 🔘 Enable' and 'Protocol Type Same As: 🔘 Ecowitt'\n - Enter the Server IP / Host Name, Path, and Port (printed above).\n - Save" } } } From 5d5ab82ba0baad3a719058fd1dfdf4a6d7ed253a Mon Sep 17 00:00:00 2001 From: David Knowles Date: Tue, 26 Nov 2024 03:00:42 -0500 Subject: [PATCH 0850/1070] Bump pyschlage to 2024.11.0 (#131593) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 5619cf7b3126a..61cc2a3c63d27 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2024.8.0"] + "requirements": ["pyschlage==2024.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index deb8a17b99c19..638ca160c44e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2221,7 +2221,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2024.8.0 +pyschlage==2024.11.0 # homeassistant.components.sensibo pysensibo==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 929ed282fe2bf..bc29da4aba6e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1790,7 +1790,7 @@ pyrympro==0.0.8 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2024.8.0 +pyschlage==2024.11.0 # homeassistant.components.sensibo pysensibo==1.1.0 From db198d4da2c33f9dee1bfee6073d625e9733aefc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 26 Nov 2024 09:00:54 +0100 Subject: [PATCH 0851/1070] Ignore flaky cloud translations (#131600) --- tests/components/conftest.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 08bd16d1f7b8e..42944a480227b 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -777,6 +777,9 @@ def _issue_registry_async_create_issue( f"Unused ignore translations: {', '.join(unused_ignore)}. " "Please remove them from the ignore_translations fixture." ) - for description in translation_errors.values(): + for key, description in translation_errors.items(): + if key.startswith("component.cloud.issues."): + # cloud tests are flaky + continue if description not in {"used", "unused"}: pytest.fail(description) From 17466684a6b62def593bfd79b36edfcad29a8f24 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Tue, 26 Nov 2024 09:01:13 +0100 Subject: [PATCH 0852/1070] Add timesync and restart functionality to linkplay (#130167) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/linkplay/button.py | 82 +++++++++++++++++++ homeassistant/components/linkplay/const.py | 2 +- homeassistant/components/linkplay/entity.py | 57 +++++++++++++ homeassistant/components/linkplay/icons.json | 7 ++ .../components/linkplay/media_player.py | 44 +--------- .../components/linkplay/strings.json | 7 ++ 6 files changed, 158 insertions(+), 41 deletions(-) create mode 100644 homeassistant/components/linkplay/button.py create mode 100644 homeassistant/components/linkplay/entity.py diff --git a/homeassistant/components/linkplay/button.py b/homeassistant/components/linkplay/button.py new file mode 100644 index 0000000000000..1c93ebcdc3ec8 --- /dev/null +++ b/homeassistant/components/linkplay/button.py @@ -0,0 +1,82 @@ +"""Support for LinkPlay buttons.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from linkplay.bridge import LinkPlayBridge + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import LinkPlayConfigEntry +from .entity import LinkPlayBaseEntity, exception_wrap + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class LinkPlayButtonEntityDescription(ButtonEntityDescription): + """Class describing LinkPlay button entities.""" + + remote_function: Callable[[LinkPlayBridge], Coroutine[Any, Any, None]] + + +BUTTON_TYPES: tuple[LinkPlayButtonEntityDescription, ...] = ( + LinkPlayButtonEntityDescription( + key="timesync", + translation_key="timesync", + remote_function=lambda linkplay_bridge: linkplay_bridge.device.timesync(), + entity_category=EntityCategory.CONFIG, + ), + LinkPlayButtonEntityDescription( + key="restart", + device_class=ButtonDeviceClass.RESTART, + remote_function=lambda linkplay_bridge: linkplay_bridge.device.reboot(), + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LinkPlayConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the LinkPlay buttons from config entry.""" + + # add entities + async_add_entities( + LinkPlayButton(config_entry.runtime_data.bridge, description) + for description in BUTTON_TYPES + ) + + +class LinkPlayButton(LinkPlayBaseEntity, ButtonEntity): + """Representation of LinkPlay button.""" + + entity_description: LinkPlayButtonEntityDescription + + def __init__( + self, + bridge: LinkPlayBridge, + description: LinkPlayButtonEntityDescription, + ) -> None: + """Initialize LinkPlay button.""" + super().__init__(bridge) + self.entity_description = description + self._attr_unique_id = f"{bridge.device.uuid}-{description.key}" + + @exception_wrap + async def async_press(self) -> None: + """Press the button.""" + await self.entity_description.remote_function(self._bridge) diff --git a/homeassistant/components/linkplay/const.py b/homeassistant/components/linkplay/const.py index a776365e38f57..e10450cf255e1 100644 --- a/homeassistant/components/linkplay/const.py +++ b/homeassistant/components/linkplay/const.py @@ -8,5 +8,5 @@ DOMAIN = "linkplay" CONTROLLER = "controller" CONTROLLER_KEY: HassKey[LinkPlayController] = HassKey(CONTROLLER) -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] DATA_SESSION = "session" diff --git a/homeassistant/components/linkplay/entity.py b/homeassistant/components/linkplay/entity.py new file mode 100644 index 0000000000000..00e2f39b233f2 --- /dev/null +++ b/homeassistant/components/linkplay/entity.py @@ -0,0 +1,57 @@ +"""BaseEntity to support multiple LinkPlay platforms.""" + +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate + +from linkplay.bridge import LinkPlayBridge + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import Entity + +from . import DOMAIN, LinkPlayRequestException +from .utils import MANUFACTURER_GENERIC, get_info_from_project + + +def exception_wrap[_LinkPlayEntityT: LinkPlayBaseEntity, **_P, _R]( + func: Callable[Concatenate[_LinkPlayEntityT, _P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[_LinkPlayEntityT, _P], Coroutine[Any, Any, _R]]: + """Define a wrapper to catch exceptions and raise HomeAssistant errors.""" + + async def _wrap(self: _LinkPlayEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R: + try: + return await func(self, *args, **kwargs) + except LinkPlayRequestException as err: + raise HomeAssistantError( + f"Exception occurred when communicating with API {func}: {err}" + ) from err + + return _wrap + + +class LinkPlayBaseEntity(Entity): + """Representation of a LinkPlay base entity.""" + + _attr_has_entity_name = True + + def __init__(self, bridge: LinkPlayBridge) -> None: + """Initialize the LinkPlay media player.""" + + self._bridge = bridge + + manufacturer, model = get_info_from_project(bridge.device.properties["project"]) + model_id = None + if model != MANUFACTURER_GENERIC: + model_id = bridge.device.properties["project"] + + self._attr_device_info = dr.DeviceInfo( + configuration_url=bridge.endpoint, + connections={(dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"])}, + hw_version=bridge.device.properties["hardware"], + identifiers={(DOMAIN, bridge.device.uuid)}, + manufacturer=manufacturer, + model=model, + model_id=model_id, + name=bridge.device.name, + sw_version=bridge.device.properties["firmware"], + ) diff --git a/homeassistant/components/linkplay/icons.json b/homeassistant/components/linkplay/icons.json index ee76344dc3960..c0fe86d9ac73a 100644 --- a/homeassistant/components/linkplay/icons.json +++ b/homeassistant/components/linkplay/icons.json @@ -1,4 +1,11 @@ { + "entity": { + "button": { + "timesync": { + "default": "mdi:clock" + } + } + }, "services": { "play_preset": { "service": "mdi:play-box-outline" diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index c29c29785228f..456fbf2328921 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -2,9 +2,8 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine import logging -from typing import Any, Concatenate +from typing import Any from linkplay.bridge import LinkPlayBridge from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus @@ -28,7 +27,6 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_validation as cv, - device_registry as dr, entity_platform, entity_registry as er, ) @@ -37,7 +35,7 @@ from . import LinkPlayConfigEntry, LinkPlayData from .const import CONTROLLER_KEY, DOMAIN -from .utils import MANUFACTURER_GENERIC, get_info_from_project +from .entity import LinkPlayBaseEntity, exception_wrap _LOGGER = logging.getLogger(__name__) STATE_MAP: dict[PlayingStatus, MediaPlayerState] = { @@ -145,58 +143,24 @@ async def async_setup_entry( async_add_entities([LinkPlayMediaPlayerEntity(entry.runtime_data.bridge)]) -def exception_wrap[_LinkPlayEntityT: LinkPlayMediaPlayerEntity, **_P, _R]( - func: Callable[Concatenate[_LinkPlayEntityT, _P], Coroutine[Any, Any, _R]], -) -> Callable[Concatenate[_LinkPlayEntityT, _P], Coroutine[Any, Any, _R]]: - """Define a wrapper to catch exceptions and raise HomeAssistant errors.""" - - async def _wrap(self: _LinkPlayEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R: - try: - return await func(self, *args, **kwargs) - except LinkPlayRequestException as err: - raise HomeAssistantError( - f"Exception occurred when communicating with API {func}: {err}" - ) from err - - return _wrap - - -class LinkPlayMediaPlayerEntity(MediaPlayerEntity): +class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): """Representation of a LinkPlay media player.""" _attr_sound_mode_list = list(EQUALIZER_MAP.values()) _attr_device_class = MediaPlayerDeviceClass.RECEIVER _attr_media_content_type = MediaType.MUSIC - _attr_has_entity_name = True _attr_name = None def __init__(self, bridge: LinkPlayBridge) -> None: """Initialize the LinkPlay media player.""" - self._bridge = bridge + super().__init__(bridge) self._attr_unique_id = bridge.device.uuid self._attr_source_list = [ SOURCE_MAP[playing_mode] for playing_mode in bridge.device.playmode_support ] - manufacturer, model = get_info_from_project(bridge.device.properties["project"]) - model_id = None - if model != MANUFACTURER_GENERIC: - model_id = bridge.device.properties["project"] - - self._attr_device_info = dr.DeviceInfo( - configuration_url=bridge.endpoint, - connections={(dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"])}, - hw_version=bridge.device.properties["hardware"], - identifiers={(DOMAIN, bridge.device.uuid)}, - manufacturer=manufacturer, - model=model, - model_id=model_id, - name=bridge.device.name, - sw_version=bridge.device.properties["firmware"], - ) - @exception_wrap async def async_update(self) -> None: """Update the state of the media player.""" diff --git a/homeassistant/components/linkplay/strings.json b/homeassistant/components/linkplay/strings.json index f3495b293e02d..31b4649e13135 100644 --- a/homeassistant/components/linkplay/strings.json +++ b/homeassistant/components/linkplay/strings.json @@ -35,6 +35,13 @@ } } }, + "entity": { + "button": { + "timesync": { + "name": "Sync time" + } + } + }, "exceptions": { "invalid_grouping_entity": { "message": "Entity with id {entity_id} can't be added to the LinkPlay multiroom. Is the entity a LinkPlay mediaplayer?" From db07483c4058c7c80b7b789060082d3fb6a65fd7 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 26 Nov 2024 17:05:16 +0900 Subject: [PATCH 0853/1070] Fix twoSet temp and fan_mode error in LG ThinQ integration (#131130) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/climate.py | 34 +++++++------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 9ead57ab7b0c7..5cf9ccbd442f4 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -12,7 +12,6 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - FAN_OFF, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, @@ -37,7 +36,7 @@ class ThinQClimateEntityDescription(ClimateEntityDescription): step: float | None = None -DEVIE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ThinQClimateEntityDescription, ...]] = { +DEVICE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ThinQClimateEntityDescription, ...]] = { DeviceType.AIR_CONDITIONER: ( ThinQClimateEntityDescription( key=ExtendedProperty.CLIMATE_AIR_CONDITIONER, @@ -86,7 +85,7 @@ async def async_setup_entry( entities: list[ThinQClimateEntity] = [] for coordinator in entry.runtime_data.coordinators.values(): if ( - descriptions := DEVIE_TYPE_CLIMATE_MAP.get( + descriptions := DEVICE_TYPE_CLIMATE_MAP.get( coordinator.api.device.device_type ) ) is not None: @@ -149,10 +148,9 @@ def _update_status(self) -> None: super()._update_status() # Update fan, hvac and preset mode. + if self.supported_features & ClimateEntityFeature.FAN_MODE: + self._attr_fan_mode = self.data.fan_mode if self.data.is_on: - if self.supported_features & ClimateEntityFeature.FAN_MODE: - self._attr_fan_mode = self.data.fan_mode - hvac_mode = self._requested_hvac_mode or self.data.hvac_mode if hvac_mode in STR_TO_HVAC: self._attr_hvac_mode = STR_TO_HVAC.get(hvac_mode) @@ -160,9 +158,6 @@ def _update_status(self) -> None: elif hvac_mode in THINQ_PRESET_MODE: self._attr_preset_mode = hvac_mode else: - if self.supported_features & ClimateEntityFeature.FAN_MODE: - self._attr_fan_mode = FAN_OFF - self._attr_hvac_mode = HVACMode.OFF self._attr_preset_mode = None @@ -170,6 +165,7 @@ def _update_status(self) -> None: self._attr_current_humidity = self.data.humidity self._attr_current_temperature = self.data.current_temp + # Update min, max and step. if (max_temp := self.entity_description.max_temp) is not None or ( max_temp := self.data.max ) is not None: @@ -184,26 +180,18 @@ def _update_status(self) -> None: self._attr_target_temperature_step = step # Update target temperatures. - if ( - self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - and self.hvac_mode == HVACMode.AUTO - ): - self._attr_target_temperature = None - self._attr_target_temperature_high = self.data.target_temp_high - self._attr_target_temperature_low = self.data.target_temp_low - else: - self._attr_target_temperature = self.data.target_temp - self._attr_target_temperature_high = None - self._attr_target_temperature_low = None + self._attr_target_temperature = self.data.target_temp + self._attr_target_temperature_high = self.data.target_temp_high + self._attr_target_temperature_low = self.data.target_temp_low _LOGGER.debug( - "[%s:%s] update status: %s/%s -> %s/%s, hvac:%s, unit:%s, step:%s", + "[%s:%s] update status: c:%s, t:%s, l:%s, h:%s, hvac:%s, unit:%s, step:%s", self.coordinator.device_name, self.property_id, - self.data.current_temp, - self.data.target_temp, self.current_temperature, self.target_temperature, + self.target_temperature_low, + self.target_temperature_high, self.hvac_mode, self.temperature_unit, self.target_temperature_step, From 1ddd31673a9cb2e34239ad79650032c15700d9b0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 26 Nov 2024 09:09:56 +0100 Subject: [PATCH 0854/1070] Add reconfigure flow to SABnzbd (#131555) * Add reconfigure flow to SABnzbd * Process code review * Add suggested values --- .../components/sabnzbd/config_flow.py | 38 ++++++++---- homeassistant/components/sabnzbd/strings.json | 3 + tests/components/sabnzbd/test_config_flow.py | 61 +++++++++++++++++++ 3 files changed, 89 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/sabnzbd/config_flow.py b/homeassistant/components/sabnzbd/config_flow.py index 846f7b2b4676c..72b3f92409f5a 100644 --- a/homeassistant/components/sabnzbd/config_flow.py +++ b/homeassistant/components/sabnzbd/config_flow.py @@ -7,7 +7,11 @@ import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.helpers.selector import ( TextSelector, @@ -41,31 +45,39 @@ class SABnzbdConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _async_validate_input(self, user_input): - """Validate the user input allows us to connect.""" - errors = {} - sab_api = await get_client(self.hass, user_input) - if not sab_api: - errors["base"] = "cannot_connect" - - return errors + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration flow.""" + return await self.async_step_user(user_input) async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - errors = {} + if user_input is not None: - errors = await self._async_validate_input(user_input) + sab_api = await get_client(self.hass, user_input) + if not sab_api: + errors["base"] = "cannot_connect" + else: + if self.source == SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data_updates=user_input + ) - if not errors: return self.async_create_entry( title=user_input[CONF_API_KEY][:12], data=user_input ) return self.async_show_form( step_id="user", - data_schema=USER_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + USER_SCHEMA, + self._get_reconfigure_entry().data + if self.source == SOURCE_RECONFIGURE + else user_input, + ), errors=errors, ) diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 742c327ed2357..78a8b88486ed8 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -15,6 +15,9 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "entity": { diff --git a/tests/components/sabnzbd/test_config_flow.py b/tests/components/sabnzbd/test_config_flow.py index 969e379160c53..8bf28e2c0cd42 100644 --- a/tests/components/sabnzbd/test_config_flow.py +++ b/tests/components/sabnzbd/test_config_flow.py @@ -12,6 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + VALID_CONFIG = { CONF_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0", CONF_URL: "http://localhost:8080", @@ -71,3 +73,62 @@ async def test_auth_error(hass: HomeAssistant, sabnzbd: AsyncMock) -> None: CONF_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0", CONF_URL: "http://localhost:8080", } + + +async def test_reconfigure_successful( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfiguring a SABnzbd entry.""" + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://10.10.10.10:8080", CONF_API_KEY: "new_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == { + CONF_URL: "http://10.10.10.10:8080", + CONF_API_KEY: "new_key", + } + + +async def test_reconfigure_error( + hass: HomeAssistant, config_entry: MockConfigEntry, sabnzbd: AsyncMock +) -> None: + """Test reconfiguring a SABnzbd entry.""" + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # set side effect and check if error is handled + sabnzbd.check_available.side_effect = SabnzbdApiException("Some error") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://10.10.10.10:8080", CONF_API_KEY: "new_key"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # reset side effect and check if we can recover + sabnzbd.check_available.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://10.10.10.10:8080", CONF_API_KEY: "new_key"}, + ) + + assert "errors" not in result + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == { + CONF_URL: "http://10.10.10.10:8080", + CONF_API_KEY: "new_key", + } From 875623f889cea3889b7de774aedb01e5f38ca7dc Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Tue, 26 Nov 2024 09:22:44 +0100 Subject: [PATCH 0855/1070] Add translation for exceptions in coordinator for fyta (#131521) --- homeassistant/components/fyta/coordinator.py | 13 ++++++++++--- homeassistant/components/fyta/strings.json | 11 +++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index c4aa9bfe58915..553960bdcc69a 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -61,7 +61,9 @@ async def _async_update_data( try: data = await self.fyta.update_all_plants() except (FytaConnectionError, FytaPlantError) as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_error" + ) from err _LOGGER.debug("Data successfully updated") # data must be assigned before _async_add_remove_devices, as it is uses to set-up possible new devices @@ -122,9 +124,14 @@ async def renew_authentication(self) -> bool: try: credentials = await self.fyta.login() except FytaConnectionError as ex: - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="config_entry_not_ready" + ) from ex except (FytaAuthentificationError, FytaPasswordError) as ex: - raise ConfigEntryAuthFailed from ex + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from ex new_config_entry = {**self.config_entry.data} new_config_entry[CONF_ACCESS_TOKEN] = credentials.access_token diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index 3fb01ba042231..5adde02c0cbc0 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -101,5 +101,16 @@ "name": "Salinity" } } + }, + "exceptions": { + "update_error": { + "message": "Error while updating data from the API." + }, + "config_entry_not_ready": { + "message": "Error while loading the config entry." + }, + "auth_failed": { + "message": "Error while logging in to the API." + } } } From ad19c5f9c1e7480abfe9e20080aca38c12021b74 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Tue, 26 Nov 2024 09:23:08 +0100 Subject: [PATCH 0856/1070] Remove Bang & Olufsen static icon (#131528) --- .../components/bang_olufsen/media_player.py | 1 - .../snapshots/test_media_player.ambr | 18 ------------------ 2 files changed, 19 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 56aa66d32e80f..3d75269e0fc51 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -180,7 +180,6 @@ async def async_setup_entry( class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): """Representation of a media player.""" - _attr_icon = "mdi:speaker-wireless" _attr_name = None _attr_device_class = MediaPlayerDeviceClass.SPEAKER diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr index ea96e28682146..36fcc72aa2227 100644 --- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -23,7 +23,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'repeat': , 'shuffle': False, @@ -72,7 +71,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'repeat': , 'shuffle': False, @@ -122,7 +120,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'repeat': , 'shuffle': False, @@ -172,7 +169,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'repeat': , 'shuffle': False, @@ -222,7 +218,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'repeat': , 'shuffle': False, @@ -272,7 +267,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'repeat': , 'shuffle': False, @@ -321,7 +315,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'repeat': , 'shuffle': False, @@ -370,7 +363,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'repeat': , 'shuffle': False, @@ -420,7 +412,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ @@ -467,7 +458,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'repeat': , 'shuffle': False, @@ -517,7 +507,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ @@ -564,7 +553,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'media_position': 0, 'sound_mode': 'Test Listening Mode (123)', @@ -613,7 +601,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ @@ -660,7 +647,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ @@ -708,7 +694,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ @@ -755,7 +740,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'repeat': , 'shuffle': False, @@ -802,7 +786,6 @@ 'media_player.beosound_balance_22222222', 'media_player.beosound_balance_11111111', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ @@ -849,7 +832,6 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'icon': 'mdi:speaker-wireless', 'media_content_type': , 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ From f5b2f9dcbfc4396be612ce16b859f6c8a506e776 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Tue, 26 Nov 2024 09:23:23 +0100 Subject: [PATCH 0857/1070] Add parallel updates setting to Bang & Olufsen (#131526) --- homeassistant/components/bang_olufsen/media_player.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 3d75269e0fc51..96e7cca017512 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -86,6 +86,8 @@ from .entity import BangOlufsenEntity from .util import get_serial_number_from_jid +PARALLEL_UPDATES = 0 + SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) From e7030f57049f06de29136c10f3844d5b4dd69541 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 26 Nov 2024 09:25:36 +0100 Subject: [PATCH 0858/1070] Refactor coordinator for Garages Amsterdam integration (#131054) --- .../components/garages_amsterdam/__init__.py | 52 ++++++------------- .../garages_amsterdam/binary_sensor.py | 13 ++--- .../components/garages_amsterdam/const.py | 11 +++- .../garages_amsterdam/coordinator.py | 34 ++++++++++++ .../components/garages_amsterdam/entity.py | 13 ++--- .../components/garages_amsterdam/sensor.py | 19 ++++--- .../components/garages_amsterdam/conftest.py | 20 ++++++- .../garages_amsterdam/test_config_flow.py | 2 +- .../components/garages_amsterdam/test_init.py | 28 ++++++++++ 9 files changed, 132 insertions(+), 60 deletions(-) create mode 100644 homeassistant/components/garages_amsterdam/coordinator.py create mode 100644 tests/components/garages_amsterdam/test_init.py diff --git a/homeassistant/components/garages_amsterdam/__init__.py b/homeassistant/components/garages_amsterdam/__init__.py index 81ec72d9fbfc1..4cdcc3f06be0b 100644 --- a/homeassistant/components/garages_amsterdam/__init__.py +++ b/homeassistant/components/garages_amsterdam/__init__.py @@ -1,25 +1,32 @@ """The Garages Amsterdam integration.""" -import asyncio -from datetime import timedelta -import logging +from __future__ import annotations -from odp_amsterdam import ODPAmsterdam, VehicleType +from odp_amsterdam import ODPAmsterdam from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +from .coordinator import GaragesAmsterdamDataUpdateCoordinator -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] + +type GaragesAmsterdamConfigEntry = ConfigEntry[GaragesAmsterdamDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Garages Amsterdam from a config entry.""" - await get_coordinator(hass) + client = ODPAmsterdam(session=async_get_clientsession(hass)) + coordinator = GaragesAmsterdamDataUpdateCoordinator(hass, client) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -31,32 +38,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.pop(DOMAIN) return unload_ok - - -async def get_coordinator( - hass: HomeAssistant, -) -> DataUpdateCoordinator: - """Get the data update coordinator.""" - if DOMAIN in hass.data: - return hass.data[DOMAIN] - - async def async_get_garages(): - async with asyncio.timeout(10): - return { - garage.garage_name: garage - for garage in await ODPAmsterdam( - session=aiohttp_client.async_get_clientsession(hass) - ).all_garages(vehicle=VehicleType.CAR) - } - - coordinator = DataUpdateCoordinator( - hass, - logging.getLogger(__name__), - name=DOMAIN, - update_method=async_get_garages, - update_interval=timedelta(minutes=10), - ) - await coordinator.async_config_entry_first_refresh() - - hass.data[DOMAIN] = coordinator - return coordinator diff --git a/homeassistant/components/garages_amsterdam/binary_sensor.py b/homeassistant/components/garages_amsterdam/binary_sensor.py index 0aebe36baeb3c..2be8aaeffc036 100644 --- a/homeassistant/components/garages_amsterdam/binary_sensor.py +++ b/homeassistant/components/garages_amsterdam/binary_sensor.py @@ -10,7 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import get_coordinator +from .const import DOMAIN +from .coordinator import GaragesAmsterdamDataUpdateCoordinator from .entity import GaragesAmsterdamEntity BINARY_SENSORS = { @@ -20,16 +21,16 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" - coordinator = await get_coordinator(hass) + coordinator: GaragesAmsterdamDataUpdateCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] async_add_entities( - GaragesAmsterdamBinarySensor( - coordinator, config_entry.data["garage_name"], info_type - ) + GaragesAmsterdamBinarySensor(coordinator, entry.data["garage_name"], info_type) for info_type in BINARY_SENSORS ) diff --git a/homeassistant/components/garages_amsterdam/const.py b/homeassistant/components/garages_amsterdam/const.py index ae7801a9abd96..0f1e6505f9f62 100644 --- a/homeassistant/components/garages_amsterdam/const.py +++ b/homeassistant/components/garages_amsterdam/const.py @@ -1,4 +1,13 @@ """Constants for the Garages Amsterdam integration.""" -DOMAIN = "garages_amsterdam" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "garages_amsterdam" ATTRIBUTION = f'{"Data provided by municipality of Amsterdam"}' + +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(minutes=10) diff --git a/homeassistant/components/garages_amsterdam/coordinator.py b/homeassistant/components/garages_amsterdam/coordinator.py new file mode 100644 index 0000000000000..3d06aba79e254 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/coordinator.py @@ -0,0 +1,34 @@ +"""Coordinator for the Garages Amsterdam integration.""" + +from __future__ import annotations + +from odp_amsterdam import Garage, ODPAmsterdam, VehicleType + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + + +class GaragesAmsterdamDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Garage]]): + """Class to manage fetching Garages Amsterdam data from single endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + client: ODPAmsterdam, + ) -> None: + """Initialize global Garages Amsterdam data updater.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.client = client + + async def _async_update_data(self) -> dict[str, Garage]: + return { + garage.garage_name: garage + for garage in await self.client.all_garages(vehicle=VehicleType.CAR) + } diff --git a/homeassistant/components/garages_amsterdam/entity.py b/homeassistant/components/garages_amsterdam/entity.py index 671405235d4fa..a8b030157bc33 100644 --- a/homeassistant/components/garages_amsterdam/entity.py +++ b/homeassistant/components/garages_amsterdam/entity.py @@ -3,22 +3,23 @@ from __future__ import annotations from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, DOMAIN +from .coordinator import GaragesAmsterdamDataUpdateCoordinator -class GaragesAmsterdamEntity(CoordinatorEntity): +class GaragesAmsterdamEntity(CoordinatorEntity[GaragesAmsterdamDataUpdateCoordinator]): """Base Entity for garages amsterdam data.""" _attr_attribution = ATTRIBUTION _attr_has_entity_name = True def __init__( - self, coordinator: DataUpdateCoordinator, garage_name: str, info_type: str + self, + coordinator: GaragesAmsterdamDataUpdateCoordinator, + garage_name: str, + info_type: str, ) -> None: """Initialize garages amsterdam entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py index b6fc950a843e2..87c72f4a248a0 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -6,9 +6,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import get_coordinator +from .const import DOMAIN +from .coordinator import GaragesAmsterdamDataUpdateCoordinator from .entity import GaragesAmsterdamEntity SENSORS = { @@ -21,16 +21,18 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" - coordinator = await get_coordinator(hass) + coordinator: GaragesAmsterdamDataUpdateCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] async_add_entities( - GaragesAmsterdamSensor(coordinator, config_entry.data["garage_name"], info_type) + GaragesAmsterdamSensor(coordinator, entry.data["garage_name"], info_type) for info_type in SENSORS - if getattr(coordinator.data[config_entry.data["garage_name"]], info_type) != "" + if getattr(coordinator.data[entry.data["garage_name"]], info_type) != "" ) @@ -40,7 +42,10 @@ class GaragesAmsterdamSensor(GaragesAmsterdamEntity, SensorEntity): _attr_native_unit_of_measurement = "cars" def __init__( - self, coordinator: DataUpdateCoordinator, garage_name: str, info_type: str + self, + coordinator: GaragesAmsterdamDataUpdateCoordinator, + garage_name: str, + info_type: str, ) -> None: """Initialize garages amsterdam sensor.""" super().__init__(coordinator, garage_name, info_type) diff --git a/tests/components/garages_amsterdam/conftest.py b/tests/components/garages_amsterdam/conftest.py index fb59ba26569c8..8d7eb8752b012 100644 --- a/tests/components/garages_amsterdam/conftest.py +++ b/tests/components/garages_amsterdam/conftest.py @@ -1,12 +1,28 @@ -"""Test helpers.""" +"""Fixtures for Garages Amsterdam integration tests.""" from unittest.mock import Mock, patch import pytest +from homeassistant.components.garages_amsterdam.const import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="monitor", + domain=DOMAIN, + data={}, + unique_id="unique_thingy", + version=1, + ) + @pytest.fixture(autouse=True) -def mock_cases(): +def mock_garages_amsterdam(): """Mock garages_amsterdam garages.""" with patch( "odp_amsterdam.ODPAmsterdam.all_garages", diff --git a/tests/components/garages_amsterdam/test_config_flow.py b/tests/components/garages_amsterdam/test_config_flow.py index 729d31e413ca6..9c5b10f9ecc8e 100644 --- a/tests/components/garages_amsterdam/test_config_flow.py +++ b/tests/components/garages_amsterdam/test_config_flow.py @@ -12,7 +12,7 @@ from homeassistant.data_entry_flow import FlowResultType -async def test_full_flow(hass: HomeAssistant) -> None: +async def test_full_user_flow(hass: HomeAssistant) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/garages_amsterdam/test_init.py b/tests/components/garages_amsterdam/test_init.py new file mode 100644 index 0000000000000..ff3166183a1c6 --- /dev/null +++ b/tests/components/garages_amsterdam/test_init.py @@ -0,0 +1,28 @@ +"""Tests for the Garages Amsterdam integration.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.garages_amsterdam.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_garages_amsterdam: AsyncMock, +) -> None: + """Test the Garages Amsterdam integration loads and unloads correctly.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED From 6947800d93ddbe47df7c85f4b369b6f53ca5f900 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Tue, 26 Nov 2024 09:30:45 +0100 Subject: [PATCH 0859/1070] Pass websession to fyta_cli (#131311) --- homeassistant/components/fyta/__init__.py | 5 +++- homeassistant/components/fyta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fyta/fixtures/plant_status1.json | 6 +++- .../fyta/fixtures/plant_status2.json | 6 +++- .../fyta/fixtures/plant_status3.json | 6 +++- .../fyta/snapshots/test_diagnostics.ambr | 28 +++++++++++++++++-- 8 files changed, 48 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index efbb145345634..b29789be87e7b 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -15,6 +15,7 @@ Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.dt import async_get_time_zone from .const import CONF_EXPIRATION @@ -39,7 +40,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: FytaConfigEntry) -> bool entry.data[CONF_EXPIRATION] ).astimezone(await async_get_time_zone(tz)) - fyta = FytaConnector(username, password, access_token, expiration, tz) + fyta = FytaConnector( + username, password, access_token, expiration, tz, async_get_clientsession(hass) + ) coordinator = FytaCoordinator(hass, fyta) diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index cc052e9e11fe4..0df9eca2e3805 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["fyta_cli"], - "requirements": ["fyta_cli==0.6.10"] + "requirements": ["fyta_cli==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 638ca160c44e2..277b9cc5020a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -947,7 +947,7 @@ freesms==0.2.0 fritzconnection[qr]==1.14.0 # homeassistant.components.fyta -fyta_cli==0.6.10 +fyta_cli==0.7.0 # homeassistant.components.google_translate gTTS==2.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc29da4aba6e0..a4d87e43c22bd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -800,7 +800,7 @@ freebox-api==1.1.0 fritzconnection[qr]==1.14.0 # homeassistant.components.fyta -fyta_cli==0.6.10 +fyta_cli==0.7.0 # homeassistant.components.google_translate gTTS==2.2.4 diff --git a/tests/components/fyta/fixtures/plant_status1.json b/tests/components/fyta/fixtures/plant_status1.json index 72d129492bba4..600fc46608cd6 100644 --- a/tests/components/fyta/fixtures/plant_status1.json +++ b/tests/components/fyta/fixtures/plant_status1.json @@ -1,13 +1,16 @@ { "battery_level": 80, - "battery_status": true, + "low_battery": true, "last_updated": "2023-01-10 10:10:00", "light": 2, "light_status": 3, "nickname": "Gummibaum", + "nutrients_status": 3, "moisture": 61, "moisture_status": 3, "sensor_available": true, + "sensor_id": "FD:1D:B7:E3:D0:E2", + "sensor_update_available": false, "sw_version": "1.0", "status": 1, "online": true, @@ -15,6 +18,7 @@ "plant_id": 0, "plant_origin_path": "", "plant_thumb_path": "", + "is_productive_plant": false, "salinity": 1, "salinity_status": 4, "scientific_name": "Ficus elastica", diff --git a/tests/components/fyta/fixtures/plant_status2.json b/tests/components/fyta/fixtures/plant_status2.json index 8ed095325671e..c39e2ac8685a8 100644 --- a/tests/components/fyta/fixtures/plant_status2.json +++ b/tests/components/fyta/fixtures/plant_status2.json @@ -1,13 +1,16 @@ { "battery_level": 80, - "battery_status": true, + "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, "light_status": 3, "nickname": "Kakaobaum", + "nutrients_status": 3, "moisture": 61, "moisture_status": 3, "sensor_available": true, + "sensor_id": "FD:1D:B7:E3:D0:E3", + "sensor_update_available": false, "sw_version": "1.0", "status": 1, "online": true, @@ -15,6 +18,7 @@ "plant_id": 0, "plant_origin_path": "", "plant_thumb_path": "", + "is_productive_plant": false, "salinity": 1, "salinity_status": 4, "scientific_name": "Theobroma cacao", diff --git a/tests/components/fyta/fixtures/plant_status3.json b/tests/components/fyta/fixtures/plant_status3.json index 6e32ba601ed08..58e3e1b86a0b4 100644 --- a/tests/components/fyta/fixtures/plant_status3.json +++ b/tests/components/fyta/fixtures/plant_status3.json @@ -1,13 +1,16 @@ { "battery_level": 80, - "battery_status": true, + "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, "light_status": 3, "nickname": "Tomatenpflanze", + "nutrients_status": 0, "moisture": 61, "moisture_status": 3, "sensor_available": true, + "sensor_id": "FD:1D:B7:E3:D0:E3", + "sensor_update_available": false, "sw_version": "1.0", "status": 1, "online": true, @@ -15,6 +18,7 @@ "plant_id": 0, "plant_origin_path": "", "plant_thumb_path": "", + "is_productive_plant": true, "salinity": 1, "salinity_status": 4, "scientific_name": "Solanum lycopersicum", diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index 2af616c64123f..eb19797e5b1a7 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -26,22 +26,34 @@ 'plant_data': dict({ '0': dict({ 'battery_level': 80.0, - 'battery_status': True, + 'fertilise_last': None, + 'fertilise_next': None, 'last_updated': '2023-01-10T10:10:00', 'light': 2.0, 'light_status': 3, + 'low_battery': True, 'moisture': 61.0, 'moisture_status': 3, 'name': 'Gummibaum', + 'notification_light': False, + 'notification_nutrition': False, + 'notification_temperature': False, + 'notification_water': False, + 'nutrients_status': 3, 'online': True, 'ph': None, 'plant_id': 0, 'plant_origin_path': '', 'plant_thumb_path': '', + 'productive_plant': False, + 'repotted': False, 'salinity': 1.0, 'salinity_status': 4, 'scientific_name': 'Ficus elastica', 'sensor_available': True, + 'sensor_id': 'FD:1D:B7:E3:D0:E2', + 'sensor_status': 0, + 'sensor_update_available': False, 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, @@ -49,22 +61,34 @@ }), '1': dict({ 'battery_level': 80.0, - 'battery_status': True, + 'fertilise_last': None, + 'fertilise_next': None, 'last_updated': '2023-01-02T10:10:00', 'light': 2.0, 'light_status': 3, + 'low_battery': True, 'moisture': 61.0, 'moisture_status': 3, 'name': 'Kakaobaum', + 'notification_light': False, + 'notification_nutrition': False, + 'notification_temperature': False, + 'notification_water': False, + 'nutrients_status': 3, 'online': True, 'ph': 7.0, 'plant_id': 0, 'plant_origin_path': '', 'plant_thumb_path': '', + 'productive_plant': False, + 'repotted': False, 'salinity': 1.0, 'salinity_status': 4, 'scientific_name': 'Theobroma cacao', 'sensor_available': True, + 'sensor_id': 'FD:1D:B7:E3:D0:E3', + 'sensor_status': 0, + 'sensor_update_available': False, 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, From 752df5a8cb8786d515210077a06e365de604e065 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 26 Nov 2024 02:42:31 -0600 Subject: [PATCH 0860/1070] Filter entity names before intent matching (#131563) --- .../components/conversation/default_agent.py | 159 ++++++++++-------- .../components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- .../conversation/test_default_agent.py | 75 ++++++++- 7 files changed, 166 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 20720b90423ed..c1256a1507b37 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -14,8 +14,14 @@ import time from typing import IO, Any, cast -from hassil.expression import Expression, ListReference, Sequence -from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList +from hassil.expression import Expression, ListReference, Sequence, TextChunk +from hassil.intents import ( + Intents, + SlotList, + TextSlotList, + TextSlotValue, + WildcardSlotList, +) from hassil.recognize import ( MISSING_ENTITY, RecognizeResult, @@ -23,6 +29,7 @@ recognize_best, ) from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity +from hassil.trie import Trie from hassil.util import merge_dict from home_assistant_intents import ErrorKey, get_intents, get_languages import yaml @@ -110,8 +117,8 @@ class IntentMatchingStage(Enum): EXPOSED_ENTITIES_ONLY = auto() """Match against exposed entities only.""" - ALL_ENTITIES = auto() - """Match against all entities in Home Assistant.""" + UNEXPOSED_ENTITIES = auto() + """Match against unexposed entities in Home Assistant.""" FUZZY = auto() """Capture names that are not known to Home Assistant.""" @@ -233,7 +240,10 @@ def __init__( # intent -> [sentences] self._config_intents: dict[str, Any] = config_intents self._slot_lists: dict[str, SlotList] | None = None - self._all_entity_names: TextSlotList | None = None + + # Used to filter slot lists before intent matching + self._exposed_names_trie: Trie | None = None + self._unexposed_names_trie: Trie | None = None # Sentences that will trigger a callback (skipping intent recognition) self._trigger_sentences: list[TriggerData] = [] @@ -305,6 +315,16 @@ async def async_recognize_intent( slot_lists = self._make_slot_lists() intent_context = self._make_intent_context(user_input) + if self._exposed_names_trie is not None: + # Filter by input string + text_lower = user_input.text.strip().lower() + slot_lists["name"] = TextSlotList( + name="name", + values=[ + result[2] for result in self._exposed_names_trie.find(text_lower) + ], + ) + start = time.monotonic() result = await self.hass.async_add_executor_job( @@ -540,29 +560,29 @@ def _recognize( return None # Try again with all entities (including unexposed) - skip_all_entities_match = False + skip_unexposed_entities_match = False if cache_value is not None: if (cache_value.result is not None) and ( - cache_value.stage == IntentMatchingStage.ALL_ENTITIES + cache_value.stage == IntentMatchingStage.UNEXPOSED_ENTITIES ): _LOGGER.debug("Got cached result for all entities") return cache_value.result # Continue with matching, but we know we won't succeed for all # entities. - skip_all_entities_match = True + skip_unexposed_entities_match = True - if not skip_all_entities_match: - all_entities_slot_lists = { + if not skip_unexposed_entities_match: + unexposed_entities_slot_lists = { **slot_lists, - "name": self._get_all_entity_names(), + "name": self._get_unexposed_entity_names(user_input.text), } start_time = time.monotonic() strict_result = self._recognize_strict( user_input, lang_intents, - all_entities_slot_lists, + unexposed_entities_slot_lists, intent_context, language, ) @@ -575,7 +595,7 @@ def _recognize( self._intent_cache.put( cache_key, IntentCacheValue( - result=strict_result, stage=IntentMatchingStage.ALL_ENTITIES + result=strict_result, stage=IntentMatchingStage.UNEXPOSED_ENTITIES ), ) @@ -683,15 +703,43 @@ def _recognize( return maybe_result - def _get_all_entity_names(self) -> TextSlotList: - """Get slot list with all entity names in Home Assistant.""" - if self._all_entity_names is not None: - return self._all_entity_names + def _get_unexposed_entity_names(self, text: str) -> TextSlotList: + """Get filtered slot list with unexposed entity names in Home Assistant.""" + if self._unexposed_names_trie is None: + # Build trie + self._unexposed_names_trie = Trie() + for name_tuple in self._get_entity_name_tuples(exposed=False): + self._unexposed_names_trie.insert( + name_tuple[0].lower(), + TextSlotValue.from_tuple(name_tuple), + ) + + # Build filtered slot list + text_lower = text.strip().lower() + return TextSlotList( + name="name", + values=[ + result[2] for result in self._unexposed_names_trie.find(text_lower) + ], + ) + def _get_entity_name_tuples( + self, exposed: bool + ) -> Iterable[tuple[str, str, dict[str, Any]]]: + """Yield (input name, output name, context) tuples for entities.""" entity_registry = er.async_get(self.hass) - all_entity_names: list[tuple[str, str, dict[str, Any]]] = [] for state in self.hass.states.async_all(): + entity_exposed = async_should_expose(self.hass, DOMAIN, state.entity_id) + if exposed and (not entity_exposed): + # Required exposed, entity is not + continue + + if (not exposed) and entity_exposed: + # Required not exposed, entity is + continue + + # Checked against "requires_context" and "excludes_context" in hassil context = {"domain": state.domain} if state.attributes: # Include some attributes @@ -700,28 +748,18 @@ def _get_all_entity_names(self) -> TextSlotList: continue context[attr] = state.attributes[attr] - if entity := entity_registry.async_get(state.entity_id): - # Skip config/hidden entities - if (entity.entity_category is not None) or ( - entity.hidden_by is not None - ): - continue - - if entity.aliases: - # Also add aliases - for alias in entity.aliases: - if not alias.strip(): - continue + if ( + entity := entity_registry.async_get(state.entity_id) + ) and entity.aliases: + for alias in entity.aliases: + alias = alias.strip() + if not alias: + continue - all_entity_names.append((alias, alias, context)) + yield (alias, alias, context) # Default name - all_entity_names.append((state.name, state.name, context)) - - self._all_entity_names = TextSlotList.from_tuples( - all_entity_names, allow_template=False - ) - return self._all_entity_names + yield (state.name, state.name, context) def _recognize_strict( self, @@ -1013,7 +1051,8 @@ def _async_clear_slot_list(self, event: core.Event[Any] | None = None) -> None: if self._unsub_clear_slot_list is None: return self._slot_lists = None - self._all_entity_names = None + self._exposed_names_trie = None + self._unexposed_names_trie = None for unsub in self._unsub_clear_slot_list: unsub() self._unsub_clear_slot_list = None @@ -1029,8 +1068,6 @@ def _make_slot_lists(self) -> dict[str, SlotList]: start = time.monotonic() - entity_registry = er.async_get(self.hass) - # Gather entity names, keeping track of exposed names. # We try intent recognition with only exposed names first, then all names. # @@ -1038,35 +1075,7 @@ def _make_slot_lists(self) -> dict[str, SlotList]: # have the same name. The intent matcher doesn't gather all matching # values for a list, just the first. So we will need to match by name no # matter what. - exposed_entity_names = [] - for state in self.hass.states.async_all(): - is_exposed = async_should_expose(self.hass, DOMAIN, state.entity_id) - - # Checked against "requires_context" and "excludes_context" in hassil - context = {"domain": state.domain} - if state.attributes: - # Include some attributes - for attr in DEFAULT_EXPOSED_ATTRIBUTES: - if attr not in state.attributes: - continue - context[attr] = state.attributes[attr] - - if ( - entity := entity_registry.async_get(state.entity_id) - ) and entity.aliases: - for alias in entity.aliases: - if not alias.strip(): - continue - - name_tuple = (alias, alias, context) - if is_exposed: - exposed_entity_names.append(name_tuple) - - # Default name - name_tuple = (state.name, state.name, context) - if is_exposed: - exposed_entity_names.append(name_tuple) - + exposed_entity_names = list(self._get_entity_name_tuples(exposed=True)) _LOGGER.debug("Exposed entities: %s", exposed_entity_names) # Expose all areas. @@ -1099,11 +1108,17 @@ def _make_slot_lists(self) -> dict[str, SlotList]: floor_names.append((alias, floor.name)) + # Build trie + self._exposed_names_trie = Trie() + name_list = TextSlotList.from_tuples(exposed_entity_names, allow_template=False) + for name_value in name_list.values: + assert isinstance(name_value.text_in, TextChunk) + name_text = name_value.text_in.text.strip().lower() + self._exposed_names_trie.insert(name_text, name_value) + self._slot_lists = { "area": TextSlotList.from_tuples(area_names, allow_template=False), - "name": TextSlotList.from_tuples( - exposed_entity_names, allow_template=False - ), + "name": name_list, "floor": TextSlotList.from_tuples(floor_names, allow_template=False), } diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 6c2d70b6a1148..b45a545682545 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.0.2", "home-assistant-intents==2024.11.13"] + "requirements": ["hassil==2.0.4", "home-assistant-intents==2024.11.13"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 765fbd89a2440..19bfee3c80a51 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 habluetooth==3.6.0 hass-nabucasa==0.85.0 -hassil==2.0.2 +hassil==2.0.4 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241106.2 home-assistant-intents==2024.11.13 diff --git a/requirements_all.txt b/requirements_all.txt index 277b9cc5020a6..8aeb750939536 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1096,7 +1096,7 @@ hass-nabucasa==0.85.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.0.2 +hassil==2.0.4 # homeassistant.components.jewish_calendar hdate==0.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4d87e43c22bd..3fb24e4a46ee8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -931,7 +931,7 @@ habluetooth==3.6.0 hass-nabucasa==0.85.0 # homeassistant.components.conversation -hassil==2.0.2 +hassil==2.0.4 # homeassistant.components.jewish_calendar hdate==0.11.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index b75477820ffeb..e6ab27de9b08c 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.4,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.0 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.2 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.4 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 1e5e284a24572..6990ffe7717b7 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -1735,7 +1735,7 @@ async def test_empty_aliases( return_value=None, ) as mock_recognize_all: await conversation.async_converse( - hass, "turn on lights in the kitchen", None, Context(), None + hass, "turn on kitchen light", None, Context(), None ) assert mock_recognize_all.call_count > 0 @@ -2940,3 +2940,76 @@ async def test_intent_cache_fuzzy(hass: HomeAssistant) -> None: result = await agent.async_recognize_intent(user_input) assert result is not None assert getattr(result, mark, None) is True + + +@pytest.mark.usefixtures("init_components") +async def test_entities_filtered_by_input(hass: HomeAssistant) -> None: + """Test that entities are filtered by the input text before intent matching.""" + agent = hass.data[DATA_DEFAULT_ENTITY] + assert isinstance(agent, default_agent.DefaultAgent) + + # Only the switch is exposed + hass.states.async_set("light.test_light", "off") + hass.states.async_set( + "light.test_light_2", "off", attributes={ATTR_FRIENDLY_NAME: "test light"} + ) + hass.states.async_set("cover.garage_door", "closed") + hass.states.async_set("switch.test_switch", "off") + expose_entity(hass, "light.test_light", False) + expose_entity(hass, "light.test_light_2", False) + expose_entity(hass, "cover.garage_door", False) + expose_entity(hass, "switch.test_switch", True) + await hass.async_block_till_done() + + # test switch is exposed + user_input = ConversationInput( + text="turn on test switch", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + agent_id=None, + ) + + with patch( + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=None, + ) as recognize_best: + await agent.async_recognize_intent(user_input) + + # (1) exposed, (2) all entities + assert len(recognize_best.call_args_list) == 2 + + # Only the test light should have been considered because its name shows + # up in the input text. + slot_lists = recognize_best.call_args_list[0].kwargs["slot_lists"] + name_list = slot_lists["name"] + assert len(name_list.values) == 1 + assert name_list.values[0].text_in.text == "test switch" + + # test light is not exposed + user_input = ConversationInput( + text="turn on Test Light", # different casing for name + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + agent_id=None, + ) + + with patch( + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=None, + ) as recognize_best: + await agent.async_recognize_intent(user_input) + + # (1) exposed, (2) all entities + assert len(recognize_best.call_args_list) == 2 + + # Both test lights should have been considered because their name shows + # up in the input text. + slot_lists = recognize_best.call_args_list[1].kwargs["slot_lists"] + name_list = slot_lists["name"] + assert len(name_list.values) == 2 + assert name_list.values[0].text_in.text == "test light" + assert name_list.values[1].text_in.text == "test light" From 60e1fb5d4f3fd43d3e18aa395cdc51e66202d745 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 26 Nov 2024 09:43:25 +0100 Subject: [PATCH 0861/1070] Translate UpdateFailed in devolo Home Network (#131603) --- .../devolo_home_network/__init__.py | 42 +++++++++++++++---- .../devolo_home_network/strings.json | 3 ++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 70a9453143162..8006bcfdc87c2 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -98,7 +98,11 @@ async def async_update_firmware_available() -> UpdateFirmwareCheck: try: return await device.device.async_check_firmware_available() except DeviceUnavailable as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err async def async_update_connected_plc_devices() -> LogicalNetwork: """Fetch data from API endpoint.""" @@ -107,7 +111,11 @@ async def async_update_connected_plc_devices() -> LogicalNetwork: try: return await device.plcnet.async_get_network_overview() except DeviceUnavailable as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err async def async_update_guest_wifi_status() -> WifiGuestAccessGet: """Fetch data from API endpoint.""" @@ -116,7 +124,11 @@ async def async_update_guest_wifi_status() -> WifiGuestAccessGet: try: return await device.device.async_get_wifi_guest_access() except DeviceUnavailable as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err except DevicePasswordProtected as err: raise ConfigEntryAuthFailed( err, translation_domain=DOMAIN, translation_key="password_wrong" @@ -129,7 +141,11 @@ async def async_update_led_status() -> bool: try: return await device.device.async_get_led_setting() except DeviceUnavailable as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err async def async_update_last_restart() -> int: """Fetch data from API endpoint.""" @@ -138,7 +154,11 @@ async def async_update_last_restart() -> int: try: return await device.device.async_uptime() except DeviceUnavailable as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err except DevicePasswordProtected as err: raise ConfigEntryAuthFailed( err, translation_domain=DOMAIN, translation_key="password_wrong" @@ -151,7 +171,11 @@ async def async_update_wifi_connected_station() -> list[ConnectedStationInfo]: try: return await device.device.async_get_wifi_connected_station() except DeviceUnavailable as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err async def async_update_wifi_neighbor_access_points() -> list[NeighborAPInfo]: """Fetch data from API endpoint.""" @@ -160,7 +184,11 @@ async def async_update_wifi_neighbor_access_points() -> list[NeighborAPInfo]: try: return await device.device.async_get_wifi_neighbor_access_points() except DeviceUnavailable as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err async def disconnect(event: Event) -> None: """Disconnect from device.""" diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 0799bb14172bd..2996cea90cb3f 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -94,6 +94,9 @@ }, "password_wrong": { "message": "The used password is wrong" + }, + "update_failed": { + "message": "Error while updating the data: {error}" } } } From 725d49ca9e6c332e3a42492540e1b0605e09f574 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 26 Nov 2024 09:43:39 +0100 Subject: [PATCH 0862/1070] Use hostname as config entry title in SABnzbd (#131604) --- homeassistant/components/sabnzbd/config_flow.py | 5 ++++- tests/components/sabnzbd/test_config_flow.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sabnzbd/config_flow.py b/homeassistant/components/sabnzbd/config_flow.py index 72b3f92409f5a..9ce29df02eab2 100644 --- a/homeassistant/components/sabnzbd/config_flow.py +++ b/homeassistant/components/sabnzbd/config_flow.py @@ -6,6 +6,7 @@ from typing import Any import voluptuous as vol +import yarl from homeassistant.config_entries import ( SOURCE_RECONFIGURE, @@ -18,6 +19,7 @@ TextSelectorConfig, TextSelectorType, ) +from homeassistant.util import slugify from .const import DOMAIN from .helpers import get_client @@ -67,8 +69,9 @@ async def async_step_user( self._get_reconfigure_entry(), data_updates=user_input ) + parsed_url = yarl.URL(user_input[CONF_URL]) return self.async_create_entry( - title=user_input[CONF_API_KEY][:12], data=user_input + title=slugify(parsed_url.host), data=user_input ) return self.async_show_form( diff --git a/tests/components/sabnzbd/test_config_flow.py b/tests/components/sabnzbd/test_config_flow.py index 8bf28e2c0cd42..8d8289e1e887a 100644 --- a/tests/components/sabnzbd/test_config_flow.py +++ b/tests/components/sabnzbd/test_config_flow.py @@ -37,7 +37,7 @@ async def test_create_entry(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "edc3eee7330e" + assert result["title"] == "localhost" assert result["data"] == { CONF_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0", CONF_URL: "http://localhost:8080", @@ -68,7 +68,7 @@ async def test_auth_error(hass: HomeAssistant, sabnzbd: AsyncMock) -> None: assert "errors" not in result assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "edc3eee7330e" + assert result["title"] == "localhost" assert result["data"] == { CONF_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0", CONF_URL: "http://localhost:8080", From 521cc67d4503f4824caba37abd176f34b0072eaa Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 26 Nov 2024 09:54:23 +0100 Subject: [PATCH 0863/1070] Streamline HomeWizard unit test MAC-addresses (#131310) * Streamline HomeWizard unit test MAC-addresses * Also streamline mock_config_entry --- tests/components/homewizard/conftest.py | 8 +- .../homewizard/fixtures/HWE-KWH1/device.json | 2 +- .../homewizard/fixtures/HWE-KWH3/device.json | 2 +- .../fixtures/HWE-P1-invalid-EAN/device.json | 2 +- .../HWE-P1-unused-exports/device.json | 2 +- .../fixtures/HWE-P1-zero-values/device.json | 2 +- .../homewizard/fixtures/HWE-P1/device.json | 2 +- .../fixtures/HWE-SKT-11/device.json | 2 +- .../fixtures/HWE-SKT-21/device.json | 2 +- .../homewizard/fixtures/HWE-WTR/device.json | 2 +- .../fixtures/SDM230/SDM630/device.json | 2 +- .../homewizard/fixtures/SDM230/device.json | 2 +- .../homewizard/fixtures/SDM630/device.json | 2 +- .../homewizard/snapshots/test_button.ambr | 6 +- .../snapshots/test_config_flow.ambr | 18 +- .../snapshots/test_diagnostics.ambr | 32 +- .../homewizard/snapshots/test_number.ambr | 12 +- .../homewizard/snapshots/test_sensor.ambr | 1248 ++++++++--------- .../homewizard/snapshots/test_switch.ambr | 66 +- .../components/homewizard/test_config_flow.py | 12 +- 20 files changed, 713 insertions(+), 713 deletions(-) diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index 82ec0ecef789d..dfd92577a0422 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -77,12 +77,12 @@ def mock_config_entry() -> MockConfigEntry: title="Device", domain=DOMAIN, data={ - "product_name": "Product name", - "product_type": "product_type", - "serial": "aabbccddeeff", + "product_name": "P1 Meter", + "product_type": "HWE-P1", + "serial": "5c2fafabcdef", CONF_IP_ADDRESS: "127.0.0.1", }, - unique_id="aabbccddeeff", + unique_id="HWE-P1_5c2fafabcdef", ) diff --git a/tests/components/homewizard/fixtures/HWE-KWH1/device.json b/tests/components/homewizard/fixtures/HWE-KWH1/device.json index 67f9ddf42cb53..2cb20bf125509 100644 --- a/tests/components/homewizard/fixtures/HWE-KWH1/device.json +++ b/tests/components/homewizard/fixtures/HWE-KWH1/device.json @@ -1,7 +1,7 @@ { "product_type": "HWE-KWH1", "product_name": "kWh meter", - "serial": "3c39e7aabbcc", + "serial": "5c2fafabcdef", "firmware_version": "3.06", "api_version": "v1" } diff --git a/tests/components/homewizard/fixtures/HWE-KWH3/device.json b/tests/components/homewizard/fixtures/HWE-KWH3/device.json index e3122c8ff893e..a3ba3281a4f4a 100644 --- a/tests/components/homewizard/fixtures/HWE-KWH3/device.json +++ b/tests/components/homewizard/fixtures/HWE-KWH3/device.json @@ -1,7 +1,7 @@ { "product_type": "HWE-KWH3", "product_name": "KWh meter 3-phase", - "serial": "3c39e7aabbcc", + "serial": "5c2fafabcdef", "firmware_version": "3.06", "api_version": "v1" } diff --git a/tests/components/homewizard/fixtures/HWE-P1-invalid-EAN/device.json b/tests/components/homewizard/fixtures/HWE-P1-invalid-EAN/device.json index 4972c49185994..a444aa81c30b6 100644 --- a/tests/components/homewizard/fixtures/HWE-P1-invalid-EAN/device.json +++ b/tests/components/homewizard/fixtures/HWE-P1-invalid-EAN/device.json @@ -1,7 +1,7 @@ { "product_type": "HWE-P1", "product_name": "P1 meter", - "serial": "3c39e7aabbcc", + "serial": "5c2fafabcdef", "firmware_version": "4.19", "api_version": "v1" } diff --git a/tests/components/homewizard/fixtures/HWE-P1-unused-exports/device.json b/tests/components/homewizard/fixtures/HWE-P1-unused-exports/device.json index 4972c49185994..a444aa81c30b6 100644 --- a/tests/components/homewizard/fixtures/HWE-P1-unused-exports/device.json +++ b/tests/components/homewizard/fixtures/HWE-P1-unused-exports/device.json @@ -1,7 +1,7 @@ { "product_type": "HWE-P1", "product_name": "P1 meter", - "serial": "3c39e7aabbcc", + "serial": "5c2fafabcdef", "firmware_version": "4.19", "api_version": "v1" } diff --git a/tests/components/homewizard/fixtures/HWE-P1-zero-values/device.json b/tests/components/homewizard/fixtures/HWE-P1-zero-values/device.json index 4972c49185994..a444aa81c30b6 100644 --- a/tests/components/homewizard/fixtures/HWE-P1-zero-values/device.json +++ b/tests/components/homewizard/fixtures/HWE-P1-zero-values/device.json @@ -1,7 +1,7 @@ { "product_type": "HWE-P1", "product_name": "P1 meter", - "serial": "3c39e7aabbcc", + "serial": "5c2fafabcdef", "firmware_version": "4.19", "api_version": "v1" } diff --git a/tests/components/homewizard/fixtures/HWE-P1/device.json b/tests/components/homewizard/fixtures/HWE-P1/device.json index 4972c49185994..a444aa81c30b6 100644 --- a/tests/components/homewizard/fixtures/HWE-P1/device.json +++ b/tests/components/homewizard/fixtures/HWE-P1/device.json @@ -1,7 +1,7 @@ { "product_type": "HWE-P1", "product_name": "P1 meter", - "serial": "3c39e7aabbcc", + "serial": "5c2fafabcdef", "firmware_version": "4.19", "api_version": "v1" } diff --git a/tests/components/homewizard/fixtures/HWE-SKT-11/device.json b/tests/components/homewizard/fixtures/HWE-SKT-11/device.json index bab5a63636838..8b768eccb98ab 100644 --- a/tests/components/homewizard/fixtures/HWE-SKT-11/device.json +++ b/tests/components/homewizard/fixtures/HWE-SKT-11/device.json @@ -1,7 +1,7 @@ { "product_type": "HWE-SKT", "product_name": "Energy Socket", - "serial": "3c39e7aabbcc", + "serial": "5c2fafabcdef", "firmware_version": "3.03", "api_version": "v1" } diff --git a/tests/components/homewizard/fixtures/HWE-SKT-21/device.json b/tests/components/homewizard/fixtures/HWE-SKT-21/device.json index 69b5947351f4f..a4ab182e7ec71 100644 --- a/tests/components/homewizard/fixtures/HWE-SKT-21/device.json +++ b/tests/components/homewizard/fixtures/HWE-SKT-21/device.json @@ -1,7 +1,7 @@ { "product_type": "HWE-SKT", "product_name": "Energy Socket", - "serial": "3c39e7aabbcc", + "serial": "5c2fafabcdef", "firmware_version": "4.07", "api_version": "v1" } diff --git a/tests/components/homewizard/fixtures/HWE-WTR/device.json b/tests/components/homewizard/fixtures/HWE-WTR/device.json index d33e6045299b9..3f57d7174fc62 100644 --- a/tests/components/homewizard/fixtures/HWE-WTR/device.json +++ b/tests/components/homewizard/fixtures/HWE-WTR/device.json @@ -1,7 +1,7 @@ { "product_type": "HWE-WTR", "product_name": "Watermeter", - "serial": "3c39e7aabbcc", + "serial": "5c2fafabcdef", "firmware_version": "2.03", "api_version": "v1" } diff --git a/tests/components/homewizard/fixtures/SDM230/SDM630/device.json b/tests/components/homewizard/fixtures/SDM230/SDM630/device.json index b8ec1d18fe802..c7fefd081b5da 100644 --- a/tests/components/homewizard/fixtures/SDM230/SDM630/device.json +++ b/tests/components/homewizard/fixtures/SDM230/SDM630/device.json @@ -1,7 +1,7 @@ { "product_type": "SDM630-wifi", "product_name": "KWh meter 3-phase", - "serial": "3c39e7aabbcc", + "serial": "5c2fafabcdef", "firmware_version": "3.06", "api_version": "v1" } diff --git a/tests/components/homewizard/fixtures/SDM230/device.json b/tests/components/homewizard/fixtures/SDM230/device.json index b6b5c18904e14..2dcd391e119f2 100644 --- a/tests/components/homewizard/fixtures/SDM230/device.json +++ b/tests/components/homewizard/fixtures/SDM230/device.json @@ -1,7 +1,7 @@ { "product_type": "SDM230-wifi", "product_name": "kWh meter", - "serial": "3c39e7aabbcc", + "serial": "5c2fafabcdef", "firmware_version": "3.06", "api_version": "v1" } diff --git a/tests/components/homewizard/fixtures/SDM630/device.json b/tests/components/homewizard/fixtures/SDM630/device.json index b8ec1d18fe802..c7fefd081b5da 100644 --- a/tests/components/homewizard/fixtures/SDM630/device.json +++ b/tests/components/homewizard/fixtures/SDM630/device.json @@ -1,7 +1,7 @@ { "product_type": "SDM630-wifi", "product_name": "KWh meter 3-phase", - "serial": "3c39e7aabbcc", + "serial": "5c2fafabcdef", "firmware_version": "3.06", "api_version": "v1" } diff --git a/tests/components/homewizard/snapshots/test_button.ambr b/tests/components/homewizard/snapshots/test_button.ambr index d5ad977047802..6dd7fcc45d275 100644 --- a/tests/components/homewizard/snapshots/test_button.ambr +++ b/tests/components/homewizard/snapshots/test_button.ambr @@ -42,7 +42,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_identify', + 'unique_id': 'HWE-P1_5c2fafabcdef_identify', 'unit_of_measurement': None, }) # --- @@ -54,7 +54,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -64,7 +64,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, diff --git a/tests/components/homewizard/snapshots/test_config_flow.ambr b/tests/components/homewizard/snapshots/test_config_flow.ambr index c3852a8c3faf0..0a301fc39410e 100644 --- a/tests/components/homewizard/snapshots/test_config_flow.ambr +++ b/tests/components/homewizard/snapshots/test_config_flow.ambr @@ -3,7 +3,7 @@ FlowResultSnapshot({ 'context': dict({ 'source': 'zeroconf', - 'unique_id': 'HWE-P1_aabbccddeeff', + 'unique_id': 'HWE-P1_5c2fafabcdef', }), 'data': dict({ 'ip_address': '127.0.0.1', @@ -31,7 +31,7 @@ 'pref_disable_polling': False, 'source': 'zeroconf', 'title': 'P1 meter', - 'unique_id': 'HWE-P1_aabbccddeeff', + 'unique_id': 'HWE-P1_5c2fafabcdef', 'version': 1, }), 'title': 'P1 meter', @@ -47,7 +47,7 @@ 'title_placeholders': dict({ 'name': 'P1 meter', }), - 'unique_id': 'HWE-P1_aabbccddeeff', + 'unique_id': 'HWE-P1_5c2fafabcdef', }), 'data': dict({ 'ip_address': '127.0.0.1', @@ -75,7 +75,7 @@ 'pref_disable_polling': False, 'source': 'zeroconf', 'title': 'P1 meter', - 'unique_id': 'HWE-P1_aabbccddeeff', + 'unique_id': 'HWE-P1_5c2fafabcdef', 'version': 1, }), 'title': 'P1 meter', @@ -89,9 +89,9 @@ 'confirm_only': True, 'source': 'zeroconf', 'title_placeholders': dict({ - 'name': 'Energy Socket (aabbccddeeff)', + 'name': 'Energy Socket (5c2fafabcdef)', }), - 'unique_id': 'HWE-SKT_aabbccddeeff', + 'unique_id': 'HWE-SKT_5c2fafabcdef', }), 'data': dict({ 'ip_address': '127.0.0.1', @@ -119,7 +119,7 @@ 'pref_disable_polling': False, 'source': 'zeroconf', 'title': 'Energy Socket', - 'unique_id': 'HWE-SKT_aabbccddeeff', + 'unique_id': 'HWE-SKT_5c2fafabcdef', 'version': 1, }), 'title': 'Energy Socket', @@ -131,7 +131,7 @@ FlowResultSnapshot({ 'context': dict({ 'source': 'user', - 'unique_id': 'HWE-P1_3c39e7aabbcc', + 'unique_id': 'HWE-P1_5c2fafabcdef', }), 'data': dict({ 'ip_address': '2.2.2.2', @@ -159,7 +159,7 @@ 'pref_disable_polling': False, 'source': 'user', 'title': 'P1 meter', - 'unique_id': 'HWE-P1_3c39e7aabbcc', + 'unique_id': 'HWE-P1_5c2fafabcdef', 'version': 1, }), 'title': 'P1 meter', diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index f8ac80f253624..cb5e7ef1f43d3 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -82,8 +82,8 @@ }), 'entry': dict({ 'ip_address': '**REDACTED**', - 'product_name': 'Product name', - 'product_type': 'product_type', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-P1', 'serial': '**REDACTED**', }), }) @@ -171,8 +171,8 @@ }), 'entry': dict({ 'ip_address': '**REDACTED**', - 'product_name': 'Product name', - 'product_type': 'product_type', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-P1', 'serial': '**REDACTED**', }), }) @@ -311,8 +311,8 @@ }), 'entry': dict({ 'ip_address': '**REDACTED**', - 'product_name': 'Product name', - 'product_type': 'product_type', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-P1', 'serial': '**REDACTED**', }), }) @@ -404,8 +404,8 @@ }), 'entry': dict({ 'ip_address': '**REDACTED**', - 'product_name': 'Product name', - 'product_type': 'product_type', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-P1', 'serial': '**REDACTED**', }), }) @@ -497,8 +497,8 @@ }), 'entry': dict({ 'ip_address': '**REDACTED**', - 'product_name': 'Product name', - 'product_type': 'product_type', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-P1', 'serial': '**REDACTED**', }), }) @@ -586,8 +586,8 @@ }), 'entry': dict({ 'ip_address': '**REDACTED**', - 'product_name': 'Product name', - 'product_type': 'product_type', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-P1', 'serial': '**REDACTED**', }), }) @@ -675,8 +675,8 @@ }), 'entry': dict({ 'ip_address': '**REDACTED**', - 'product_name': 'Product name', - 'product_type': 'product_type', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-P1', 'serial': '**REDACTED**', }), }) @@ -764,8 +764,8 @@ }), 'entry': dict({ 'ip_address': '**REDACTED**', - 'product_name': 'Product name', - 'product_type': 'product_type', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-P1', 'serial': '**REDACTED**', }), }) diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 768255c7508a3..49f23cf8e2fd1 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -51,7 +51,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'status_light_brightness', - 'unique_id': 'aabbccddeeff_status_light_brightness', + 'unique_id': 'HWE-P1_5c2fafabcdef_status_light_brightness', 'unit_of_measurement': '%', }) # --- @@ -63,7 +63,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -73,7 +73,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -143,7 +143,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'status_light_brightness', - 'unique_id': 'aabbccddeeff_status_light_brightness', + 'unique_id': 'HWE-P1_5c2fafabcdef_status_light_brightness', 'unit_of_measurement': '%', }) # --- @@ -155,7 +155,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -165,7 +165,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 5d5b458dccc4c..a91c87722d1d8 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -7,7 +7,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -17,7 +17,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -66,7 +66,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_va', 'unit_of_measurement': , }) # --- @@ -94,7 +94,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -104,7 +104,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -153,7 +153,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_current_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', 'unit_of_measurement': , }) # --- @@ -181,7 +181,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -191,7 +191,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -240,7 +240,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', 'unit_of_measurement': , }) # --- @@ -268,7 +268,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -278,7 +278,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -327,7 +327,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', 'unit_of_measurement': , }) # --- @@ -355,7 +355,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -365,7 +365,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -414,7 +414,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', 'unit_of_measurement': , }) # --- @@ -442,7 +442,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -452,7 +452,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -504,7 +504,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_power_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', 'unit_of_measurement': , }) # --- @@ -532,7 +532,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -542,7 +542,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -591,7 +591,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_power_factor', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor', 'unit_of_measurement': '%', }) # --- @@ -619,7 +619,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -629,7 +629,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -678,7 +678,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_var', 'unit_of_measurement': , }) # --- @@ -706,7 +706,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -716,7 +716,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -765,7 +765,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_voltage_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_v', 'unit_of_measurement': , }) # --- @@ -793,7 +793,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -803,7 +803,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -850,7 +850,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', - 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', 'unit_of_measurement': None, }) # --- @@ -875,7 +875,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -885,7 +885,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -934,7 +934,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', - 'unique_id': 'aabbccddeeff_wifi_strength', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', 'unit_of_measurement': '%', }) # --- @@ -961,7 +961,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -971,7 +971,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -1020,7 +1020,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_va', 'unit_of_measurement': , }) # --- @@ -1048,7 +1048,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -1058,7 +1058,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -1107,7 +1107,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_apparent_power_phase_va', - 'unique_id': 'aabbccddeeff_active_apparent_power_l1_va', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_l1_va', 'unit_of_measurement': , }) # --- @@ -1135,7 +1135,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -1145,7 +1145,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -1194,7 +1194,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_apparent_power_phase_va', - 'unique_id': 'aabbccddeeff_active_apparent_power_l2_va', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_l2_va', 'unit_of_measurement': , }) # --- @@ -1222,7 +1222,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -1232,7 +1232,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -1281,7 +1281,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_apparent_power_phase_va', - 'unique_id': 'aabbccddeeff_active_apparent_power_l3_va', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_l3_va', 'unit_of_measurement': , }) # --- @@ -1309,7 +1309,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -1319,7 +1319,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -1368,7 +1368,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_current_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', 'unit_of_measurement': , }) # --- @@ -1396,7 +1396,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -1406,7 +1406,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -1455,7 +1455,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l1_a', 'unit_of_measurement': , }) # --- @@ -1483,7 +1483,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -1493,7 +1493,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -1542,7 +1542,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l2_a', 'unit_of_measurement': , }) # --- @@ -1570,7 +1570,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -1580,7 +1580,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -1629,7 +1629,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l3_a', 'unit_of_measurement': , }) # --- @@ -1657,7 +1657,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -1667,7 +1667,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -1716,7 +1716,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', 'unit_of_measurement': , }) # --- @@ -1744,7 +1744,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -1754,7 +1754,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -1803,7 +1803,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', 'unit_of_measurement': , }) # --- @@ -1831,7 +1831,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -1841,7 +1841,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -1890,7 +1890,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', 'unit_of_measurement': , }) # --- @@ -1918,7 +1918,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -1928,7 +1928,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -1980,7 +1980,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_power_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', 'unit_of_measurement': , }) # --- @@ -2008,7 +2008,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -2018,7 +2018,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -2067,7 +2067,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_factor_phase', - 'unique_id': 'aabbccddeeff_active_power_factor_l1', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor_l1', 'unit_of_measurement': '%', }) # --- @@ -2095,7 +2095,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -2105,7 +2105,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -2154,7 +2154,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_factor_phase', - 'unique_id': 'aabbccddeeff_active_power_factor_l2', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor_l2', 'unit_of_measurement': '%', }) # --- @@ -2182,7 +2182,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -2192,7 +2192,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -2241,7 +2241,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_factor_phase', - 'unique_id': 'aabbccddeeff_active_power_factor_l3', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor_l3', 'unit_of_measurement': '%', }) # --- @@ -2269,7 +2269,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -2279,7 +2279,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -2331,7 +2331,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', 'unit_of_measurement': , }) # --- @@ -2359,7 +2359,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -2369,7 +2369,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -2421,7 +2421,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l2_w', 'unit_of_measurement': , }) # --- @@ -2449,7 +2449,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -2459,7 +2459,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -2511,7 +2511,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l3_w', 'unit_of_measurement': , }) # --- @@ -2539,7 +2539,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -2549,7 +2549,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -2598,7 +2598,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_var', 'unit_of_measurement': , }) # --- @@ -2626,7 +2626,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -2636,7 +2636,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -2685,7 +2685,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_reactive_power_phase_var', - 'unique_id': 'aabbccddeeff_active_reactive_power_l1_var', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_l1_var', 'unit_of_measurement': , }) # --- @@ -2713,7 +2713,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -2723,7 +2723,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -2772,7 +2772,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_reactive_power_phase_var', - 'unique_id': 'aabbccddeeff_active_reactive_power_l2_var', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_l2_var', 'unit_of_measurement': , }) # --- @@ -2800,7 +2800,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -2810,7 +2810,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -2859,7 +2859,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_reactive_power_phase_var', - 'unique_id': 'aabbccddeeff_active_reactive_power_l3_var', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_l3_var', 'unit_of_measurement': , }) # --- @@ -2887,7 +2887,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -2897,7 +2897,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -2946,7 +2946,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l1_v', 'unit_of_measurement': , }) # --- @@ -2974,7 +2974,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -2984,7 +2984,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -3033,7 +3033,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l2_v', 'unit_of_measurement': , }) # --- @@ -3061,7 +3061,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -3071,7 +3071,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -3120,7 +3120,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l3_v', 'unit_of_measurement': , }) # --- @@ -3148,7 +3148,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -3158,7 +3158,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -3205,7 +3205,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', - 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', 'unit_of_measurement': None, }) # --- @@ -3230,7 +3230,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -3240,7 +3240,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -3289,7 +3289,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', - 'unique_id': 'aabbccddeeff_wifi_strength', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', 'unit_of_measurement': '%', }) # --- @@ -3316,7 +3316,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -3326,7 +3326,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -3373,7 +3373,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_average_w', - 'unique_id': 'aabbccddeeff_active_power_average_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_average_w', 'unit_of_measurement': , }) # --- @@ -3400,7 +3400,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -3410,7 +3410,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -3459,7 +3459,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l1_a', 'unit_of_measurement': , }) # --- @@ -3487,7 +3487,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -3497,7 +3497,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -3546,7 +3546,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l2_a', 'unit_of_measurement': , }) # --- @@ -3574,7 +3574,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -3584,7 +3584,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -3633,7 +3633,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l3_a', 'unit_of_measurement': , }) # --- @@ -3661,7 +3661,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -3671,7 +3671,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -3718,7 +3718,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsmr_version', - 'unique_id': 'aabbccddeeff_smr_version', + 'unique_id': 'HWE-P1_5c2fafabcdef_smr_version', 'unit_of_measurement': None, }) # --- @@ -3743,7 +3743,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -3753,7 +3753,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -3802,7 +3802,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', 'unit_of_measurement': , }) # --- @@ -3830,7 +3830,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -3840,7 +3840,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -3889,7 +3889,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t1_kwh', 'unit_of_measurement': , }) # --- @@ -3917,7 +3917,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -3927,7 +3927,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -3976,7 +3976,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t2_kwh', 'unit_of_measurement': , }) # --- @@ -4004,7 +4004,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -4014,7 +4014,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -4063,7 +4063,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t3_kwh', 'unit_of_measurement': , }) # --- @@ -4091,7 +4091,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -4101,7 +4101,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -4150,7 +4150,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t4_kwh', 'unit_of_measurement': , }) # --- @@ -4178,7 +4178,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -4188,7 +4188,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -4237,7 +4237,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', 'unit_of_measurement': , }) # --- @@ -4265,7 +4265,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -4275,7 +4275,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -4324,7 +4324,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t1_kwh', 'unit_of_measurement': , }) # --- @@ -4352,7 +4352,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -4362,7 +4362,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -4411,7 +4411,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t2_kwh', 'unit_of_measurement': , }) # --- @@ -4439,7 +4439,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -4449,7 +4449,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -4498,7 +4498,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t3_kwh', 'unit_of_measurement': , }) # --- @@ -4526,7 +4526,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -4536,7 +4536,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -4585,7 +4585,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t4_kwh', 'unit_of_measurement': , }) # --- @@ -4613,7 +4613,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -4623,7 +4623,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -4672,7 +4672,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', 'unit_of_measurement': , }) # --- @@ -4700,7 +4700,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -4710,7 +4710,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -4757,7 +4757,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'long_power_fail_count', - 'unique_id': 'aabbccddeeff_long_power_fail_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_long_power_fail_count', 'unit_of_measurement': None, }) # --- @@ -4782,7 +4782,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -4792,7 +4792,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -4839,7 +4839,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'monthly_power_peak_w', - 'unique_id': 'aabbccddeeff_monthly_power_peak_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_monthly_power_peak_w', 'unit_of_measurement': , }) # --- @@ -4866,7 +4866,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -4876,7 +4876,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -4928,7 +4928,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_power_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', 'unit_of_measurement': , }) # --- @@ -4956,7 +4956,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -4966,7 +4966,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -5013,7 +5013,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'any_power_fail_count', - 'unique_id': 'aabbccddeeff_any_power_fail_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_any_power_fail_count', 'unit_of_measurement': None, }) # --- @@ -5038,7 +5038,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -5048,7 +5048,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -5100,7 +5100,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', 'unit_of_measurement': , }) # --- @@ -5128,7 +5128,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -5138,7 +5138,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -5190,7 +5190,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l2_w', 'unit_of_measurement': , }) # --- @@ -5218,7 +5218,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -5228,7 +5228,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -5280,7 +5280,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l3_w', 'unit_of_measurement': , }) # --- @@ -5308,7 +5308,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -5318,7 +5318,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -5365,7 +5365,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'unique_meter_id', - 'unique_id': 'aabbccddeeff_unique_meter_id', + 'unique_id': 'HWE-P1_5c2fafabcdef_unique_meter_id', 'unit_of_measurement': None, }) # --- @@ -5390,7 +5390,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -5400,7 +5400,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -5447,7 +5447,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'meter_model', - 'unique_id': 'aabbccddeeff_meter_model', + 'unique_id': 'HWE-P1_5c2fafabcdef_meter_model', 'unit_of_measurement': None, }) # --- @@ -5472,7 +5472,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -5482,7 +5482,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -5536,7 +5536,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_tariff', - 'unique_id': 'aabbccddeeff_active_tariff', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_tariff', 'unit_of_measurement': None, }) # --- @@ -5568,7 +5568,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -5578,7 +5578,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -5627,7 +5627,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_liter_m3', - 'unique_id': 'aabbccddeeff_total_liter_m3', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_liter_m3', 'unit_of_measurement': , }) # --- @@ -5655,7 +5655,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -5665,7 +5665,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -5714,7 +5714,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l1_v', 'unit_of_measurement': , }) # --- @@ -5742,7 +5742,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -5752,7 +5752,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -5801,7 +5801,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l2_v', 'unit_of_measurement': , }) # --- @@ -5829,7 +5829,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -5839,7 +5839,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -5888,7 +5888,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l3_v', 'unit_of_measurement': , }) # --- @@ -5916,7 +5916,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -5926,7 +5926,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -5973,7 +5973,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', - 'unique_id': 'aabbccddeeff_voltage_sag_l1_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l1_count', 'unit_of_measurement': None, }) # --- @@ -5998,7 +5998,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -6008,7 +6008,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -6055,7 +6055,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', - 'unique_id': 'aabbccddeeff_voltage_sag_l2_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l2_count', 'unit_of_measurement': None, }) # --- @@ -6080,7 +6080,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -6090,7 +6090,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -6137,7 +6137,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', - 'unique_id': 'aabbccddeeff_voltage_sag_l3_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l3_count', 'unit_of_measurement': None, }) # --- @@ -6162,7 +6162,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -6172,7 +6172,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -6219,7 +6219,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', - 'unique_id': 'aabbccddeeff_voltage_swell_l1_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l1_count', 'unit_of_measurement': None, }) # --- @@ -6244,7 +6244,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -6254,7 +6254,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -6301,7 +6301,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', - 'unique_id': 'aabbccddeeff_voltage_swell_l2_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l2_count', 'unit_of_measurement': None, }) # --- @@ -6326,7 +6326,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -6336,7 +6336,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -6383,7 +6383,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', - 'unique_id': 'aabbccddeeff_voltage_swell_l3_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l3_count', 'unit_of_measurement': None, }) # --- @@ -6408,7 +6408,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -6418,7 +6418,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -6467,7 +6467,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_liter_lpm', - 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', 'unit_of_measurement': 'l/min', }) # --- @@ -6494,7 +6494,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -6504,7 +6504,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -6551,7 +6551,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', - 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', 'unit_of_measurement': None, }) # --- @@ -6576,7 +6576,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -6586,7 +6586,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -6635,7 +6635,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', - 'unique_id': 'aabbccddeeff_wifi_strength', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', 'unit_of_measurement': '%', }) # --- @@ -7076,7 +7076,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -7086,7 +7086,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -7133,7 +7133,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_average_w', - 'unique_id': 'aabbccddeeff_active_power_average_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_average_w', 'unit_of_measurement': , }) # --- @@ -7160,7 +7160,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -7170,7 +7170,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -7219,7 +7219,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l1_a', 'unit_of_measurement': , }) # --- @@ -7247,7 +7247,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -7257,7 +7257,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -7306,7 +7306,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l2_a', 'unit_of_measurement': , }) # --- @@ -7334,7 +7334,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -7344,7 +7344,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -7393,7 +7393,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l3_a', 'unit_of_measurement': , }) # --- @@ -7421,7 +7421,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -7431,7 +7431,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -7478,7 +7478,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsmr_version', - 'unique_id': 'aabbccddeeff_smr_version', + 'unique_id': 'HWE-P1_5c2fafabcdef_smr_version', 'unit_of_measurement': None, }) # --- @@ -7503,7 +7503,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -7513,7 +7513,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -7562,7 +7562,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', 'unit_of_measurement': , }) # --- @@ -7590,7 +7590,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -7600,7 +7600,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -7649,7 +7649,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t1_kwh', 'unit_of_measurement': , }) # --- @@ -7677,7 +7677,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -7687,7 +7687,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -7736,7 +7736,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t2_kwh', 'unit_of_measurement': , }) # --- @@ -7764,7 +7764,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -7774,7 +7774,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -7823,7 +7823,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t3_kwh', 'unit_of_measurement': , }) # --- @@ -7851,7 +7851,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -7861,7 +7861,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -7910,7 +7910,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t4_kwh', 'unit_of_measurement': , }) # --- @@ -7938,7 +7938,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -7948,7 +7948,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -7997,7 +7997,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', 'unit_of_measurement': , }) # --- @@ -8025,7 +8025,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -8035,7 +8035,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -8084,7 +8084,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t1_kwh', 'unit_of_measurement': , }) # --- @@ -8112,7 +8112,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -8122,7 +8122,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -8171,7 +8171,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t2_kwh', 'unit_of_measurement': , }) # --- @@ -8199,7 +8199,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -8209,7 +8209,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -8258,7 +8258,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t3_kwh', 'unit_of_measurement': , }) # --- @@ -8286,7 +8286,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -8296,7 +8296,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -8345,7 +8345,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t4_kwh', 'unit_of_measurement': , }) # --- @@ -8373,7 +8373,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -8383,7 +8383,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -8432,7 +8432,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', 'unit_of_measurement': , }) # --- @@ -8460,7 +8460,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -8470,7 +8470,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -8517,7 +8517,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'long_power_fail_count', - 'unique_id': 'aabbccddeeff_long_power_fail_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_long_power_fail_count', 'unit_of_measurement': None, }) # --- @@ -8542,7 +8542,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -8552,7 +8552,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -8599,7 +8599,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'monthly_power_peak_w', - 'unique_id': 'aabbccddeeff_monthly_power_peak_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_monthly_power_peak_w', 'unit_of_measurement': , }) # --- @@ -8626,7 +8626,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -8636,7 +8636,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -8688,7 +8688,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_power_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', 'unit_of_measurement': , }) # --- @@ -8716,7 +8716,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -8726,7 +8726,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -8773,7 +8773,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'any_power_fail_count', - 'unique_id': 'aabbccddeeff_any_power_fail_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_any_power_fail_count', 'unit_of_measurement': None, }) # --- @@ -8798,7 +8798,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -8808,7 +8808,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -8860,7 +8860,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', 'unit_of_measurement': , }) # --- @@ -8888,7 +8888,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -8898,7 +8898,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -8950,7 +8950,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l2_w', 'unit_of_measurement': , }) # --- @@ -8978,7 +8978,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -8988,7 +8988,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -9040,7 +9040,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l3_w', 'unit_of_measurement': , }) # --- @@ -9068,7 +9068,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -9078,7 +9078,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -9125,7 +9125,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'unique_meter_id', - 'unique_id': 'aabbccddeeff_unique_meter_id', + 'unique_id': 'HWE-P1_5c2fafabcdef_unique_meter_id', 'unit_of_measurement': None, }) # --- @@ -9150,7 +9150,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -9160,7 +9160,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -9207,7 +9207,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'meter_model', - 'unique_id': 'aabbccddeeff_meter_model', + 'unique_id': 'HWE-P1_5c2fafabcdef_meter_model', 'unit_of_measurement': None, }) # --- @@ -9232,7 +9232,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -9242,7 +9242,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -9296,7 +9296,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_tariff', - 'unique_id': 'aabbccddeeff_active_tariff', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_tariff', 'unit_of_measurement': None, }) # --- @@ -9328,7 +9328,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -9338,7 +9338,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -9387,7 +9387,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_liter_m3', - 'unique_id': 'aabbccddeeff_total_liter_m3', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_liter_m3', 'unit_of_measurement': , }) # --- @@ -9415,7 +9415,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -9425,7 +9425,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -9474,7 +9474,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l1_v', 'unit_of_measurement': , }) # --- @@ -9502,7 +9502,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -9512,7 +9512,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -9561,7 +9561,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l2_v', 'unit_of_measurement': , }) # --- @@ -9589,7 +9589,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -9599,7 +9599,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -9648,7 +9648,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l3_v', 'unit_of_measurement': , }) # --- @@ -9676,7 +9676,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -9686,7 +9686,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -9733,7 +9733,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', - 'unique_id': 'aabbccddeeff_voltage_sag_l1_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l1_count', 'unit_of_measurement': None, }) # --- @@ -9758,7 +9758,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -9768,7 +9768,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -9815,7 +9815,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', - 'unique_id': 'aabbccddeeff_voltage_sag_l2_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l2_count', 'unit_of_measurement': None, }) # --- @@ -9840,7 +9840,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -9850,7 +9850,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -9897,7 +9897,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', - 'unique_id': 'aabbccddeeff_voltage_sag_l3_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l3_count', 'unit_of_measurement': None, }) # --- @@ -9922,7 +9922,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -9932,7 +9932,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -9979,7 +9979,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', - 'unique_id': 'aabbccddeeff_voltage_swell_l1_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l1_count', 'unit_of_measurement': None, }) # --- @@ -10004,7 +10004,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -10014,7 +10014,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -10061,7 +10061,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', - 'unique_id': 'aabbccddeeff_voltage_swell_l2_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l2_count', 'unit_of_measurement': None, }) # --- @@ -10086,7 +10086,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -10096,7 +10096,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -10143,7 +10143,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', - 'unique_id': 'aabbccddeeff_voltage_swell_l3_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l3_count', 'unit_of_measurement': None, }) # --- @@ -10168,7 +10168,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -10178,7 +10178,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -10227,7 +10227,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_liter_lpm', - 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', 'unit_of_measurement': 'l/min', }) # --- @@ -10254,7 +10254,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -10264,7 +10264,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -10311,7 +10311,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', - 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', 'unit_of_measurement': None, }) # --- @@ -10336,7 +10336,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -10346,7 +10346,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -10395,7 +10395,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', - 'unique_id': 'aabbccddeeff_wifi_strength', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', 'unit_of_measurement': '%', }) # --- @@ -10836,7 +10836,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -10846,7 +10846,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -10893,7 +10893,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_average_w', - 'unique_id': 'aabbccddeeff_active_power_average_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_average_w', 'unit_of_measurement': , }) # --- @@ -10920,7 +10920,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -10930,7 +10930,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -10979,7 +10979,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l1_a', 'unit_of_measurement': , }) # --- @@ -11007,7 +11007,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -11017,7 +11017,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -11066,7 +11066,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l2_a', 'unit_of_measurement': , }) # --- @@ -11094,7 +11094,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -11104,7 +11104,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -11153,7 +11153,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l3_a', 'unit_of_measurement': , }) # --- @@ -11181,7 +11181,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -11191,7 +11191,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -11240,7 +11240,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', 'unit_of_measurement': , }) # --- @@ -11268,7 +11268,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -11278,7 +11278,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -11327,7 +11327,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t1_kwh', 'unit_of_measurement': , }) # --- @@ -11355,7 +11355,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -11365,7 +11365,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -11414,7 +11414,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t2_kwh', 'unit_of_measurement': , }) # --- @@ -11442,7 +11442,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -11452,7 +11452,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -11501,7 +11501,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t3_kwh', 'unit_of_measurement': , }) # --- @@ -11529,7 +11529,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -11539,7 +11539,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -11588,7 +11588,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_t4_kwh', 'unit_of_measurement': , }) # --- @@ -11616,7 +11616,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -11626,7 +11626,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -11675,7 +11675,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', 'unit_of_measurement': , }) # --- @@ -11703,7 +11703,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -11713,7 +11713,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -11762,7 +11762,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t1_kwh', 'unit_of_measurement': , }) # --- @@ -11790,7 +11790,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -11800,7 +11800,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -11849,7 +11849,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t2_kwh', 'unit_of_measurement': , }) # --- @@ -11877,7 +11877,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -11887,7 +11887,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -11936,7 +11936,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t3_kwh', 'unit_of_measurement': , }) # --- @@ -11964,7 +11964,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -11974,7 +11974,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -12023,7 +12023,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_tariff_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_t4_kwh', 'unit_of_measurement': , }) # --- @@ -12051,7 +12051,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -12061,7 +12061,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -12110,7 +12110,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', 'unit_of_measurement': , }) # --- @@ -12138,7 +12138,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -12148,7 +12148,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -12195,7 +12195,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'long_power_fail_count', - 'unique_id': 'aabbccddeeff_long_power_fail_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_long_power_fail_count', 'unit_of_measurement': None, }) # --- @@ -12220,7 +12220,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -12230,7 +12230,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -12282,7 +12282,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_power_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', 'unit_of_measurement': , }) # --- @@ -12310,7 +12310,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -12320,7 +12320,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -12367,7 +12367,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'any_power_fail_count', - 'unique_id': 'aabbccddeeff_any_power_fail_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_any_power_fail_count', 'unit_of_measurement': None, }) # --- @@ -12392,7 +12392,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -12402,7 +12402,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -12454,7 +12454,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', 'unit_of_measurement': , }) # --- @@ -12482,7 +12482,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -12492,7 +12492,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -12544,7 +12544,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l2_w', 'unit_of_measurement': , }) # --- @@ -12572,7 +12572,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -12582,7 +12582,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -12634,7 +12634,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l3_w', 'unit_of_measurement': , }) # --- @@ -12662,7 +12662,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -12672,7 +12672,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -12721,7 +12721,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_liter_m3', - 'unique_id': 'aabbccddeeff_total_liter_m3', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_liter_m3', 'unit_of_measurement': , }) # --- @@ -12749,7 +12749,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -12759,7 +12759,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -12808,7 +12808,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l1_v', 'unit_of_measurement': , }) # --- @@ -12836,7 +12836,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -12846,7 +12846,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -12895,7 +12895,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l2_v', 'unit_of_measurement': , }) # --- @@ -12923,7 +12923,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -12933,7 +12933,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -12982,7 +12982,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l3_v', 'unit_of_measurement': , }) # --- @@ -13010,7 +13010,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -13020,7 +13020,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -13067,7 +13067,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', - 'unique_id': 'aabbccddeeff_voltage_sag_l1_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l1_count', 'unit_of_measurement': None, }) # --- @@ -13092,7 +13092,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -13102,7 +13102,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -13149,7 +13149,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', - 'unique_id': 'aabbccddeeff_voltage_sag_l2_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l2_count', 'unit_of_measurement': None, }) # --- @@ -13174,7 +13174,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -13184,7 +13184,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -13231,7 +13231,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_sag_phase_count', - 'unique_id': 'aabbccddeeff_voltage_sag_l3_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_sag_l3_count', 'unit_of_measurement': None, }) # --- @@ -13256,7 +13256,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -13266,7 +13266,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -13313,7 +13313,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', - 'unique_id': 'aabbccddeeff_voltage_swell_l1_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l1_count', 'unit_of_measurement': None, }) # --- @@ -13338,7 +13338,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -13348,7 +13348,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -13395,7 +13395,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', - 'unique_id': 'aabbccddeeff_voltage_swell_l2_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l2_count', 'unit_of_measurement': None, }) # --- @@ -13420,7 +13420,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -13430,7 +13430,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -13477,7 +13477,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_swell_phase_count', - 'unique_id': 'aabbccddeeff_voltage_swell_l3_count', + 'unique_id': 'HWE-P1_5c2fafabcdef_voltage_swell_l3_count', 'unit_of_measurement': None, }) # --- @@ -13502,7 +13502,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -13512,7 +13512,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -13561,7 +13561,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_liter_lpm', - 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', 'unit_of_measurement': 'l/min', }) # --- @@ -13588,7 +13588,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -13598,7 +13598,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -13647,7 +13647,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', 'unit_of_measurement': , }) # --- @@ -13675,7 +13675,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -13685,7 +13685,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -13734,7 +13734,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', 'unit_of_measurement': , }) # --- @@ -13762,7 +13762,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -13772,7 +13772,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -13824,7 +13824,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_power_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', 'unit_of_measurement': , }) # --- @@ -13852,7 +13852,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -13862,7 +13862,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -13914,7 +13914,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', 'unit_of_measurement': , }) # --- @@ -13942,7 +13942,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -13952,7 +13952,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -13999,7 +13999,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', - 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', 'unit_of_measurement': None, }) # --- @@ -14024,7 +14024,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -14034,7 +14034,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -14083,7 +14083,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', - 'unique_id': 'aabbccddeeff_wifi_strength', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', 'unit_of_measurement': '%', }) # --- @@ -14110,7 +14110,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -14120,7 +14120,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -14169,7 +14169,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_va', 'unit_of_measurement': , }) # --- @@ -14197,7 +14197,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -14207,7 +14207,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -14256,7 +14256,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_current_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', 'unit_of_measurement': , }) # --- @@ -14284,7 +14284,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -14294,7 +14294,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -14343,7 +14343,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', 'unit_of_measurement': , }) # --- @@ -14371,7 +14371,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -14381,7 +14381,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -14430,7 +14430,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', 'unit_of_measurement': , }) # --- @@ -14458,7 +14458,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -14468,7 +14468,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -14517,7 +14517,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', 'unit_of_measurement': , }) # --- @@ -14545,7 +14545,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -14555,7 +14555,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -14607,7 +14607,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_power_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', 'unit_of_measurement': , }) # --- @@ -14635,7 +14635,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -14645,7 +14645,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -14694,7 +14694,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_power_factor', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor', 'unit_of_measurement': '%', }) # --- @@ -14722,7 +14722,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -14732,7 +14732,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -14784,7 +14784,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', 'unit_of_measurement': , }) # --- @@ -14812,7 +14812,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -14822,7 +14822,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -14871,7 +14871,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_var', 'unit_of_measurement': , }) # --- @@ -14899,7 +14899,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -14909,7 +14909,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -14958,7 +14958,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_voltage_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_v', 'unit_of_measurement': , }) # --- @@ -14986,7 +14986,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -14996,7 +14996,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -15043,7 +15043,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', - 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', 'unit_of_measurement': None, }) # --- @@ -15068,7 +15068,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -15078,7 +15078,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -15127,7 +15127,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', - 'unique_id': 'aabbccddeeff_wifi_strength', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', 'unit_of_measurement': '%', }) # --- @@ -15154,7 +15154,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -15164,7 +15164,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -15213,7 +15213,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_liter_m3', - 'unique_id': 'aabbccddeeff_total_liter_m3', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_liter_m3', 'unit_of_measurement': , }) # --- @@ -15241,7 +15241,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -15251,7 +15251,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -15300,7 +15300,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_liter_lpm', - 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', 'unit_of_measurement': 'l/min', }) # --- @@ -15327,7 +15327,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -15337,7 +15337,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -15384,7 +15384,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', - 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', 'unit_of_measurement': None, }) # --- @@ -15409,7 +15409,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -15419,7 +15419,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -15468,7 +15468,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', - 'unique_id': 'aabbccddeeff_wifi_strength', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', 'unit_of_measurement': '%', }) # --- @@ -15495,7 +15495,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -15505,7 +15505,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -15554,7 +15554,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_va', 'unit_of_measurement': , }) # --- @@ -15582,7 +15582,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -15592,7 +15592,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -15641,7 +15641,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_current_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', 'unit_of_measurement': , }) # --- @@ -15669,7 +15669,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -15679,7 +15679,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -15728,7 +15728,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', 'unit_of_measurement': , }) # --- @@ -15756,7 +15756,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -15766,7 +15766,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -15815,7 +15815,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', 'unit_of_measurement': , }) # --- @@ -15843,7 +15843,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -15853,7 +15853,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -15902,7 +15902,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', 'unit_of_measurement': , }) # --- @@ -15930,7 +15930,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -15940,7 +15940,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -15992,7 +15992,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_power_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', 'unit_of_measurement': , }) # --- @@ -16020,7 +16020,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -16030,7 +16030,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -16079,7 +16079,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_power_factor', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor', 'unit_of_measurement': '%', }) # --- @@ -16107,7 +16107,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -16117,7 +16117,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -16166,7 +16166,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_var', 'unit_of_measurement': , }) # --- @@ -16194,7 +16194,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -16204,7 +16204,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -16253,7 +16253,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_voltage_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_v', 'unit_of_measurement': , }) # --- @@ -16281,7 +16281,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -16291,7 +16291,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -16338,7 +16338,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', - 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', 'unit_of_measurement': None, }) # --- @@ -16363,7 +16363,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -16373,7 +16373,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -16422,7 +16422,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', - 'unique_id': 'aabbccddeeff_wifi_strength', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', 'unit_of_measurement': '%', }) # --- @@ -16449,7 +16449,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -16459,7 +16459,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -16508,7 +16508,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_va', 'unit_of_measurement': , }) # --- @@ -16536,7 +16536,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -16546,7 +16546,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -16595,7 +16595,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_apparent_power_phase_va', - 'unique_id': 'aabbccddeeff_active_apparent_power_l1_va', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_l1_va', 'unit_of_measurement': , }) # --- @@ -16623,7 +16623,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -16633,7 +16633,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -16682,7 +16682,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_apparent_power_phase_va', - 'unique_id': 'aabbccddeeff_active_apparent_power_l2_va', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_l2_va', 'unit_of_measurement': , }) # --- @@ -16710,7 +16710,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -16720,7 +16720,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -16769,7 +16769,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_apparent_power_phase_va', - 'unique_id': 'aabbccddeeff_active_apparent_power_l3_va', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_apparent_power_l3_va', 'unit_of_measurement': , }) # --- @@ -16797,7 +16797,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -16807,7 +16807,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -16856,7 +16856,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_current_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', 'unit_of_measurement': , }) # --- @@ -16884,7 +16884,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -16894,7 +16894,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -16943,7 +16943,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l1_a', 'unit_of_measurement': , }) # --- @@ -16971,7 +16971,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -16981,7 +16981,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -17030,7 +17030,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l2_a', 'unit_of_measurement': , }) # --- @@ -17058,7 +17058,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -17068,7 +17068,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -17117,7 +17117,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_current_phase_a', - 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_l3_a', 'unit_of_measurement': , }) # --- @@ -17145,7 +17145,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -17155,7 +17155,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -17204,7 +17204,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_export_kwh', - 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', 'unit_of_measurement': , }) # --- @@ -17232,7 +17232,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -17242,7 +17242,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -17291,7 +17291,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_energy_import_kwh', - 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', 'unit_of_measurement': , }) # --- @@ -17319,7 +17319,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -17329,7 +17329,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -17378,7 +17378,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', 'unit_of_measurement': , }) # --- @@ -17406,7 +17406,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -17416,7 +17416,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -17468,7 +17468,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_power_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', 'unit_of_measurement': , }) # --- @@ -17496,7 +17496,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -17506,7 +17506,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -17555,7 +17555,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_factor_phase', - 'unique_id': 'aabbccddeeff_active_power_factor_l1', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor_l1', 'unit_of_measurement': '%', }) # --- @@ -17583,7 +17583,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -17593,7 +17593,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -17642,7 +17642,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_factor_phase', - 'unique_id': 'aabbccddeeff_active_power_factor_l2', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor_l2', 'unit_of_measurement': '%', }) # --- @@ -17670,7 +17670,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -17680,7 +17680,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -17729,7 +17729,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_factor_phase', - 'unique_id': 'aabbccddeeff_active_power_factor_l3', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_factor_l3', 'unit_of_measurement': '%', }) # --- @@ -17757,7 +17757,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -17767,7 +17767,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -17819,7 +17819,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l1_w', 'unit_of_measurement': , }) # --- @@ -17847,7 +17847,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -17857,7 +17857,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -17909,7 +17909,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l2_w', 'unit_of_measurement': , }) # --- @@ -17937,7 +17937,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -17947,7 +17947,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -17999,7 +17999,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_power_phase_w', - 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_l3_w', 'unit_of_measurement': , }) # --- @@ -18027,7 +18027,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -18037,7 +18037,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -18086,7 +18086,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_var', 'unit_of_measurement': , }) # --- @@ -18114,7 +18114,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -18124,7 +18124,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -18173,7 +18173,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_reactive_power_phase_var', - 'unique_id': 'aabbccddeeff_active_reactive_power_l1_var', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_l1_var', 'unit_of_measurement': , }) # --- @@ -18201,7 +18201,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -18211,7 +18211,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -18260,7 +18260,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_reactive_power_phase_var', - 'unique_id': 'aabbccddeeff_active_reactive_power_l2_var', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_l2_var', 'unit_of_measurement': , }) # --- @@ -18288,7 +18288,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -18298,7 +18298,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -18347,7 +18347,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_reactive_power_phase_var', - 'unique_id': 'aabbccddeeff_active_reactive_power_l3_var', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_reactive_power_l3_var', 'unit_of_measurement': , }) # --- @@ -18375,7 +18375,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -18385,7 +18385,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -18434,7 +18434,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l1_v', 'unit_of_measurement': , }) # --- @@ -18462,7 +18462,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -18472,7 +18472,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -18521,7 +18521,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l2_v', 'unit_of_measurement': , }) # --- @@ -18549,7 +18549,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -18559,7 +18559,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -18608,7 +18608,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'active_voltage_phase_v', - 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_l3_v', 'unit_of_measurement': , }) # --- @@ -18636,7 +18636,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -18646,7 +18646,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -18693,7 +18693,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_ssid', - 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', 'unit_of_measurement': None, }) # --- @@ -18718,7 +18718,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -18728,7 +18728,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -18777,7 +18777,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wifi_strength', - 'unique_id': 'aabbccddeeff_wifi_strength', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', 'unit_of_measurement': '%', }) # --- diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index 68a351c1ebbd0..c2ef87970f363 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -41,7 +41,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', - 'unique_id': 'aabbccddeeff_cloud_connection', + 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', 'unit_of_measurement': None, }) # --- @@ -53,7 +53,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -63,7 +63,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -123,7 +123,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', - 'unique_id': 'aabbccddeeff_cloud_connection', + 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', 'unit_of_measurement': None, }) # --- @@ -135,7 +135,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -145,7 +145,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -206,7 +206,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_power_on', + 'unique_id': 'HWE-P1_5c2fafabcdef_power_on', 'unit_of_measurement': None, }) # --- @@ -218,7 +218,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -228,7 +228,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -288,7 +288,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', - 'unique_id': 'aabbccddeeff_cloud_connection', + 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', 'unit_of_measurement': None, }) # --- @@ -300,7 +300,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -310,7 +310,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -370,7 +370,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'switch_lock', - 'unique_id': 'aabbccddeeff_switch_lock', + 'unique_id': 'HWE-P1_5c2fafabcdef_switch_lock', 'unit_of_measurement': None, }) # --- @@ -382,7 +382,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -392,7 +392,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -453,7 +453,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aabbccddeeff_power_on', + 'unique_id': 'HWE-P1_5c2fafabcdef_power_on', 'unit_of_measurement': None, }) # --- @@ -465,7 +465,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -475,7 +475,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -535,7 +535,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', - 'unique_id': 'aabbccddeeff_cloud_connection', + 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', 'unit_of_measurement': None, }) # --- @@ -547,7 +547,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -557,7 +557,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -617,7 +617,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'switch_lock', - 'unique_id': 'aabbccddeeff_switch_lock', + 'unique_id': 'HWE-P1_5c2fafabcdef_switch_lock', 'unit_of_measurement': None, }) # --- @@ -629,7 +629,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -639,7 +639,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -699,7 +699,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', - 'unique_id': 'aabbccddeeff_cloud_connection', + 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', 'unit_of_measurement': None, }) # --- @@ -711,7 +711,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -721,7 +721,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -781,7 +781,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', - 'unique_id': 'aabbccddeeff_cloud_connection', + 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', 'unit_of_measurement': None, }) # --- @@ -793,7 +793,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -803,7 +803,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, @@ -863,7 +863,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'cloud_connection', - 'unique_id': 'aabbccddeeff_cloud_connection', + 'unique_id': 'HWE-P1_5c2fafabcdef_cloud_connection', 'unit_of_measurement': None, }) # --- @@ -875,7 +875,7 @@ 'connections': set({ tuple( 'mac', - '3c:39:e7:aa:bb:cc', + '5c:2f:af:ab:cd:ef', ), }), 'disabled_by': None, @@ -885,7 +885,7 @@ 'identifiers': set({ tuple( 'homewizard', - '3c39e7aabbcc', + '5c2fafabcdef', ), }), 'is_new': False, diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 442659f2aad2d..2e408072ca73b 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -66,7 +66,7 @@ async def test_discovery_flow_works( "path": "/api/v1", "product_name": "Energy Socket", "product_type": "HWE-SKT", - "serial": "aabbccddeeff", + "serial": "5c2fafabcdef", }, ), ) @@ -112,7 +112,7 @@ async def test_discovery_flow_during_onboarding( "path": "/api/v1", "product_name": "P1 meter", "product_type": "HWE-P1", - "serial": "aabbccddeeff", + "serial": "5c2fafabcdef", }, ), ) @@ -149,7 +149,7 @@ async def test_discovery_flow_during_onboarding_disabled_api( "path": "/api/v1", "product_name": "P1 meter", "product_type": "HWE-P1", - "serial": "aabbccddeeff", + "serial": "5c2fafabcdef", }, ), ) @@ -193,7 +193,7 @@ async def test_discovery_disabled_api( "path": "/api/v1", "product_name": "P1 meter", "product_type": "HWE-P1", - "serial": "aabbccddeeff", + "serial": "5c2fafabcdef", }, ), ) @@ -228,7 +228,7 @@ async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> No "path": "/api/v1", "product_name": "P1 meter", "product_type": "HWE-P1", - "serial": "aabbccddeeff", + "serial": "5c2fafabcdef", }, ), ) @@ -254,7 +254,7 @@ async def test_discovery_invalid_api(hass: HomeAssistant) -> None: "path": "/api/not_v1", "product_name": "P1 meter", "product_type": "HWE-P1", - "serial": "aabbccddeeff", + "serial": "5c2fafabcdef", }, ), ) From 91e4939bf04501410a6cf5e71ef6f6ed11333544 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 26 Nov 2024 10:00:34 +0100 Subject: [PATCH 0864/1070] Add fingerprint and nfc event support to unifiprotect (#130840) Co-authored-by: J. Nick Koston --- .../components/unifiprotect/const.py | 7 + .../components/unifiprotect/event.py | 148 +++++++++++--- .../components/unifiprotect/strings.json | 22 +++ tests/components/unifiprotect/conftest.py | 2 + tests/components/unifiprotect/test_event.py | 180 +++++++++++++++++- tests/components/unifiprotect/utils.py | 6 +- 6 files changed, 335 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index ad251ba6153a0..7d1e5b55d3f83 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -1,5 +1,7 @@ """Constant definitions for UniFi Protect Integration.""" +from typing import Final + from uiprotect.data import ModelType, Version from homeassistant.const import Platform @@ -75,3 +77,8 @@ DISPATCH_ADD = "add_device" DISPATCH_ADOPT = "adopt_device" DISPATCH_CHANNELS = "new_camera_channels" + +EVENT_TYPE_FINGERPRINT_IDENTIFIED: Final = "identified" +EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED: Final = "not_identified" +EVENT_TYPE_NFC_SCANNED: Final = "scanned" +EVENT_TYPE_DOORBELL_RING: Final = "ring" diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index 8bbe568242b9e..f126920fb189d 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -14,7 +14,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_EVENT_ID +from .const import ( + ATTR_EVENT_ID, + EVENT_TYPE_DOORBELL_RING, + EVENT_TYPE_FINGERPRINT_IDENTIFIED, + EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED, + EVENT_TYPE_NFC_SCANNED, +) from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import EventEntityMixin, ProtectDeviceEntity, ProtectEventMixin @@ -23,22 +29,10 @@ class ProtectEventEntityDescription(ProtectEventMixin, EventEntityDescription): """Describes UniFi Protect event entity.""" - -EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( - ProtectEventEntityDescription( - key="doorbell", - translation_key="doorbell", - name="Doorbell", - device_class=EventDeviceClass.DOORBELL, - icon="mdi:doorbell-video", - ufp_required_field="feature_flags.is_doorbell", - ufp_event_obj="last_ring_event", - event_types=[EventType.RING], - ), -) + entity_class: type[ProtectDeviceEntity] -class ProtectDeviceEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity): +class ProtectDeviceRingEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity): """A UniFi Protect event entity.""" entity_description: ProtectEventEntityDescription @@ -57,26 +51,128 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: if ( event and not self._event_already_ended(prev_event, prev_event_end) - and (event_types := description.event_types) - and (event_type := event.type) in event_types + and event.type is EventType.RING ): - self._trigger_event(event_type, {ATTR_EVENT_ID: event.id}) + self._trigger_event(EVENT_TYPE_DOORBELL_RING, {ATTR_EVENT_ID: event.id}) self.async_write_ha_state() +class ProtectDeviceNFCEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity): + """A UniFi Protect NFC event entity.""" + + entity_description: ProtectEventEntityDescription + + @callback + def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: + description = self.entity_description + + prev_event = self._event + prev_event_end = self._event_end + super()._async_update_device_from_protect(device) + if event := description.get_event_obj(device): + self._event = event + self._event_end = event.end if event else None + + if ( + event + and not self._event_already_ended(prev_event, prev_event_end) + and event.type is EventType.NFC_CARD_SCANNED + ): + event_data = {ATTR_EVENT_ID: event.id} + if event.metadata and event.metadata.nfc and event.metadata.nfc.nfc_id: + event_data["nfc_id"] = event.metadata.nfc.nfc_id + + self._trigger_event(EVENT_TYPE_NFC_SCANNED, event_data) + self.async_write_ha_state() + + +class ProtectDeviceFingerprintEventEntity( + EventEntityMixin, ProtectDeviceEntity, EventEntity +): + """A UniFi Protect fingerprint event entity.""" + + entity_description: ProtectEventEntityDescription + + @callback + def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: + description = self.entity_description + + prev_event = self._event + prev_event_end = self._event_end + super()._async_update_device_from_protect(device) + if event := description.get_event_obj(device): + self._event = event + self._event_end = event.end if event else None + + if ( + event + and not self._event_already_ended(prev_event, prev_event_end) + and event.type is EventType.FINGERPRINT_IDENTIFIED + ): + event_data = {ATTR_EVENT_ID: event.id} + if ( + event.metadata + and event.metadata.fingerprint + and event.metadata.fingerprint.ulp_id + ): + event_data["ulp_id"] = event.metadata.fingerprint.ulp_id + event_identified = EVENT_TYPE_FINGERPRINT_IDENTIFIED + else: + event_data["ulp_id"] = "" + event_identified = EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED + + self._trigger_event(event_identified, event_data) + self.async_write_ha_state() + + +EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( + ProtectEventEntityDescription( + key="doorbell", + translation_key="doorbell", + device_class=EventDeviceClass.DOORBELL, + icon="mdi:doorbell-video", + ufp_required_field="feature_flags.is_doorbell", + ufp_event_obj="last_ring_event", + event_types=[EVENT_TYPE_DOORBELL_RING], + entity_class=ProtectDeviceRingEventEntity, + ), + ProtectEventEntityDescription( + key="nfc", + translation_key="nfc", + device_class=EventDeviceClass.DOORBELL, + icon="mdi:nfc", + ufp_required_field="feature_flags.support_nfc", + ufp_event_obj="last_nfc_card_scanned_event", + event_types=[EVENT_TYPE_NFC_SCANNED], + entity_class=ProtectDeviceNFCEventEntity, + ), + ProtectEventEntityDescription( + key="fingerprint", + translation_key="fingerprint", + device_class=EventDeviceClass.DOORBELL, + icon="mdi:fingerprint", + ufp_required_field="feature_flags.has_fingerprint_sensor", + ufp_event_obj="last_fingerprint_identified_event", + event_types=[ + EVENT_TYPE_FINGERPRINT_IDENTIFIED, + EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED, + ], + entity_class=ProtectDeviceFingerprintEventEntity, + ), +) + + @callback def _async_event_entities( data: ProtectData, ufp_device: ProtectAdoptableDeviceModel | None = None, ) -> list[ProtectDeviceEntity]: - entities: list[ProtectDeviceEntity] = [] - for device in data.get_cameras() if ufp_device is None else [ufp_device]: - entities.extend( - ProtectDeviceEventEntity(data, device, description) - for description in EVENT_DESCRIPTIONS - if description.has_required(device) - ) - return entities + return [ + description.entity_class(data, device, description) + for device in (data.get_cameras() if ufp_device is None else [ufp_device]) + for description in EVENT_DESCRIPTIONS + if description.has_required(device) + ] async def async_setup_entry( diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 7b2a06dfef72e..8ecb4076409ad 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -137,6 +137,7 @@ }, "event": { "doorbell": { + "name": "Doorbell", "state_attributes": { "event_type": { "state": { @@ -144,6 +145,27 @@ } } } + }, + "nfc": { + "name": "NFC", + "state_attributes": { + "event_type": { + "state": { + "scanned": "Scanned" + } + } + } + }, + "fingerprint": { + "name": "Fingerprint", + "state_attributes": { + "event_type": { + "state": { + "identified": "Identified", + "not_identified": "Not identified" + } + } + } } } }, diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 0bef1ff0eb953..fad65c095dfee 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -233,6 +233,8 @@ def doorbell_fixture(camera: Camera, fixed_now: datetime): doorbell.feature_flags.has_speaker = True doorbell.feature_flags.has_privacy_mask = True doorbell.feature_flags.is_doorbell = True + doorbell.feature_flags.has_fingerprint_sensor = True + doorbell.feature_flags.support_nfc = True doorbell.feature_flags.has_chime = True doorbell.feature_flags.has_smart_detect = True doorbell.feature_flags.has_package_camera = True diff --git a/tests/components/unifiprotect/test_event.py b/tests/components/unifiprotect/test_event.py index 9d1a701fe39d8..cc2195c1dba01 100644 --- a/tests/components/unifiprotect/test_event.py +++ b/tests/components/unifiprotect/test_event.py @@ -33,11 +33,11 @@ async def test_camera_remove( ufp.api.bootstrap.nvr.system_info.ustorage = None await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 1, 1) + assert_entity_counts(hass, Platform.EVENT, 3, 3) await remove_entities(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.EVENT, 0, 0) await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 1, 1) + assert_entity_counts(hass, Platform.EVENT, 3, 3) async def test_doorbell_ring( @@ -50,7 +50,7 @@ async def test_doorbell_ring( """Test a doorbell ring event.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 1, 1) + assert_entity_counts(hass, Platform.EVENT, 3, 3) events: list[HAEvent] = [] @callback @@ -152,3 +152,177 @@ def _capture_event(event: HAEvent) -> None: assert state assert state.state == timestamp unsub() + + +async def test_doorbell_nfc_scanned( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test a doorbell NFC scanned event.""" + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.EVENT, 3, 3) + events: list[HAEvent] = [] + + @callback + def _capture_event(event: HAEvent) -> None: + events.append(event) + + _, entity_id = ids_from_device_description( + Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + ) + + unsub = async_track_state_change_event(hass, entity_id, _capture_event) + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.NFC_CARD_SCANNED, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + metadata={"nfc": {"nfc_id": "test_nfc_id", "user_id": "test_user_id"}}, + ) + + new_camera = doorbell.copy() + new_camera.last_nfc_card_scanned_event_id = "test_event_id" + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + assert len(events) == 1 + state = events[0].data["new_state"] + assert state + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_ID] == "test_event_id" + assert state.attributes["nfc_id"] == "test_nfc_id" + + unsub() + + +async def test_doorbell_fingerprint_identified( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test a doorbell fingerprint identified event.""" + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.EVENT, 3, 3) + events: list[HAEvent] = [] + + @callback + def _capture_event(event: HAEvent) -> None: + events.append(event) + + _, entity_id = ids_from_device_description( + Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + ) + + unsub = async_track_state_change_event(hass, entity_id, _capture_event) + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.FINGERPRINT_IDENTIFIED, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + metadata={"fingerprint": {"ulp_id": "test_ulp_id"}}, + ) + + new_camera = doorbell.copy() + new_camera.last_fingerprint_identified_event_id = "test_event_id" + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + assert len(events) == 1 + state = events[0].data["new_state"] + assert state + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_ID] == "test_event_id" + assert state.attributes["ulp_id"] == "test_ulp_id" + + unsub() + + +async def test_doorbell_fingerprint_not_identified( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test a doorbell fingerprint identified event.""" + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.EVENT, 3, 3) + events: list[HAEvent] = [] + + @callback + def _capture_event(event: HAEvent) -> None: + events.append(event) + + _, entity_id = ids_from_device_description( + Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + ) + + unsub = async_track_state_change_event(hass, entity_id, _capture_event) + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.FINGERPRINT_IDENTIFIED, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + metadata={"fingerprint": {}}, + ) + + new_camera = doorbell.copy() + new_camera.last_fingerprint_identified_event_id = "test_event_id" + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + assert len(events) == 1 + state = events[0].data["new_state"] + assert state + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_ID] == "test_event_id" + assert state.attributes["ulp_id"] == "" + + unsub() diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 25a9ddcbb922c..5a1ffa8258e83 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -109,7 +109,11 @@ def ids_from_device_description( """Return expected unique_id and entity_id for a give platform/device/description combination.""" entity_name = normalize_name(device.display_name) - description_entity_name = normalize_name(str(description.name)) + + if description.name and isinstance(description.name, str): + description_entity_name = normalize_name(description.name) + else: + description_entity_name = normalize_name(description.key) unique_id = f"{device.mac}_{description.key}" entity_id = f"{platform.value}.{entity_name}_{description_entity_name}" From bee34fe954896eac2ae277b934719969e4aa29fc Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 26 Nov 2024 10:02:23 +0100 Subject: [PATCH 0865/1070] Set PARALLEL_UPDATES in remaining HomeWizard platforms (#131316) --- homeassistant/components/homewizard/button.py | 2 ++ homeassistant/components/homewizard/number.py | 2 ++ homeassistant/components/homewizard/quality_scale.yaml | 2 +- homeassistant/components/homewizard/switch.py | 2 ++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index a9cc19d72a76f..7b05cb95271b4 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -10,6 +10,8 @@ from .entity import HomeWizardEntity from .helpers import homewizard_exception_handler +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 1af77859a0f8b..1b4a0643dbe6b 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -13,6 +13,8 @@ from .entity import HomeWizardEntity from .helpers import homewizard_exception_handler +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml index 281157465fcb8..1dbdba8212d79 100644 --- a/homeassistant/components/homewizard/quality_scale.yaml +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -39,7 +39,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: done diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 36cca4663698a..aa0af17f5787b 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -23,6 +23,8 @@ from .entity import HomeWizardEntity from .helpers import homewizard_exception_handler +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class HomeWizardSwitchEntityDescription(SwitchEntityDescription): From bd69af55003fd56cf48ab1140f7724c4aa50ee3c Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 26 Nov 2024 10:07:35 +0100 Subject: [PATCH 0866/1070] Add and improve descriptions in ista EcoTrand config flow (#131566) --- homeassistant/components/ista_ecotrend/strings.json | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ista_ecotrend/strings.json b/homeassistant/components/ista_ecotrend/strings.json index f76cf5286cbf0..0757977a8ea45 100644 --- a/homeassistant/components/ista_ecotrend/strings.json +++ b/homeassistant/components/ista_ecotrend/strings.json @@ -14,14 +14,23 @@ "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" - } + }, + "data_description": { + "email": "Enter the email address associated with your ista EcoTrend account", + "password": "Enter the password for your ista EcoTrend account" + }, + "description": "Connect your **ista EcoTrend** account to Home Assistant to access your monthly heating and water usage data." }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "Please reenter the password for: {email}", + "description": "Re-enter your password for `{email}` to reconnect your ista EcoTrend account to Home Assistant.", "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::ista_ecotrend::config::step::user::data_description::email%]", + "password": "[%key:component::ista_ecotrend::config::step::user::data_description::password%]" } } } From f6ef2d730b4ef7fb2195b984caa7ef82e5eb2f44 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Tue, 26 Nov 2024 10:11:36 +0100 Subject: [PATCH 0867/1070] Add translation to coordinator exceptions in solarlog (#131523) --- .../components/solarlog/coordinator.py | 30 +++++++++++++++---- .../components/solarlog/strings.json | 11 +++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index fe075a209703f..6e8867c0f5225 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -83,15 +83,27 @@ async def _async_update_data(self) -> SolarlogData: await self.solarlog.update_device_list() data.inverter_data = await self.solarlog.update_inverter_data() except SolarLogConnectionError as ex: - raise ConfigEntryNotReady(ex) from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) from ex except SolarLogAuthenticationError as ex: if await self.renew_authentication(): # login was successful, update availability of extended data, retry data update await self.solarlog.test_extended_data_available() - raise ConfigEntryNotReady from ex - raise ConfigEntryAuthFailed from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) from ex + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from ex except SolarLogUpdateError as ex: - raise UpdateFailed(ex) from ex + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from ex _LOGGER.debug("Data successfully updated") @@ -150,9 +162,15 @@ async def renew_authentication(self) -> bool: try: logged_in = await self.solarlog.login() except SolarLogAuthenticationError as ex: - raise ConfigEntryAuthFailed from ex + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from ex except (SolarLogConnectionError, SolarLogUpdateError) as ex: - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) from ex _LOGGER.debug("Credentials successfully updated? %s", logged_in) diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index 723af6cb277f9..fb724c02adb61 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -121,5 +121,16 @@ "name": "Usage" } } + }, + "exceptions": { + "update_error": { + "message": "Error while updating data from the API." + }, + "config_entry_not_ready": { + "message": "Error while loading the config entry." + }, + "auth_failed": { + "message": "Error while logging in to the API." + } } } From 2d8b595b955a49c0f0af2da018ae7345e4c209d2 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Tue, 26 Nov 2024 10:25:37 +0100 Subject: [PATCH 0868/1070] Set PARALLEL_UPDATES for lamarzocco and UpdateFailed translation (#131099) --- homeassistant/components/lamarzocco/button.py | 1 + homeassistant/components/lamarzocco/coordinator.py | 4 +++- homeassistant/components/lamarzocco/number.py | 2 ++ homeassistant/components/lamarzocco/quality_scale.yaml | 4 ++-- homeassistant/components/lamarzocco/select.py | 2 ++ homeassistant/components/lamarzocco/strings.json | 3 +++ homeassistant/components/lamarzocco/switch.py | 2 ++ homeassistant/components/lamarzocco/update.py | 2 ++ 8 files changed, 17 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py index ae79e21897ffe..dabf01d817db8 100644 --- a/homeassistant/components/lamarzocco/button.py +++ b/homeassistant/components/lamarzocco/button.py @@ -16,6 +16,7 @@ from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +PARALLEL_UPDATES = 1 BACKFLUSH_ENABLED_DURATION = 15 diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index d7539fd9ca43e..46a8e05745ea2 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -133,4 +133,6 @@ async def _async_handle_request[**_P]( ) from ex except RequestNotSuccessful as ex: _LOGGER.debug(ex, exc_info=True) - raise UpdateFailed(f"Querying API failed. Error: {ex}") from ex + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="api_error" + ) from ex diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 825c5d6deb074..f32607fd73b2f 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -35,6 +35,8 @@ from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class LaMarzoccoNumberEntityDescription( diff --git a/homeassistant/components/lamarzocco/quality_scale.yaml b/homeassistant/components/lamarzocco/quality_scale.yaml index 59417143c21f3..3cea45539c90f 100644 --- a/homeassistant/components/lamarzocco/quality_scale.yaml +++ b/homeassistant/components/lamarzocco/quality_scale.yaml @@ -42,7 +42,7 @@ rules: status: done comment: | Handled by coordinator. - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: done @@ -69,7 +69,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: done repair-issues: done diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index 1889ba38d6bc7..637ef93597940 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -19,6 +19,8 @@ from .coordinator import LaMarzoccoConfigEntry from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +PARALLEL_UPDATES = 1 + STEAM_LEVEL_HA_TO_LM = { "1": SteamLevel.LEVEL_1, "2": SteamLevel.LEVEL_2, diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index a9784eadf9ae4..f98d5c2a700ed 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -199,6 +199,9 @@ } }, "exceptions": { + "api_error": { + "message": "Error while communicating with the API" + }, "authentication_failed": { "message": "Authentication failed" }, diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index 9f2598affaefa..4dc701c4c29c5 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -19,6 +19,8 @@ from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator from .entity import LaMarzoccoBaseEntity, LaMarzoccoEntity, LaMarzoccoEntityDescription +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class LaMarzoccoSwitchEntityDescription( diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py index 371ff679bae0d..ca18290904277 100644 --- a/homeassistant/components/lamarzocco/update.py +++ b/homeassistant/components/lamarzocco/update.py @@ -21,6 +21,8 @@ from .coordinator import LaMarzoccoConfigEntry from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class LaMarzoccoUpdateEntityDescription( From 245c785a5cb64301fc9b27d3996aac1ac2a47fb9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 26 Nov 2024 10:25:56 +0100 Subject: [PATCH 0869/1070] Update two strings for creating a Utility meter Helper (#131196) --- homeassistant/components/utility_meter/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json index e05789aece16f..4a8ae415a835e 100644 --- a/homeassistant/components/utility_meter/strings.json +++ b/homeassistant/components/utility_meter/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Add Utility Meter", + "title": "Create Utility Meter", "description": "Create a sensor which tracks consumption of various utilities (e.g., energy, gas, water, heating) over a configured period of time, typically monthly. The utility meter sensor optionally supports splitting the consumption by tariffs, in that case one sensor for each tariff is created as well as a select entity to choose the current tariff.", "data": { "always_available": "Sensor always available", From 6b28748d60a79f6f2cec6926ec880c84f8161b8a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 26 Nov 2024 01:26:16 -0800 Subject: [PATCH 0870/1070] Update rainbird to meet the runtime-data quality scale check (#131391) --- homeassistant/components/rainbird/__init__.py | 39 +++++++++++++------ .../components/rainbird/binary_sensor.py | 7 ++-- homeassistant/components/rainbird/calendar.py | 7 ++-- .../components/rainbird/coordinator.py | 34 ---------------- homeassistant/components/rainbird/number.py | 7 ++-- .../components/rainbird/quality_scale.yaml | 6 +-- homeassistant/components/rainbird/sensor.py | 7 ++-- homeassistant/components/rainbird/switch.py | 6 +-- homeassistant/components/rainbird/types.py | 26 +++++++++++++ 9 files changed, 70 insertions(+), 69 deletions(-) create mode 100644 homeassistant/components/rainbird/types.py diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 737b8a0341db0..97dec9a681e55 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -23,7 +23,12 @@ from homeassistant.helpers.device_registry import format_mac from .const import CONF_SERIAL_NUMBER -from .coordinator import RainbirdData, async_create_clientsession +from .coordinator import ( + RainbirdScheduleUpdateCoordinator, + RainbirdUpdateCoordinator, + async_create_clientsession, +) +from .types import RainbirdConfigEntry, RainbirdData _LOGGER = logging.getLogger(__name__) @@ -40,7 +45,9 @@ def _async_register_clientsession_shutdown( - hass: HomeAssistant, entry: ConfigEntry, clientsession: aiohttp.ClientSession + hass: HomeAssistant, + entry: ConfigEntry, + clientsession: aiohttp.ClientSession, ) -> None: """Register cleanup hooks for the clientsession.""" @@ -55,7 +62,7 @@ async def _async_close_websession(*_: Any) -> None: entry.async_on_unload(_async_close_websession) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> bool: """Set up the config entry for Rain Bird.""" hass.data.setdefault(DOMAIN, {}) @@ -96,11 +103,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except RainbirdApiException as err: raise ConfigEntryNotReady from err - data = RainbirdData(hass, entry, controller, model_info) + data = RainbirdData( + controller, + model_info, + coordinator=RainbirdUpdateCoordinator( + hass, + name=entry.title, + controller=controller, + unique_id=entry.unique_id, + model_info=model_info, + ), + schedule_coordinator=RainbirdScheduleUpdateCoordinator( + hass, + name=f"{entry.title} Schedule", + controller=controller, + ), + ) await data.coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = data - + entry.runtime_data = data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -238,8 +259,4 @@ def _async_fix_device_id( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index d44022b0a2d52..5722b8852dd33 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -8,13 +8,12 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN from .coordinator import RainbirdUpdateCoordinator +from .types import RainbirdConfigEntry _LOGGER = logging.getLogger(__name__) @@ -27,11 +26,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RainbirdConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird binary_sensor.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator + coordinator = config_entry.runtime_data.coordinator async_add_entities([RainBirdSensor(coordinator, RAIN_SENSOR_ENTITY_DESCRIPTION)]) diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py index 42c1cce69d395..160fe70c61e97 100644 --- a/homeassistant/components/rainbird/calendar.py +++ b/homeassistant/components/rainbird/calendar.py @@ -6,7 +6,6 @@ import logging from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -14,19 +13,19 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import DOMAIN from .coordinator import RainbirdScheduleUpdateCoordinator +from .types import RainbirdConfigEntry _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RainbirdConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird irrigation calendar.""" - data = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data if not data.model_info.model_info.max_programs: return diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 2657fd6433ed3..437aa7ddbd407 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -8,7 +8,6 @@ import logging import aiohttp -from propcache import cached_property from pyrainbird.async_client import ( AsyncRainbirdController, RainbirdApiException, @@ -166,36 +165,3 @@ async def _async_update_data(self) -> Schedule: return await self._controller.get_schedule() except RainbirdApiException as err: raise UpdateFailed(f"Error communicating with Device: {err}") from err - - -@dataclass -class RainbirdData: - """Holder for shared integration data. - - The coordinators are lazy since they may only be used by some platforms when needed. - """ - - hass: HomeAssistant - entry: ConfigEntry - controller: AsyncRainbirdController - model_info: ModelAndVersion - - @cached_property - def coordinator(self) -> RainbirdUpdateCoordinator: - """Return RainbirdUpdateCoordinator.""" - return RainbirdUpdateCoordinator( - self.hass, - name=self.entry.title, - controller=self.controller, - unique_id=self.entry.unique_id, - model_info=self.model_info, - ) - - @cached_property - def schedule_coordinator(self) -> RainbirdScheduleUpdateCoordinator: - """Return RainbirdScheduleUpdateCoordinator.""" - return RainbirdScheduleUpdateCoordinator( - self.hass, - name=f"{self.entry.title} Schedule", - controller=self.controller, - ) diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index 507a31e59a404..d8081a796b923 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -7,29 +7,28 @@ from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyException from homeassistant.components.number import NumberEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN from .coordinator import RainbirdUpdateCoordinator +from .types import RainbirdConfigEntry _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RainbirdConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird number platform.""" async_add_entities( [ RainDelayNumber( - hass.data[DOMAIN][config_entry.entry_id].coordinator, + config_entry.runtime_data.coordinator, ) ] ) diff --git a/homeassistant/components/rainbird/quality_scale.yaml b/homeassistant/components/rainbird/quality_scale.yaml index e918bf845ba85..cd000c63fad0e 100644 --- a/homeassistant/components/rainbird/quality_scale.yaml +++ b/homeassistant/components/rainbird/quality_scale.yaml @@ -36,11 +36,7 @@ rules: docs-high-level-description: done config-flow-test-coverage: done docs-actions: done - runtime-data: - status: todo - comment: | - The integration currently stores config entry data in `hass.data` and - needs to be moved to `runtime_data`. + runtime-data: done # Silver log-when-unavailable: todo diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index 649d643a20ccc..4725a33bc9a65 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -5,14 +5,13 @@ import logging from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN from .coordinator import RainbirdUpdateCoordinator +from .types import RainbirdConfigEntry _LOGGER = logging.getLogger(__name__) @@ -25,14 +24,14 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RainbirdConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird sensor.""" async_add_entities( [ RainBirdSensor( - hass.data[DOMAIN][config_entry.entry_id].coordinator, + config_entry.runtime_data.coordinator, RAIN_DELAY_ENTITY_DESCRIPTION, ) ] diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 62a2a7c4a3274..f622a1b9b2cff 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform @@ -19,6 +18,7 @@ from .const import ATTR_DURATION, CONF_IMPORTED_NAMES, DOMAIN, MANUFACTURER from .coordinator import RainbirdUpdateCoordinator +from .types import RainbirdConfigEntry _LOGGER = logging.getLogger(__name__) @@ -31,11 +31,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RainbirdConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird irrigation switches.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator + coordinator = config_entry.runtime_data.coordinator async_add_entities( RainBirdSwitch( coordinator, diff --git a/homeassistant/components/rainbird/types.py b/homeassistant/components/rainbird/types.py new file mode 100644 index 0000000000000..b452712d97133 --- /dev/null +++ b/homeassistant/components/rainbird/types.py @@ -0,0 +1,26 @@ +"""Types for Rain Bird integration.""" + +from dataclasses import dataclass + +from pyrainbird.async_client import AsyncRainbirdController +from pyrainbird.data import ModelAndVersion + +from homeassistant.config_entries import ConfigEntry + +from .coordinator import RainbirdScheduleUpdateCoordinator, RainbirdUpdateCoordinator + + +@dataclass +class RainbirdData: + """Holder for shared integration data. + + The coordinators are lazy since they may only be used by some platforms when needed. + """ + + controller: AsyncRainbirdController + model_info: ModelAndVersion + coordinator: RainbirdUpdateCoordinator + schedule_coordinator: RainbirdScheduleUpdateCoordinator + + +type RainbirdConfigEntry = ConfigEntry[RainbirdData] From f81955ef25a5d29b3a0cc55424745a5fd6ae8ad4 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 26 Nov 2024 10:27:02 +0100 Subject: [PATCH 0871/1070] Add unit translations for Brother integration (#131275) --- homeassistant/components/brother/sensor.py | 16 ------- homeassistant/components/brother/strings.json | 42 ++++++++++++------- .../brother/snapshots/test_sensor.ambr | 42 +++++++------------ 3 files changed, 42 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index e86eb59d6bcdc..d49ebdf07ca3b 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -30,8 +30,6 @@ ATTR_COUNTER = "counter" ATTR_REMAINING_PAGES = "remaining_pages" -UNIT_PAGES = "p" - _LOGGER = logging.getLogger(__name__) @@ -52,7 +50,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="page_counter", translation_key="page_counter", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.page_counter, @@ -60,7 +57,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="bw_counter", translation_key="bw_pages", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.bw_counter, @@ -68,7 +64,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="color_counter", translation_key="color_pages", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.color_counter, @@ -76,7 +71,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="duplex_unit_pages_counter", translation_key="duplex_unit_page_counter", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.duplex_unit_pages_counter, @@ -92,7 +86,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="drum_remaining_pages", translation_key="drum_remaining_pages", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.drum_remaining_pages, @@ -100,7 +93,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="drum_counter", translation_key="drum_page_counter", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.drum_counter, @@ -116,7 +108,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="black_drum_remaining_pages", translation_key="black_drum_remaining_pages", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.black_drum_remaining_pages, @@ -124,7 +115,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="black_drum_counter", translation_key="black_drum_page_counter", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.black_drum_counter, @@ -140,7 +130,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="cyan_drum_remaining_pages", translation_key="cyan_drum_remaining_pages", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.cyan_drum_remaining_pages, @@ -148,7 +137,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="cyan_drum_counter", translation_key="cyan_drum_page_counter", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.cyan_drum_counter, @@ -164,7 +152,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="magenta_drum_remaining_pages", translation_key="magenta_drum_remaining_pages", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.magenta_drum_remaining_pages, @@ -172,7 +159,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="magenta_drum_counter", translation_key="magenta_drum_page_counter", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.magenta_drum_counter, @@ -188,7 +174,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="yellow_drum_remaining_pages", translation_key="yellow_drum_remaining_pages", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.yellow_drum_remaining_pages, @@ -196,7 +181,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="yellow_drum_counter", translation_key="yellow_drum_page_counter", - native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.yellow_drum_counter, diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index 3b5b38ce9a05d..b502ed7e3b953 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -46,61 +46,75 @@ "name": "Status" }, "page_counter": { - "name": "Page counter" + "name": "Page counter", + "unit_of_measurement": "pages" }, "bw_pages": { - "name": "B/W pages" + "name": "B/W pages", + "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" }, "color_pages": { - "name": "Color pages" + "name": "Color pages", + "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" }, "duplex_unit_page_counter": { - "name": "Duplex unit page counter" + "name": "Duplex unit page counter", + "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" }, "drum_remaining_life": { "name": "Drum remaining lifetime" }, "drum_remaining_pages": { - "name": "Drum remaining pages" + "name": "Drum remaining pages", + "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" }, "drum_page_counter": { - "name": "Drum page counter" + "name": "Drum page counter", + "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" }, "black_drum_remaining_life": { "name": "Black drum remaining lifetime" }, "black_drum_remaining_pages": { - "name": "Black drum remaining pages" + "name": "Black drum remaining pages", + "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" }, "black_drum_page_counter": { - "name": "Black drum page counter" + "name": "Black drum page counter", + "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" }, "cyan_drum_remaining_life": { "name": "Cyan drum remaining lifetime" }, "cyan_drum_remaining_pages": { - "name": "Cyan drum remaining pages" + "name": "Cyan drum remaining pages", + "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" }, "cyan_drum_page_counter": { - "name": "Cyan drum page counter" + "name": "Cyan drum page counter", + "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" }, "magenta_drum_remaining_life": { "name": "Magenta drum remaining lifetime" }, "magenta_drum_remaining_pages": { - "name": "Magenta drum remaining pages" + "name": "Magenta drum remaining pages", + "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" }, "magenta_drum_page_counter": { - "name": "Magenta drum page counter" + "name": "Magenta drum page counter", + "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" }, "yellow_drum_remaining_life": { "name": "Yellow drum remaining lifetime" }, "yellow_drum_remaining_pages": { - "name": "Yellow drum remaining pages" + "name": "Yellow drum remaining pages", + "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" }, "yellow_drum_page_counter": { - "name": "Yellow drum page counter" + "name": "Yellow drum page counter", + "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" }, "belt_unit_remaining_life": { "name": "Belt unit remaining lifetime" diff --git a/tests/components/brother/snapshots/test_sensor.ambr b/tests/components/brother/snapshots/test_sensor.ambr index a27c5addd614d..a313e013f4b0a 100644 --- a/tests/components/brother/snapshots/test_sensor.ambr +++ b/tests/components/brother/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'supported_features': 0, 'translation_key': 'bw_pages', 'unique_id': '0123456789_bw_counter', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_b_w_pages-state] @@ -39,7 +39,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW B/W pages', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_b_w_pages', @@ -131,7 +130,7 @@ 'supported_features': 0, 'translation_key': 'black_drum_page_counter', 'unique_id': '0123456789_black_drum_counter', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_black_drum_page_counter-state] @@ -139,7 +138,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Black drum page counter', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_black_drum_page_counter', @@ -231,7 +229,7 @@ 'supported_features': 0, 'translation_key': 'black_drum_remaining_pages', 'unique_id': '0123456789_black_drum_remaining_pages', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_black_drum_remaining_pages-state] @@ -239,7 +237,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Black drum remaining pages', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_black_drum_remaining_pages', @@ -331,7 +328,7 @@ 'supported_features': 0, 'translation_key': 'color_pages', 'unique_id': '0123456789_color_counter', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_color_pages-state] @@ -339,7 +336,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Color pages', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_color_pages', @@ -381,7 +377,7 @@ 'supported_features': 0, 'translation_key': 'cyan_drum_page_counter', 'unique_id': '0123456789_cyan_drum_counter', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_cyan_drum_page_counter-state] @@ -389,7 +385,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Cyan drum page counter', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_cyan_drum_page_counter', @@ -481,7 +476,7 @@ 'supported_features': 0, 'translation_key': 'cyan_drum_remaining_pages', 'unique_id': '0123456789_cyan_drum_remaining_pages', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_cyan_drum_remaining_pages-state] @@ -489,7 +484,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Cyan drum remaining pages', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_cyan_drum_remaining_pages', @@ -581,7 +575,7 @@ 'supported_features': 0, 'translation_key': 'drum_page_counter', 'unique_id': '0123456789_drum_counter', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_drum_page_counter-state] @@ -589,7 +583,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Drum page counter', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_drum_page_counter', @@ -681,7 +674,7 @@ 'supported_features': 0, 'translation_key': 'drum_remaining_pages', 'unique_id': '0123456789_drum_remaining_pages', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_drum_remaining_pages-state] @@ -689,7 +682,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Drum remaining pages', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_drum_remaining_pages', @@ -731,7 +723,7 @@ 'supported_features': 0, 'translation_key': 'duplex_unit_page_counter', 'unique_id': '0123456789_duplex_unit_pages_counter', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_duplex_unit_page_counter-state] @@ -739,7 +731,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Duplex unit page counter', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_duplex_unit_page_counter', @@ -878,7 +869,7 @@ 'supported_features': 0, 'translation_key': 'magenta_drum_page_counter', 'unique_id': '0123456789_magenta_drum_counter', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_magenta_drum_page_counter-state] @@ -886,7 +877,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Magenta drum page counter', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_magenta_drum_page_counter', @@ -978,7 +968,7 @@ 'supported_features': 0, 'translation_key': 'magenta_drum_remaining_pages', 'unique_id': '0123456789_magenta_drum_remaining_pages', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_magenta_drum_remaining_pages-state] @@ -986,7 +976,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Magenta drum remaining pages', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_magenta_drum_remaining_pages', @@ -1078,7 +1067,7 @@ 'supported_features': 0, 'translation_key': 'page_counter', 'unique_id': '0123456789_page_counter', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_page_counter-state] @@ -1086,7 +1075,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Page counter', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_page_counter', @@ -1224,7 +1212,7 @@ 'supported_features': 0, 'translation_key': 'yellow_drum_page_counter', 'unique_id': '0123456789_yellow_drum_counter', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_yellow_drum_page_counter-state] @@ -1232,7 +1220,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Yellow drum page counter', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_yellow_drum_page_counter', @@ -1324,7 +1311,7 @@ 'supported_features': 0, 'translation_key': 'yellow_drum_remaining_pages', 'unique_id': '0123456789_yellow_drum_remaining_pages', - 'unit_of_measurement': 'p', + 'unit_of_measurement': None, }) # --- # name: test_sensors[sensor.hl_l2340dw_yellow_drum_remaining_pages-state] @@ -1332,7 +1319,6 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Yellow drum remaining pages', 'state_class': , - 'unit_of_measurement': 'p', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_yellow_drum_remaining_pages', From 7e58aa8af14af519b25c5d1c6bd1e0a91833b7aa Mon Sep 17 00:00:00 2001 From: dotvav Date: Tue, 26 Nov 2024 10:28:04 +0100 Subject: [PATCH 0872/1070] Bump pypalazzetti to 0.1.14 (#131443) --- homeassistant/components/palazzetti/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index 30134c6ac806a..05a5d260b50eb 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pypalazzetti==0.1.13"] + "requirements": ["pypalazzetti==0.1.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8aeb750939536..436de711c41cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2155,7 +2155,7 @@ pyoverkiz==1.15.0 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.13 +pypalazzetti==0.1.14 # homeassistant.components.elv pypca==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3fb24e4a46ee8..0b3df47f8320b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1742,7 +1742,7 @@ pyoverkiz==1.15.0 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.13 +pypalazzetti==0.1.14 # homeassistant.components.lcn pypck==0.7.24 From 066af3a5da596c44f020547fed9988a526325514 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 26 Nov 2024 10:29:46 +0100 Subject: [PATCH 0873/1070] Add reconfigure flow to filesize (#131106) --- .../components/filesize/config_flow.py | 51 +++++--- .../components/filesize/strings.json | 8 +- tests/components/filesize/test_config_flow.py | 118 +++++++++++++++++- 3 files changed, 156 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/filesize/config_flow.py b/homeassistant/components/filesize/config_flow.py index 51eff46bdb3ac..8ffe3f94353ca 100644 --- a/homeassistant/components/filesize/config_flow.py +++ b/homeassistant/components/filesize/config_flow.py @@ -11,7 +11,6 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_FILE_PATH from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN @@ -20,20 +19,20 @@ _LOGGER = logging.getLogger(__name__) -def validate_path(hass: HomeAssistant, path: str) -> str: +def validate_path(hass: HomeAssistant, path: str) -> tuple[str | None, dict[str, str]]: """Validate path.""" get_path = pathlib.Path(path) if not get_path.exists() or not get_path.is_file(): _LOGGER.error("Can not access file %s", path) - raise NotValidError + return (None, {"base": "not_valid"}) if not hass.config.is_allowed_path(path): _LOGGER.error("Filepath %s is not allowed", path) - raise NotAllowedError + return (None, {"base": "not_allowed"}) full_path = get_path.absolute() - return str(full_path) + return (str(full_path), {}) class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN): @@ -45,18 +44,13 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - errors: dict[str, Any] = {} + errors: dict[str, str] = {} if user_input is not None: - try: - full_path = await self.hass.async_add_executor_job( - validate_path, self.hass, user_input[CONF_FILE_PATH] - ) - except NotValidError: - errors["base"] = "not_valid" - except NotAllowedError: - errors["base"] = "not_allowed" - else: + full_path, errors = await self.hass.async_add_executor_job( + validate_path, self.hass, user_input[CONF_FILE_PATH] + ) + if not errors: await self.async_set_unique_id(full_path) self._abort_if_unique_id_configured() @@ -70,10 +64,29 @@ async def async_step_user( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfigure flow initialized by the user.""" + errors: dict[str, str] = {} -class NotValidError(HomeAssistantError): - """Path is not valid error.""" + if user_input is not None: + reconfigure_entry = self._get_reconfigure_entry() + full_path, errors = await self.hass.async_add_executor_job( + validate_path, self.hass, user_input[CONF_FILE_PATH] + ) + if not errors: + await self.async_set_unique_id(full_path) + self._abort_if_unique_id_configured() + name = str(user_input[CONF_FILE_PATH]).rsplit("/", maxsplit=1)[-1] + return self.async_update_reload_and_abort( + reconfigure_entry, + title=name, + unique_id=self.unique_id, + data_updates={CONF_FILE_PATH: user_input[CONF_FILE_PATH]}, + ) -class NotAllowedError(HomeAssistantError): - """Path is not allowed error.""" + return self.async_show_form( + step_id="reconfigure", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/filesize/strings.json b/homeassistant/components/filesize/strings.json index 27d83d9fb6262..6623cf9c37530 100644 --- a/homeassistant/components/filesize/strings.json +++ b/homeassistant/components/filesize/strings.json @@ -5,6 +5,11 @@ "data": { "file_path": "Path to file" } + }, + "reconfigure": { + "data": { + "file_path": "[%key:component::filesize::config::step::user::data::file_path%]" + } } }, "error": { @@ -12,7 +17,8 @@ "not_allowed": "Path is not allowed" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "title": "Filesize", diff --git a/tests/components/filesize/test_config_flow.py b/tests/components/filesize/test_config_flow.py index 4b275e66d029a..383b1f596f8b4 100644 --- a/tests/components/filesize/test_config_flow.py +++ b/tests/components/filesize/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import TEST_FILE_NAME, async_create_file +from . import TEST_FILE_NAME, TEST_FILE_NAME2, async_create_file from tests.common import MockConfigEntry @@ -108,3 +108,119 @@ async def test_flow_fails_on_validation(hass: HomeAssistant, tmp_path: Path) -> assert result2["data"] == { CONF_FILE_PATH: test_file, } + + +async def test_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmp_path: Path +) -> None: + """Test a reconfigure flow.""" + test_file = str(tmp_path.joinpath(TEST_FILE_NAME2)) + await async_create_file(hass, test_file) + hass.config.allowlist_external_dirs = {tmp_path} + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["step_id"] == "reconfigure" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_FILE_PATH: test_file}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_FILE_PATH: str(test_file)} + + +async def test_unique_id_already_exist_in_reconfigure_flow( + hass: HomeAssistant, tmp_path: Path +) -> None: + """Test a reconfigure flow fails when unique id already exist.""" + test_file = str(tmp_path.joinpath(TEST_FILE_NAME)) + test_file2 = str(tmp_path.joinpath(TEST_FILE_NAME2)) + await async_create_file(hass, test_file) + await async_create_file(hass, test_file2) + hass.config.allowlist_external_dirs = {tmp_path} + test_file = str(tmp_path.joinpath(TEST_FILE_NAME)) + mock_config_entry = MockConfigEntry( + title=TEST_FILE_NAME, + domain=DOMAIN, + data={CONF_FILE_PATH: test_file}, + unique_id=test_file, + ) + mock_config_entry2 = MockConfigEntry( + title=TEST_FILE_NAME2, + domain=DOMAIN, + data={CONF_FILE_PATH: test_file2}, + unique_id=test_file2, + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry2.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["step_id"] == "reconfigure" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_FILE_PATH: test_file2}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_reconfigure_flow_fails_on_validation( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmp_path: Path +) -> None: + """Test config flow errors in reconfigure.""" + test_file2 = str(tmp_path.joinpath(TEST_FILE_NAME2)) + hass.config.allowlist_external_dirs = {} + + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_FILE_PATH: test_file2, + }, + ) + + assert result["errors"] == {"base": "not_valid"} + + await async_create_file(hass, test_file2) + + with patch( + "homeassistant.components.filesize.config_flow.pathlib.Path", + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_FILE_PATH: test_file2, + }, + ) + + assert result2["errors"] == {"base": "not_allowed"} + + hass.config.allowlist_external_dirs = {tmp_path} + with patch( + "homeassistant.components.filesize.config_flow.pathlib.Path", + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_FILE_PATH: test_file2, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" From 0a16595a15fb0d8a1491f05a18d11ea96226fae8 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 26 Nov 2024 10:32:05 +0100 Subject: [PATCH 0874/1070] Update coverage to 7.6.8 (#131515) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index fa15208ead345..f9763630767b8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.3.5 -coverage==7.6.1 +coverage==7.6.8 freezegun==1.5.1 license-expression==30.4.0 mock-open==1.4.0 From 1539558935db2db28e4920c186aab354200ff0a8 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 26 Nov 2024 10:32:35 +0100 Subject: [PATCH 0875/1070] Remove non-translated string from exceptions in devolo Home Network (#131606) --- homeassistant/components/devolo_home_network/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 8006bcfdc87c2..7f6784f24040c 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -83,7 +83,6 @@ async def async_setup_entry( ) except DeviceNotFound as err: raise ConfigEntryNotReady( - f"Unable to connect to {entry.data[CONF_IP_ADDRESS]}", translation_domain=DOMAIN, translation_key="connection_failed", translation_placeholders={"ip_address": entry.data[CONF_IP_ADDRESS]}, @@ -131,7 +130,7 @@ async def async_update_guest_wifi_status() -> WifiGuestAccessGet: ) from err except DevicePasswordProtected as err: raise ConfigEntryAuthFailed( - err, translation_domain=DOMAIN, translation_key="password_wrong" + translation_domain=DOMAIN, translation_key="password_wrong" ) from err async def async_update_led_status() -> bool: @@ -161,7 +160,7 @@ async def async_update_last_restart() -> int: ) from err except DevicePasswordProtected as err: raise ConfigEntryAuthFailed( - err, translation_domain=DOMAIN, translation_key="password_wrong" + translation_domain=DOMAIN, translation_key="password_wrong" ) from err async def async_update_wifi_connected_station() -> list[ConnectedStationInfo]: From b800db9f52e0b810f99cff506e55b8ca6d352351 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 26 Nov 2024 10:33:01 +0100 Subject: [PATCH 0876/1070] Abort SABnzbd config flow when instance already configured (#131607) --- .../components/sabnzbd/config_flow.py | 7 ++++ homeassistant/components/sabnzbd/strings.json | 1 + tests/components/sabnzbd/test_config_flow.py | 35 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/homeassistant/components/sabnzbd/config_flow.py b/homeassistant/components/sabnzbd/config_flow.py index 9ce29df02eab2..ce9b0a13b18da 100644 --- a/homeassistant/components/sabnzbd/config_flow.py +++ b/homeassistant/components/sabnzbd/config_flow.py @@ -64,6 +64,13 @@ async def async_step_user( if not sab_api: errors["base"] = "cannot_connect" else: + self._async_abort_entries_match( + { + CONF_URL: user_input[CONF_URL], + CONF_API_KEY: user_input[CONF_API_KEY], + } + ) + if self.source == SOURCE_RECONFIGURE: return self.async_update_reload_and_abort( self._get_reconfigure_entry(), data_updates=user_input diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 78a8b88486ed8..186682e78e771 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -17,6 +17,7 @@ "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" }, "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, diff --git a/tests/components/sabnzbd/test_config_flow.py b/tests/components/sabnzbd/test_config_flow.py index 8d8289e1e887a..797af63c09671 100644 --- a/tests/components/sabnzbd/test_config_flow.py +++ b/tests/components/sabnzbd/test_config_flow.py @@ -132,3 +132,38 @@ async def test_reconfigure_error( CONF_URL: "http://10.10.10.10:8080", CONF_API_KEY: "new_key", } + + +async def test_abort_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that the flow aborts if SABnzbd instance is already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_abort_reconfigure_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that the reconfigure flow aborts if SABnzbd instance is already configured.""" + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From 04b8a686dcf3413f70d8c9bae6a7a5dab5c30666 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 26 Nov 2024 10:36:05 +0100 Subject: [PATCH 0877/1070] Fix Values for Recording mode and Infrared mode entities are not showing correctly (#131487) --- homeassistant/components/unifiprotect/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index a91a94aa6298f..09187e023a106 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -245,7 +245,7 @@ def _get_alarm_sound(obj: Sensor) -> str: name="Recording mode", icon="mdi:video-outline", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="recording_settings.mode", + ufp_value="recording_settings.mode.value", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( @@ -254,7 +254,7 @@ def _get_alarm_sound(obj: Sensor) -> str: icon="mdi:circle-opacity", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_led_ir", - ufp_value="isp_settings.ir_led_mode", + ufp_value="isp_settings.ir_led_mode.value", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( From d587e71f8de4687844c228c3c327ee1bae5df46c Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 26 Nov 2024 10:42:03 +0100 Subject: [PATCH 0878/1070] Add descriptions for config flow in Habitica integration (#131461) --- .../components/habitica/config_flow.py | 19 ++++++++++++++++++- homeassistant/components/habitica/const.py | 5 +++++ .../components/habitica/strings.json | 19 ++++++++++++++++--- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index 88f3d1b803c8e..d168a5f57b4ff 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -25,7 +25,15 @@ TextSelectorType, ) -from .const import CONF_API_USER, DEFAULT_URL, DOMAIN +from .const import ( + CONF_API_USER, + DEFAULT_URL, + DOMAIN, + FORGOT_PASSWORD_URL, + HABITICANS_URL, + SIGN_UP_URL, + SITE_DATA_URL, +) STEP_ADVANCED_DATA_SCHEMA = vol.Schema( { @@ -69,6 +77,10 @@ async def async_step_user( return self.async_show_menu( step_id="user", menu_options=["login", "advanced"], + description_placeholders={ + "signup": SIGN_UP_URL, + "habiticans": HABITICANS_URL, + }, ) async def async_step_login( @@ -125,6 +137,7 @@ async def async_step_login( data_schema=STEP_LOGIN_DATA_SCHEMA, suggested_values=user_input ), errors=errors, + description_placeholders={"forgot_password": FORGOT_PASSWORD_URL}, ) async def async_step_advanced( @@ -175,4 +188,8 @@ async def async_step_advanced( data_schema=STEP_ADVANCED_DATA_SCHEMA, suggested_values=user_input ), errors=errors, + description_placeholders={ + "site_data": SITE_DATA_URL, + "default_url": DEFAULT_URL, + }, ) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 1fcc4b36053ee..dce417b60a5b3 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -6,6 +6,11 @@ DEFAULT_URL = "https://habitica.com" ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/" +SITE_DATA_URL = "https://habitica.com/user/settings/siteData" +FORGOT_PASSWORD_URL = "https://habitica.com/forgot-password" +SIGN_UP_URL = "https://habitica.com/register" +HABITICANS_URL = "https://habitica.com/static/img/home-main@3x.ffc32b12.png" + DOMAIN = "habitica" # service constants diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 08809ee05a6e7..81691327aec75 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -15,26 +15,39 @@ }, "step": { "user": { + "title": "Habitica - Gamify your life", "menu_options": { "login": "Login to Habitica", "advanced": "Login to other instances" }, - "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks." + "description": "![Habiticans]({habiticans}) Connect your Habitica account to keep track of your adventurer's stats, progress, and manage your to-dos and daily tasks.\n\n[Don't have a Habitica account? Sign up here.]({signup})" }, "login": { + "title": "[%key:component::habitica::config::step::user::menu_options::login%]", "data": { "username": "Email or username (case-sensitive)", "password": "[%key:common::config_flow::data::password%]" - } + }, + "data_description": { + "username": "Email or username (case-sensitive) to connect Home Assistant to your Habitica account", + "password": "Password for the account to connect Home Assistant to Habitica" + }, + "description": "Enter your login details to start using Habitica with Home Assistant\n\n[Forgot your password?]({forgot_password})" }, "advanced": { + "title": "[%key:component::habitica::config::step::user::menu_options::advanced%]", "data": { "url": "[%key:common::config_flow::data::url%]", "api_user": "User ID", "api_key": "API Token", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, - "description": "You can retrieve your `User ID` and `API Token` from **Settings -> Site Data** on Habitica or the instance you want to connect to" + "data_description": { + "url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`", + "api_user": "User ID of your Habitica account", + "api_key": "API Token of the Habitica account" + }, + "description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to" } } }, From 0b7fbe1d17c88a88f91b03203042c0df5b95edbf Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 26 Nov 2024 10:46:12 +0100 Subject: [PATCH 0879/1070] Adjust the fan entity icon to it's state in ViCare integration (#131553) --- homeassistant/components/vicare/fan.py | 24 +++++++++++++++++++ .../components/vicare/snapshots/test_fan.ambr | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 9973cf56e39d0..1800704a16f41 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -173,6 +173,30 @@ def is_on(self) -> bool | None: # Viessmann ventilation unit cannot be turned off return True + @property + def icon(self) -> str | None: + """Return the icon to use in the frontend.""" + if hasattr(self, "_attr_preset_mode"): + if self._attr_preset_mode == VentilationMode.VENTILATION: + return "mdi:fan-clock" + if self._attr_preset_mode in [ + VentilationMode.SENSOR_DRIVEN, + VentilationMode.SENSOR_OVERRIDE, + ]: + return "mdi:fan-auto" + if self._attr_preset_mode == VentilationMode.PERMANENT: + if self._attr_percentage == 0: + return "mdi:fan-off" + if self._attr_percentage is not None: + level = 1 + ORDERED_NAMED_FAN_SPEEDS.index( + percentage_to_ordered_list_item( + ORDERED_NAMED_FAN_SPEEDS, self._attr_percentage + ) + ) + if level < 4: # fan-speed- only supports 1-3 + return f"mdi:fan-speed-{level}" + return "mdi:fan" + def set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" if self._attr_preset_mode != str(VentilationMode.PERMANENT): diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 8ec4bc41d8d33..3ecc4277fd944 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -29,7 +29,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': None, + 'original_icon': 'mdi:fan', 'original_name': 'Ventilation', 'platform': 'vicare', 'previous_unique_id': None, @@ -43,6 +43,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Ventilation', + 'icon': 'mdi:fan', 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, From d2f862b7b9166f921da5f5515bbc72d9b66144ab Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Tue, 26 Nov 2024 04:55:03 -0500 Subject: [PATCH 0880/1070] Add disconnect/reconnect tests to Cambridge Audio (#131100) --- tests/components/cambridge_audio/__init__.py | 12 +++++++++ tests/components/cambridge_audio/test_init.py | 26 +++++++++++++++++-- .../cambridge_audio/test_media_player.py | 9 +------ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/tests/components/cambridge_audio/__init__.py b/tests/components/cambridge_audio/__init__.py index f6b5f48d39d57..4e11a728f418e 100644 --- a/tests/components/cambridge_audio/__init__.py +++ b/tests/components/cambridge_audio/__init__.py @@ -1,5 +1,9 @@ """Tests for the Cambridge Audio integration.""" +from unittest.mock import AsyncMock + +from aiostreammagic.models import CallbackType + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -11,3 +15,11 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +async def mock_state_update( + client: AsyncMock, callback_type: CallbackType = CallbackType.STATE +) -> None: + """Trigger a callback in the media player.""" + for callback in client.register_state_update_callbacks.call_args_list: + await callback[0][0](client, callback_type) diff --git a/tests/components/cambridge_audio/test_init.py b/tests/components/cambridge_audio/test_init.py index 4a8c1b668e202..a058f7c8b6c3f 100644 --- a/tests/components/cambridge_audio/test_init.py +++ b/tests/components/cambridge_audio/test_init.py @@ -1,8 +1,10 @@ """Tests for the Cambridge Audio integration.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock from aiostreammagic import StreamMagicError +from aiostreammagic.models import CallbackType +import pytest from syrupy import SnapshotAssertion from homeassistant.components.cambridge_audio.const import DOMAIN @@ -10,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import setup_integration +from . import mock_state_update, setup_integration from tests.common import MockConfigEntry @@ -43,3 +45,23 @@ async def test_device_info( ) assert device_entry is not None assert device_entry == snapshot + + +async def test_disconnect_reconnect_log( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_stream_magic_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + + mock_stream_magic_client.is_connected = Mock(return_value=False) + await mock_state_update(mock_stream_magic_client, CallbackType.CONNECTION) + assert "Disconnected from device at 192.168.20.218" in caplog.text + + mock_stream_magic_client.is_connected = Mock(return_value=True) + await mock_state_update(mock_stream_magic_client, CallbackType.CONNECTION) + assert "Reconnected to device at 192.168.20.218" in caplog.text diff --git a/tests/components/cambridge_audio/test_media_player.py b/tests/components/cambridge_audio/test_media_player.py index b857e61c235a2..bb2ccd1aec499 100644 --- a/tests/components/cambridge_audio/test_media_player.py +++ b/tests/components/cambridge_audio/test_media_player.py @@ -7,7 +7,6 @@ ShuffleMode, TransportControl, ) -from aiostreammagic.models import CallbackType import pytest from homeassistant.components.media_player import ( @@ -49,18 +48,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from . import setup_integration +from . import mock_state_update, setup_integration from .const import ENTITY_ID from tests.common import MockConfigEntry -async def mock_state_update(client: AsyncMock) -> None: - """Trigger a callback in the media player.""" - for callback in client.register_state_update_callbacks.call_args_list: - await callback[0][0](client, CallbackType.STATE) - - async def test_entity_supported_features( hass: HomeAssistant, mock_stream_magic_client: AsyncMock, From 666b908242254c5132ba3d211dc4e6c173cc5e51 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Tue, 26 Nov 2024 10:57:46 +0100 Subject: [PATCH 0881/1070] Allow dhcp discovery to update host for lamarzocco (#131047) --- .../components/lamarzocco/config_flow.py | 9 +++++- homeassistant/components/lamarzocco/entity.py | 14 +++++++- .../components/lamarzocco/manifest.json | 3 ++ .../components/lamarzocco/quality_scale.yaml | 2 +- homeassistant/generated/dhcp.py | 4 +++ tests/components/lamarzocco/conftest.py | 9 +++++- .../lamarzocco/snapshots/test_switch.ambr | 4 +++ .../components/lamarzocco/test_config_flow.py | 32 +++++++++++++++++++ tests/components/lamarzocco/test_init.py | 26 +++++++++++---- 9 files changed, 92 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index b81fc8f9e4b82..0f288e22c4a01 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -26,6 +26,7 @@ OptionsFlow, ) from homeassistant.const import ( + CONF_ADDRESS, CONF_HOST, CONF_MAC, CONF_MODEL, @@ -284,7 +285,12 @@ async def async_step_dhcp( serial = discovery_info.hostname.upper() await self.async_set_unique_id(serial) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: discovery_info.ip, + CONF_ADDRESS: discovery_info.macaddress, + } + ) _LOGGER.debug( "Discovered La Marzocco machine %s through DHCP at address %s", @@ -294,6 +300,7 @@ async def async_step_dhcp( self._discovered[CONF_MACHINE] = serial self._discovered[CONF_HOST] = discovery_info.ip + self._discovered[CONF_ADDRESS] = discovery_info.macaddress return await self.async_step_user() diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index 1ea84302a17ac..f0942f51acec3 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -6,7 +6,8 @@ from pylamarzocco.const import FirmwareType from pylamarzocco.lm_machine import LaMarzoccoMachine -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.const import CONF_ADDRESS +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -47,6 +48,17 @@ def __init__( serial_number=device.serial_number, sw_version=device.firmware[FirmwareType.MACHINE].current_version, ) + if coordinator.config_entry.data.get(CONF_ADDRESS): + self._attr_device_info.update( + DeviceInfo( + connections={ + ( + CONNECTION_NETWORK_MAC, + coordinator.config_entry.data[CONF_ADDRESS], + ) + } + ) + ) class LaMarzoccoEntity(LaMarzoccoBaseEntity): diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 4aef30a5c2674..a71da7c475441 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -19,6 +19,9 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "dhcp": [ + { + "registered_devices": true + }, { "hostname": "gs[0-9][0-9][0-9][0-9][0-9][0-9]" }, diff --git a/homeassistant/components/lamarzocco/quality_scale.yaml b/homeassistant/components/lamarzocco/quality_scale.yaml index 3cea45539c90f..3677bd8d6b8e3 100644 --- a/homeassistant/components/lamarzocco/quality_scale.yaml +++ b/homeassistant/components/lamarzocco/quality_scale.yaml @@ -49,7 +49,7 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: todo + discovery-update-info: done discovery: status: done comment: | diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 7dacf9a0bcabb..1ef91841db868 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -276,6 +276,10 @@ "hostname": "polisy*", "macaddress": "000DB9*", }, + { + "domain": "lamarzocco", + "registered_devices": True, + }, { "domain": "lamarzocco", "hostname": "gs[0-9][0-9][0-9][0-9][0-9][0-9]", diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index 9c568962e3465..d6d59cf9ebc32 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -11,7 +11,13 @@ import pytest from homeassistant.components.lamarzocco.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME, CONF_TOKEN +from homeassistant.const import ( + CONF_ADDRESS, + CONF_HOST, + CONF_MODEL, + CONF_NAME, + CONF_TOKEN, +) from homeassistant.core import HomeAssistant from . import SERIAL_DICT, USER_INPUT, async_init_integration @@ -40,6 +46,7 @@ def mock_config_entry( data=USER_INPUT | { CONF_MODEL: mock_lamarzocco.model, + CONF_ADDRESS: "00:00:00:00:00:00", CONF_HOST: "host", CONF_TOKEN: "token", CONF_NAME: "GS3", diff --git a/tests/components/lamarzocco/snapshots/test_switch.ambr b/tests/components/lamarzocco/snapshots/test_switch.ambr index 5e3b99da6176e..084b54b3f3a05 100644 --- a/tests/components/lamarzocco/snapshots/test_switch.ambr +++ b/tests/components/lamarzocco/snapshots/test_switch.ambr @@ -97,6 +97,10 @@ 'config_entries': , 'configuration_url': None, 'connections': set({ + tuple( + 'mac', + '00:00:00:00:00:00', + ), }), 'disabled_by': None, 'entry_type': None, diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 516fb1db31a8e..f8103ac305441 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -18,6 +18,7 @@ ConfigEntryState, ) from homeassistant.const import ( + CONF_ADDRESS, CONF_HOST, CONF_MAC, CONF_MODEL, @@ -483,6 +484,7 @@ async def test_dhcp_discovery( assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["data"] == { **USER_INPUT, + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", CONF_HOST: "192.168.1.42", CONF_MACHINE: mock_lamarzocco.serial_number, CONF_MODEL: mock_device_info.model, @@ -491,6 +493,36 @@ async def test_dhcp_discovery( } +async def test_dhcp_already_configured_and_update( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_cloud_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test discovered IP address change.""" + old_ip = mock_config_entry.data[CONF_HOST] + old_address = mock_config_entry.data[CONF_ADDRESS] + + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.1.42", + hostname=mock_lamarzocco.serial_number, + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] != old_ip + assert mock_config_entry.data[CONF_HOST] == "192.168.1.42" + + assert mock_config_entry.data[CONF_ADDRESS] != old_address + assert mock_config_entry.data[CONF_ADDRESS] == "aa:bb:cc:dd:ee:ff" + + async def test_options_flow( hass: HomeAssistant, mock_lamarzocco: MagicMock, diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 5ef0eca13aff1..75c3019afb472 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -10,7 +10,14 @@ from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_MODEL, + CONF_NAME, + CONF_TOKEN, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -81,20 +88,22 @@ async def test_invalid_auth( async def test_v1_migration( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, mock_cloud_client: MagicMock, mock_lamarzocco: MagicMock, ) -> None: """Test v1 -> v2 Migration.""" + common_data = { + **USER_INPUT, + CONF_HOST: "host", + CONF_MAC: "aa:bb:cc:dd:ee:ff", + } entry_v1 = MockConfigEntry( domain=DOMAIN, version=1, unique_id=mock_lamarzocco.serial_number, data={ - **USER_INPUT, - CONF_HOST: "host", + **common_data, CONF_MACHINE: mock_lamarzocco.serial_number, - CONF_MAC: "aa:bb:cc:dd:ee:ff", }, ) @@ -103,8 +112,11 @@ async def test_v1_migration( await hass.async_block_till_done() assert entry_v1.version == 2 - assert dict(entry_v1.data) == dict(mock_config_entry.data) | { - CONF_MAC: "aa:bb:cc:dd:ee:ff" + assert dict(entry_v1.data) == { + **common_data, + CONF_NAME: "GS3", + CONF_MODEL: mock_lamarzocco.model, + CONF_TOKEN: "token", } From 5f7c7b323e0373afcb86a7b9871b357f0ad91240 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 26 Nov 2024 10:58:57 +0100 Subject: [PATCH 0882/1070] Add Reolink bitrate and framerate select entities (#131571) --- homeassistant/components/reolink/icons.json | 12 +++++ homeassistant/components/reolink/select.py | 50 ++++++++++++++++++- homeassistant/components/reolink/strings.json | 12 +++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index d333a8a020108..4fbc8e82ae3ac 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -263,6 +263,18 @@ "state": { "off": "mdi:music-note-off" } + }, + "main_frame_rate": { + "default": "mdi:play-speed" + }, + "sub_frame_rate": { + "default": "mdi:play-speed" + }, + "main_bit_rate": { + "default": "mdi:play-speed" + }, + "sub_bit_rate": { + "default": "mdi:play-speed" } }, "sensor": { diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 6f8a072264da0..f1147c503288f 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -21,7 +21,7 @@ from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfFrequency from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -175,6 +175,54 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: value=lambda api, ch: HDREnum(api.HDR_state(ch)).name, method=lambda api, ch, name: api.set_HDR(ch, HDREnum[name].value), ), + ReolinkSelectEntityDescription( + key="main_frame_rate", + cmd_key="GetEnc", + translation_key="main_frame_rate", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + unit_of_measurement=UnitOfFrequency.HERTZ, + get_options=lambda api, ch: [str(v) for v in api.frame_rate_list(ch, "main")], + supported=lambda api, ch: api.supported(ch, "frame_rate"), + value=lambda api, ch: str(api.frame_rate(ch, "main")), + method=lambda api, ch, value: api.set_frame_rate(ch, int(value), "main"), + ), + ReolinkSelectEntityDescription( + key="sub_frame_rate", + cmd_key="GetEnc", + translation_key="sub_frame_rate", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + unit_of_measurement=UnitOfFrequency.HERTZ, + get_options=lambda api, ch: [str(v) for v in api.frame_rate_list(ch, "sub")], + supported=lambda api, ch: api.supported(ch, "frame_rate"), + value=lambda api, ch: str(api.frame_rate(ch, "sub")), + method=lambda api, ch, value: api.set_frame_rate(ch, int(value), "sub"), + ), + ReolinkSelectEntityDescription( + key="main_bit_rate", + cmd_key="GetEnc", + translation_key="main_bit_rate", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + get_options=lambda api, ch: [str(v) for v in api.bit_rate_list(ch, "main")], + supported=lambda api, ch: api.supported(ch, "bit_rate"), + value=lambda api, ch: str(api.bit_rate(ch, "main")), + method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "main"), + ), + ReolinkSelectEntityDescription( + key="sub_bit_rate", + cmd_key="GetEnc", + translation_key="sub_bit_rate", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + get_options=lambda api, ch: [str(v) for v in api.bit_rate_list(ch, "sub")], + supported=lambda api, ch: api.supported(ch, "bit_rate"), + value=lambda api, ch: str(api.bit_rate(ch, "sub")), + method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "sub"), + ), ) CHIME_SELECT_ENTITIES = ( diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 1d699b7b65844..5e885d9ac7754 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -653,6 +653,18 @@ "moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]", "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]" } + }, + "main_frame_rate": { + "name": "Clear frame rate" + }, + "sub_frame_rate": { + "name": "Fluent frame rate" + }, + "main_bit_rate": { + "name": "Clear bit rate" + }, + "sub_bit_rate": { + "name": "Fluent bit rate" } }, "sensor": { From 5da7b1dd0512f334ee7fd7da4cf5f0d39d4e2720 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 26 Nov 2024 11:06:48 +0100 Subject: [PATCH 0883/1070] Test connection in config flow for Husqvarna Automower (#131557) --- .../components/husqvarna_automower/api.py | 14 +++++++++ .../husqvarna_automower/config_flow.py | 16 +++++++++- .../husqvarna_automower/strings.json | 4 ++- .../husqvarna_automower/fixtures/empty.json | 1 + .../husqvarna_automower/test_config_flow.py | 30 +++++++++++++------ 5 files changed, 54 insertions(+), 11 deletions(-) create mode 100644 tests/components/husqvarna_automower/fixtures/empty.json diff --git a/homeassistant/components/husqvarna_automower/api.py b/homeassistant/components/husqvarna_automower/api.py index f1d3e1ef4fa71..8a9a31b926a07 100644 --- a/homeassistant/components/husqvarna_automower/api.py +++ b/homeassistant/components/husqvarna_automower/api.py @@ -7,6 +7,7 @@ from aioautomower.const import API_BASE_URL from aiohttp import ClientSession +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.helpers import config_entry_oauth2_flow _LOGGER = logging.getLogger(__name__) @@ -28,3 +29,16 @@ async def async_get_access_token(self) -> str: """Return a valid access token.""" await self._oauth_session.async_ensure_token_valid() return cast(str, self._oauth_session.token["access_token"]) + + +class AsyncConfigFlowAuth(AbstractAuth): + """Provide Automower AbstractAuth for the config flow.""" + + def __init__(self, websession: ClientSession, token: dict) -> None: + """Initialize Husqvarna Automower auth.""" + super().__init__(websession, API_BASE_URL) + self.token: dict = token + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + return cast(str, self.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py index 3e76b9ac812d8..4da3bd1408917 100644 --- a/homeassistant/components/husqvarna_automower/config_flow.py +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -4,12 +4,15 @@ import logging from typing import Any +from aioautomower.session import AutomowerSession from aioautomower.utils import structure_token from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, CONF_TOKEN -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.util import dt as dt_util +from .api import AsyncConfigFlowAuth from .const import DOMAIN, NAME _LOGGER = logging.getLogger(__name__) @@ -46,9 +49,20 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu self._abort_if_unique_id_configured() + websession = aiohttp_client.async_get_clientsession(self.hass) + tz = await dt_util.async_get_time_zone(str(dt_util.DEFAULT_TIME_ZONE)) + automower_api = AutomowerSession(AsyncConfigFlowAuth(websession, token), tz) + try: + data = await automower_api.get_status() + except Exception: # noqa: BLE001 + return self.async_abort(reason="unknown") + if data == {}: + return self.async_abort(reason="no_mower_connected") + structured_token = structure_token(token[CONF_ACCESS_TOKEN]) first_name = structured_token.user.first_name last_name = structured_token.user.last_name + return self.async_create_entry( title=f"{NAME} of {first_name} {last_name}", data=data, diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 149d53f8783b3..d4c91e29f7d1d 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -27,7 +27,9 @@ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "wrong_account": "You can only reauthenticate this entry with the same Husqvarna account.", - "missing_amc_scope": "The `Authentication API` and the `Automower Connect API` are not connected to your application in the Husqvarna Developer Portal." + "no_mower_connected": "No mowers connected to this account.", + "missing_amc_scope": "The `Authentication API` and the `Automower Connect API` are not connected to your application in the Husqvarna Developer Portal.", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/husqvarna_automower/fixtures/empty.json b/tests/components/husqvarna_automower/fixtures/empty.json new file mode 100644 index 0000000000000..22f4a272fc185 --- /dev/null +++ b/tests/components/husqvarna_automower/fixtures/empty.json @@ -0,0 +1 @@ +{ "data": [] } diff --git a/tests/components/husqvarna_automower/test_config_flow.py b/tests/components/husqvarna_automower/test_config_flow.py index 31e8a9afcbd2e..d91078d80a243 100644 --- a/tests/components/husqvarna_automower/test_config_flow.py +++ b/tests/components/husqvarna_automower/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock, patch +from aioautomower.const import API_BASE_URL +from aioautomower.session import AutomowerEndpoint import pytest from homeassistant import config_entries @@ -18,16 +20,18 @@ from . import setup_integration from .const import CLIENT_ID, USER_ID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @pytest.mark.parametrize( - ("new_scope", "amount"), + ("new_scope", "fixture", "exception", "amount"), [ - ("iam:read amc:api", 1), - ("iam:read", 0), + ("iam:read amc:api", "mower.json", None, 1), + ("iam:read amc:api", "mower.json", Exception, 0), + ("iam:read", "mower.json", None, 0), + ("iam:read amc:api", "empty.json", None, 0), ], ) @pytest.mark.usefixtures("current_request_with_host") @@ -38,6 +42,8 @@ async def test_full_flow( jwt: str, new_scope: str, amount: int, + fixture: str, + exception: Exception | None, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( @@ -76,11 +82,17 @@ async def test_full_flow( "expires_at": 1697753347, }, ) - - with patch( - "homeassistant.components.husqvarna_automower.async_setup_entry", - return_value=True, - ) as mock_setup: + aioclient_mock.get( + f"{API_BASE_URL}/{AutomowerEndpoint.mowers}", + text=load_fixture(fixture, DOMAIN), + exc=exception, + ) + with ( + patch( + "homeassistant.components.husqvarna_automower.async_setup_entry", + return_value=True, + ) as mock_setup, + ): await hass.config_entries.flow.async_configure(result["flow_id"]) assert len(hass.config_entries.async_entries(DOMAIN)) == amount From 9a999e87422658b9fc1b66d9e5062ab6d393ca8e Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 26 Nov 2024 12:30:50 +0100 Subject: [PATCH 0884/1070] Use ConfigEntry runtime_data in Garages Amsterdam (#131611) --- .../components/garages_amsterdam/__init__.py | 18 ++++++++---------- .../garages_amsterdam/binary_sensor.py | 10 +++------- .../components/garages_amsterdam/sensor.py | 9 +++------ .../components/garages_amsterdam/test_init.py | 2 -- 4 files changed, 14 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/garages_amsterdam/__init__.py b/homeassistant/components/garages_amsterdam/__init__.py index 4cdcc3f06be0b..99d751cfcc873 100644 --- a/homeassistant/components/garages_amsterdam/__init__.py +++ b/homeassistant/components/garages_amsterdam/__init__.py @@ -9,7 +9,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN from .coordinator import GaragesAmsterdamDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -17,24 +16,23 @@ type GaragesAmsterdamConfigEntry = ConfigEntry[GaragesAmsterdamDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: GaragesAmsterdamConfigEntry +) -> bool: """Set up Garages Amsterdam from a config entry.""" client = ODPAmsterdam(session=async_get_clientsession(hass)) coordinator = GaragesAmsterdamDataUpdateCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: GaragesAmsterdamConfigEntry +) -> bool: """Unload Garages Amsterdam config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if len(hass.config_entries.async_entries(DOMAIN)) == 1: - hass.data.pop(DOMAIN) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/garages_amsterdam/binary_sensor.py b/homeassistant/components/garages_amsterdam/binary_sensor.py index 2be8aaeffc036..7c763307ddf0f 100644 --- a/homeassistant/components/garages_amsterdam/binary_sensor.py +++ b/homeassistant/components/garages_amsterdam/binary_sensor.py @@ -6,12 +6,10 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import GaragesAmsterdamDataUpdateCoordinator +from . import GaragesAmsterdamConfigEntry from .entity import GaragesAmsterdamEntity BINARY_SENSORS = { @@ -21,13 +19,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GaragesAmsterdamConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" - coordinator: GaragesAmsterdamDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data async_add_entities( GaragesAmsterdamBinarySensor(coordinator, entry.data["garage_name"], info_type) diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py index 87c72f4a248a0..64450645fdd2d 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -3,11 +3,10 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import GaragesAmsterdamConfigEntry from .coordinator import GaragesAmsterdamDataUpdateCoordinator from .entity import GaragesAmsterdamEntity @@ -21,13 +20,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GaragesAmsterdamConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" - coordinator: GaragesAmsterdamDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data async_add_entities( GaragesAmsterdamSensor(coordinator, entry.data["garage_name"], info_type) diff --git a/tests/components/garages_amsterdam/test_init.py b/tests/components/garages_amsterdam/test_init.py index ff3166183a1c6..ed5469e5ff940 100644 --- a/tests/components/garages_amsterdam/test_init.py +++ b/tests/components/garages_amsterdam/test_init.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock -from homeassistant.components.garages_amsterdam.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,5 +23,4 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED From 3af751c129dc99ff02e293fe0bb6f027dd6385cc Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 26 Nov 2024 12:40:02 +0100 Subject: [PATCH 0885/1070] Fix SABnzbd number icon (#131615) --- homeassistant/components/sabnzbd/icons.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sabnzbd/icons.json b/homeassistant/components/sabnzbd/icons.json index 190aefe4b1250..b0a72040b4b98 100644 --- a/homeassistant/components/sabnzbd/icons.json +++ b/homeassistant/components/sabnzbd/icons.json @@ -6,7 +6,9 @@ }, "resume": { "default": "mdi:play" - }, + } + }, + "number": { "speedlimit": { "default": "mdi:speedometer" } From 41c7cc6e815834844e08721e9cc4e51d6d24d5f6 Mon Sep 17 00:00:00 2001 From: Lenn <78048721+LennP@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:54:50 +0100 Subject: [PATCH 0886/1070] Bump motionblindsble to 0.1.3 (#131613) --- homeassistant/components/motionblinds_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motionblinds_ble/manifest.json b/homeassistant/components/motionblinds_ble/manifest.json index ce7e7a6bb8be8..70cddce30a1c5 100644 --- a/homeassistant/components/motionblinds_ble/manifest.json +++ b/homeassistant/components/motionblinds_ble/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "assumed_state", "loggers": ["motionblindsble"], - "requirements": ["motionblindsble==0.1.2"] + "requirements": ["motionblindsble==0.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 436de711c41cf..9519ac823939d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1397,7 +1397,7 @@ mopeka-iot-ble==0.8.0 motionblinds==0.6.25 # homeassistant.components.motionblinds_ble -motionblindsble==0.1.2 +motionblindsble==0.1.3 # homeassistant.components.motioneye motioneye-client==0.3.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b3df47f8320b..82be50ab1ef49 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1166,7 +1166,7 @@ mopeka-iot-ble==0.8.0 motionblinds==0.6.25 # homeassistant.components.motionblinds_ble -motionblindsble==0.1.2 +motionblindsble==0.1.3 # homeassistant.components.motioneye motioneye-client==0.3.14 From b0b72326d8e05d0dffbf3a752a24cf1052402bcf Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:02:17 +0100 Subject: [PATCH 0887/1070] Add Update syrupy snapshots VScode task (#131536) * add Update syrupy snapshots task * don't use xdist --- .vscode/tasks.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 2495249af6621..2b02916a73ebb 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -87,6 +87,22 @@ }, "problemMatcher": [] }, + { + "label": "Update syrupy snapshots", + "detail": "Update syrupy snapshots for a given integration.", + "type": "shell", + "command": "python3 -m pytest ./tests/components/${input:integrationName} --snapshot-update", + "dependsOn": ["Compile English translations"], + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, { "label": "Generate Requirements", "type": "shell", From f5d323679f9a93ff7f55b988ceadb8d7a0f592e0 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 26 Nov 2024 13:07:32 +0100 Subject: [PATCH 0888/1070] Fix bug on creating entities with unknown state - Garages Amsterdam (#131619) --- homeassistant/components/garages_amsterdam/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py index 64450645fdd2d..4f262b6e6674a 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -29,7 +29,7 @@ async def async_setup_entry( async_add_entities( GaragesAmsterdamSensor(coordinator, entry.data["garage_name"], info_type) for info_type in SENSORS - if getattr(coordinator.data[entry.data["garage_name"]], info_type) != "" + if getattr(coordinator.data[entry.data["garage_name"]], info_type) is not None ) From 1ddc8a35c260cd1e6697c9de9dc1641c33ccd44d Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:14:59 +0100 Subject: [PATCH 0889/1070] Add test to validate HomeWizard updates discovery info (#131540) --- .../components/homewizard/test_config_flow.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 2e408072ca73b..84bdb0ba921de 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -263,6 +263,42 @@ async def test_discovery_invalid_api(hass: HomeAssistant) -> None: assert result["reason"] == "unsupported_api_version" +async def test_discovery_flow_updates_new_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test discovery setup updates new config data.""" + mock_config_entry.add_to_hass(hass) + + # preflight check, see if the ip address is already in use + assert mock_config_entry.data[CONF_IP_ADDRESS] == "127.0.0.1" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("1.0.0.127"), + ip_addresses=[ip_address("1.0.0.127")], + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "1", + "path": "/api/v1", + "product_name": "P1 Meter", + "product_type": "HWE-P1", + "serial": "5c2fafabcdef", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" + + @pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( ("exception", "reason"), From 1fc31946131571f69ebcc9ecac1d30574ad82b9c Mon Sep 17 00:00:00 2001 From: dotvav Date: Tue, 26 Nov 2024 14:07:37 +0100 Subject: [PATCH 0890/1070] Add diagnostics to Palazzetti (#131608) --- .../components/palazzetti/diagnostics.py | 20 ++++++++++++++++ .../components/palazzetti/quality_scale.yaml | 2 +- tests/components/palazzetti/conftest.py | 23 ++++++++++++++++++- .../snapshots/test_diagnostics.ambr | 13 +++++++++++ .../components/palazzetti/test_diagnostics.py | 22 ++++++++++++++++++ 5 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/palazzetti/diagnostics.py create mode 100644 tests/components/palazzetti/snapshots/test_diagnostics.ambr create mode 100644 tests/components/palazzetti/test_diagnostics.py diff --git a/homeassistant/components/palazzetti/diagnostics.py b/homeassistant/components/palazzetti/diagnostics.py new file mode 100644 index 0000000000000..3843f0ec11173 --- /dev/null +++ b/homeassistant/components/palazzetti/diagnostics.py @@ -0,0 +1,20 @@ +"""Provides diagnostics for Palazzetti.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import PalazzettiConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: PalazzettiConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + client = entry.runtime_data.client + + return { + "api_data": client.to_dict(redact=True), + } diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml index c8e19920dbe88..493b2595117c6 100644 --- a/homeassistant/components/palazzetti/quality_scale.yaml +++ b/homeassistant/components/palazzetti/quality_scale.yaml @@ -47,7 +47,7 @@ rules: test-coverage: todo # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done docs-data-update: todo diff --git a/tests/components/palazzetti/conftest.py b/tests/components/palazzetti/conftest.py index b36a58879c151..ec58afc324a4f 100644 --- a/tests/components/palazzetti/conftest.py +++ b/tests/components/palazzetti/conftest.py @@ -1,13 +1,14 @@ """Fixtures for Palazzetti integration tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from pypalazzetti.temperature import TemperatureDefinition, TemperatureDescriptionKey import pytest from homeassistant.components.palazzetti.const import DOMAIN from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -51,6 +52,12 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: mock_client.name = "Stove" mock_client.sw_version = "0.0.0" mock_client.hw_version = "1.1.1" + mock_client.to_dict.return_value = { + "host": "XXXXXXXXXX", + "connected": True, + "properties": {}, + "attributes": {}, + } mock_client.fan_speed_min = 1 mock_client.fan_speed_max = 5 mock_client.has_fan_silent = True @@ -111,3 +118,17 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: ), ] yield mock_client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_palazzetti_client: MagicMock, +) -> MockConfigEntry: + """Set up the Palazzetti integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/palazzetti/snapshots/test_diagnostics.ambr b/tests/components/palazzetti/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..e3f2d7430e53a --- /dev/null +++ b/tests/components/palazzetti/snapshots/test_diagnostics.ambr @@ -0,0 +1,13 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'api_data': dict({ + 'attributes': dict({ + }), + 'connected': True, + 'host': 'XXXXXXXXXX', + 'properties': dict({ + }), + }), + }) +# --- diff --git a/tests/components/palazzetti/test_diagnostics.py b/tests/components/palazzetti/test_diagnostics.py new file mode 100644 index 0000000000000..80d021be511d7 --- /dev/null +++ b/tests/components/palazzetti/test_diagnostics.py @@ -0,0 +1,22 @@ +"""Test Palazzetti diagnostics.""" + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + init_integration: MockConfigEntry, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From 9510ef56f9a6399d5c0894d36410288667b83209 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 26 Nov 2024 14:39:21 +0100 Subject: [PATCH 0891/1070] Add configuration url to SABnzbd device info (#131617) --- homeassistant/components/sabnzbd/entity.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/sabnzbd/entity.py b/homeassistant/components/sabnzbd/entity.py index 946e1de447661..60a2eb8d25101 100644 --- a/homeassistant/components/sabnzbd/entity.py +++ b/homeassistant/components/sabnzbd/entity.py @@ -1,5 +1,6 @@ """Base entity for Sabnzbd.""" +from homeassistant.const import CONF_URL from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -28,4 +29,5 @@ def __init__( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry_id)}, sw_version=coordinator.data["version"], + configuration_url=coordinator.config_entry.data[CONF_URL], ) From 147679f8034a7d286a21111d4a42e313fa7898b7 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 26 Nov 2024 14:20:25 +0000 Subject: [PATCH 0892/1070] Add live view camera entity to ring integration (#127579) --- homeassistant/components/ring/__init__.py | 89 ++++--- homeassistant/components/ring/camera.py | 119 ++++++++-- homeassistant/components/ring/const.py | 2 +- homeassistant/components/ring/strings.json | 8 + .../ring/snapshots/test_camera.ambr | 215 ++++++++++++++--- tests/components/ring/test_camera.py | 220 +++++++++++++++--- tests/components/ring/test_init.py | 37 +-- 7 files changed, 579 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index b2340b3455613..edc084fb57bc5 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -9,6 +9,7 @@ from ring_doorbell import Auth, Ring, RingDevices +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import APPLICATION_NAME, CONF_DEVICE_ID, CONF_TOKEN from homeassistant.core import HomeAssistant, callback @@ -70,8 +71,6 @@ def listen_credentials_updater(token: dict[str, Any]) -> None: ) ring = Ring(auth) - await _migrate_old_unique_ids(hass, entry.entry_id) - devices_coordinator = RingDataCoordinator(hass, ring) listen_credentials = entry.data.get(CONF_LISTEN_CREDENTIALS) listen_coordinator = RingListenCoordinator( @@ -104,42 +103,46 @@ async def async_remove_config_entry_device( return True -async def _migrate_old_unique_ids(hass: HomeAssistant, entry_id: str) -> None: - entity_registry = er.async_get(hass) - - @callback - def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, str] | None: - # Old format for camera and light was int - unique_id = cast(str | int, entity_entry.unique_id) - if isinstance(unique_id, int): - new_unique_id = str(unique_id) - if existing_entity_id := entity_registry.async_get_entity_id( - entity_entry.domain, entity_entry.platform, new_unique_id - ): - _LOGGER.error( - "Cannot migrate to unique_id '%s', already exists for '%s', " - "You may have to delete unavailable ring entities", - new_unique_id, - existing_entity_id, - ) - return None - _LOGGER.debug("Fixing non string unique id %s", entity_entry.unique_id) - return {"new_unique_id": new_unique_id} - return None - - await er.async_migrate_entries(hass, entry_id, _async_migrator) - - async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old config entry.""" entry_version = entry.version entry_minor_version = entry.minor_version + entry_id = entry.entry_id new_minor_version = 2 if entry_version == 1 and entry_minor_version == 1: _LOGGER.debug( "Migrating from version %s.%s", entry_version, entry_minor_version ) + # Migrate non-str unique ids + # This step used to run unconditionally from async_setup_entry + entity_registry = er.async_get(hass) + + @callback + def _async_str_unique_id_migrator( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + # Old format for camera and light was int + unique_id = cast(str | int, entity_entry.unique_id) + if isinstance(unique_id, int): + new_unique_id = str(unique_id) + if existing_entity_id := entity_registry.async_get_entity_id( + entity_entry.domain, entity_entry.platform, new_unique_id + ): + _LOGGER.error( + "Cannot migrate to unique_id '%s', already exists for '%s', " + "You may have to delete unavailable ring entities", + new_unique_id, + existing_entity_id, + ) + return None + _LOGGER.debug("Fixing non string unique id %s", entity_entry.unique_id) + return {"new_unique_id": new_unique_id} + return None + + await er.async_migrate_entries(hass, entry_id, _async_str_unique_id_migrator) + + # Migrate the hardware id hardware_id = str(uuid.uuid4()) hass.config_entries.async_update_entry( entry, @@ -149,4 +152,34 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug( "Migration to version %s.%s complete", entry_version, new_minor_version ) + + entry_minor_version = entry.minor_version + new_minor_version = 3 + if entry_version == 1 and entry_minor_version == 2: + _LOGGER.debug( + "Migrating from version %s.%s", entry_version, entry_minor_version + ) + + @callback + def _async_camera_unique_id_migrator( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + # Migrate camera unique ids to append -last + if entity_entry.domain == CAMERA_DOMAIN and not isinstance( + cast(str | int, entity_entry.unique_id), int + ): + new_unique_id = f"{entity_entry.unique_id}-last_recording" + return {"new_unique_id": new_unique_id} + return None + + await er.async_migrate_entries(hass, entry_id, _async_camera_unique_id_migrator) + + hass.config_entries.async_update_entry( + entry, + minor_version=new_minor_version, + ) + _LOGGER.debug( + "Migration to version %s.%s complete", entry_version, new_minor_version + ) + return True diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 9c66df9d89e0b..ccd91c163d658 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -2,24 +2,37 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from datetime import timedelta import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Generic from aiohttp import web from haffmpeg.camera import CameraMjpeg from ring_doorbell import RingDoorBell +from ring_doorbell.webrtcstream import RingWebRtcMessage from homeassistant.components import ffmpeg -from homeassistant.components.camera import Camera +from homeassistant.components.camera import ( + Camera, + CameraEntityDescription, + CameraEntityFeature, + RTCIceCandidateInit, + WebRTCAnswer, + WebRTCCandidate, + WebRTCError, + WebRTCSendMessage, +) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from . import RingConfigEntry from .coordinator import RingDataCoordinator -from .entity import RingEntity, exception_wrap +from .entity import RingDeviceT, RingEntity, exception_wrap FORCE_REFRESH_INTERVAL = timedelta(minutes=3) MOTION_DETECTION_CAPABILITY = "motion_detection" @@ -27,6 +40,34 @@ _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class RingCameraEntityDescription(CameraEntityDescription, Generic[RingDeviceT]): + """Base class for event entity description.""" + + exists_fn: Callable[[RingDoorBell], bool] + live_stream: bool + motion_detection: bool + + +CAMERA_DESCRIPTIONS: tuple[RingCameraEntityDescription, ...] = ( + RingCameraEntityDescription( + key="live_view", + translation_key="live_view", + exists_fn=lambda _: True, + live_stream=True, + motion_detection=False, + ), + RingCameraEntityDescription( + key="last_recording", + translation_key="last_recording", + entity_registry_enabled_default=False, + exists_fn=lambda camera: camera.has_subscription, + live_stream=False, + motion_detection=True, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, @@ -38,9 +79,10 @@ async def async_setup_entry( ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) cams = [ - RingCam(camera, devices_coordinator, ffmpeg_manager) + RingCam(camera, devices_coordinator, description, ffmpeg_manager=ffmpeg_manager) + for description in CAMERA_DESCRIPTIONS for camera in ring_data.devices.video_devices - if camera.has_subscription + if description.exists_fn(camera) ] async_add_entities(cams) @@ -49,26 +91,31 @@ async def async_setup_entry( class RingCam(RingEntity[RingDoorBell], Camera): """An implementation of a Ring Door Bell camera.""" - _attr_name = None - def __init__( self, device: RingDoorBell, coordinator: RingDataCoordinator, + description: RingCameraEntityDescription, + *, ffmpeg_manager: ffmpeg.FFmpegManager, ) -> None: """Initialize a Ring Door Bell camera.""" super().__init__(device, coordinator) + self.entity_description = description Camera.__init__(self) self._ffmpeg_manager = ffmpeg_manager self._last_event: dict[str, Any] | None = None self._last_video_id: int | None = None self._video_url: str | None = None - self._image: bytes | None = None + self._images: dict[tuple[int | None, int | None], bytes] = {} self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL - self._attr_unique_id = str(device.id) - if device.has_capability(MOTION_DETECTION_CAPABILITY): + self._attr_unique_id = f"{device.id}-{description.key}" + if description.motion_detection and device.has_capability( + MOTION_DETECTION_CAPABILITY + ): self._attr_motion_detection_enabled = device.motion_detection + if description.live_stream: + self._attr_supported_features |= CameraEntityFeature.STREAM @callback def _handle_coordinator_update(self) -> None: @@ -86,7 +133,7 @@ def _handle_coordinator_update(self) -> None: self._last_event = None self._last_video_id = None self._video_url = None - self._image = None + self._images = {} self._expires_at = dt_util.utcnow() self.async_write_ha_state() @@ -102,7 +149,8 @@ async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - if self._image is None and self._video_url is not None: + key = (width, height) + if not (image := self._images.get(key)) and self._video_url is not None: image = await ffmpeg.async_get_image( self.hass, self._video_url, @@ -111,9 +159,9 @@ async def async_camera_image( ) if image: - self._image = image + self._images[key] = image - return self._image + return image async def handle_async_mjpeg_stream( self, request: web.Request @@ -136,6 +184,47 @@ async def handle_async_mjpeg_stream( finally: await stream.close() + async def async_handle_async_webrtc_offer( + self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage + ) -> None: + """Return the source of the stream.""" + + def message_wrapper(ring_message: RingWebRtcMessage) -> None: + if ring_message.error_code: + msg = ring_message.error_message or "" + send_message(WebRTCError(ring_message.error_code, msg)) + elif ring_message.answer: + send_message(WebRTCAnswer(ring_message.answer)) + elif ring_message.candidate: + send_message( + WebRTCCandidate( + RTCIceCandidateInit( + ring_message.candidate, + sdp_m_line_index=ring_message.sdp_m_line_index or 0, + ) + ) + ) + + return await self._device.generate_async_webrtc_stream( + offer_sdp, session_id, message_wrapper, keep_alive_timeout=None + ) + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidateInit + ) -> None: + """Handle a WebRTC candidate.""" + if candidate.sdp_m_line_index is None: + msg = "The sdp_m_line_index is required for ring webrtc streaming" + raise HomeAssistantError(msg) + await self._device.on_webrtc_candidate( + session_id, candidate.candidate, candidate.sdp_m_line_index + ) + + @callback + def close_webrtc_session(self, session_id: str) -> None: + """Close a WebRTC session.""" + self._device.sync_close_webrtc_stream(session_id) + async def async_update(self) -> None: """Update camera entity and refresh attributes.""" if ( @@ -157,7 +246,7 @@ async def async_update(self) -> None: return if self._last_video_id != self._last_event["id"]: - self._image = None + self._images = {} self._video_url = await self._async_get_video() diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py index 9595241ebb142..68ac00d69f688 100644 --- a/homeassistant/components/ring/const.py +++ b/homeassistant/components/ring/const.py @@ -33,4 +33,4 @@ CONF_2FA = "2fa" CONF_LISTEN_CREDENTIALS = "listen_token" -CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 2 +CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 3 diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 0887e4112c628..8170ec8e161fc 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -124,6 +124,14 @@ "motion_detection": { "name": "Motion detection" } + }, + "camera": { + "live_view": { + "name": "Live view" + }, + "last_recording": { + "name": "Last recording" + } } }, "issues": { diff --git a/tests/components/ring/snapshots/test_camera.ambr b/tests/components/ring/snapshots/test_camera.ambr index 4347f302c72b4..ec285b438b3bb 100644 --- a/tests/components/ring/snapshots/test_camera.ambr +++ b/tests/components/ring/snapshots/test_camera.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_states[camera.front-entry] +# name: test_states[camera.front_door_last_recording-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,7 +11,7 @@ 'disabled_by': None, 'domain': 'camera', 'entity_category': None, - 'entity_id': 'camera.front', + 'entity_id': 'camera.front_door_last_recording', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -23,35 +23,36 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Last recording', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '765432', + 'translation_key': 'last_recording', + 'unique_id': '987654-last_recording', 'unit_of_measurement': None, }) # --- -# name: test_states[camera.front-state] +# name: test_states[camera.front_door_last_recording-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'access_token': '1caab5c3b3', 'attribution': 'Data provided by Ring.com', - 'entity_picture': '/api/camera_proxy/camera.front?token=1caab5c3b3', - 'friendly_name': 'Front', + 'entity_picture': '/api/camera_proxy/camera.front_door_last_recording?token=1caab5c3b3', + 'friendly_name': 'Front Door Last recording', 'last_video_id': None, + 'motion_detection': True, 'supported_features': , 'video_url': None, }), 'context': , - 'entity_id': 'camera.front', + 'entity_id': 'camera.front_door_last_recording', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'idle', }) # --- -# name: test_states[camera.front_door-entry] +# name: test_states[camera.front_door_live_view-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -63,7 +64,7 @@ 'disabled_by': None, 'domain': 'camera', 'entity_category': None, - 'entity_id': 'camera.front_door', + 'entity_id': 'camera.front_door_live_view', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -75,36 +76,88 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Live view', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'live_view', + 'unique_id': '987654-live_view', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[camera.front_door_live_view-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'attribution': 'Data provided by Ring.com', + 'entity_picture': '/api/camera_proxy/camera.front_door_live_view?token=1caab5c3b3', + 'friendly_name': 'Front Door Live view', + 'frontend_stream_type': , + 'last_video_id': None, + 'supported_features': , + 'video_url': None, + }), + 'context': , + 'entity_id': 'camera.front_door_live_view', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_states[camera.front_last_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.front_last_recording', + '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': 'Last recording', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '987654', + 'translation_key': 'last_recording', + 'unique_id': '765432-last_recording', 'unit_of_measurement': None, }) # --- -# name: test_states[camera.front_door-state] +# name: test_states[camera.front_last_recording-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'access_token': '1caab5c3b3', 'attribution': 'Data provided by Ring.com', - 'entity_picture': '/api/camera_proxy/camera.front_door?token=1caab5c3b3', - 'friendly_name': 'Front Door', + 'entity_picture': '/api/camera_proxy/camera.front_last_recording?token=1caab5c3b3', + 'friendly_name': 'Front Last recording', 'last_video_id': None, - 'motion_detection': True, 'supported_features': , 'video_url': None, }), 'context': , - 'entity_id': 'camera.front_door', + 'entity_id': 'camera.front_last_recording', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'idle', }) # --- -# name: test_states[camera.internal-entry] +# name: test_states[camera.front_live_view-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -116,7 +169,7 @@ 'disabled_by': None, 'domain': 'camera', 'entity_category': None, - 'entity_id': 'camera.internal', + 'entity_id': 'camera.front_live_view', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -128,29 +181,135 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Live view', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'live_view', + 'unique_id': '765432-live_view', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[camera.front_live_view-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'attribution': 'Data provided by Ring.com', + 'entity_picture': '/api/camera_proxy/camera.front_live_view?token=1caab5c3b3', + 'friendly_name': 'Front Live view', + 'frontend_stream_type': , + 'last_video_id': None, + 'supported_features': , + 'video_url': None, + }), + 'context': , + 'entity_id': 'camera.front_live_view', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_states[camera.internal_last_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.internal_last_recording', + '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': 'Last recording', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '345678', + 'translation_key': 'last_recording', + 'unique_id': '345678-last_recording', 'unit_of_measurement': None, }) # --- -# name: test_states[camera.internal-state] +# name: test_states[camera.internal_last_recording-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'access_token': '1caab5c3b3', 'attribution': 'Data provided by Ring.com', - 'entity_picture': '/api/camera_proxy/camera.internal?token=1caab5c3b3', - 'friendly_name': 'Internal', + 'entity_picture': '/api/camera_proxy/camera.internal_last_recording?token=1caab5c3b3', + 'friendly_name': 'Internal Last recording', 'last_video_id': None, 'motion_detection': True, 'supported_features': , 'video_url': None, }), 'context': , - 'entity_id': 'camera.internal', + 'entity_id': 'camera.internal_last_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_states[camera.internal_live_view-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.internal_live_view', + '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': 'Live view', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'live_view', + 'unique_id': '345678-live_view', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[camera.internal_live_view-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'attribution': 'Data provided by Ring.com', + 'entity_picture': '/api/camera_proxy/camera.internal_live_view?token=1caab5c3b3', + 'friendly_name': 'Internal Live view', + 'frontend_stream_type': , + 'last_video_id': None, + 'supported_features': , + 'video_url': None, + }), + 'context': , + 'entity_id': 'camera.internal_live_view', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/ring/test_camera.py b/tests/components/ring/test_camera.py index 94ddc335dac45..4b4f019fdf765 100644 --- a/tests/components/ring/test_camera.py +++ b/tests/components/ring/test_camera.py @@ -1,14 +1,22 @@ """The tests for the Ring switch platform.""" +import logging from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import make_mocked_request from freezegun.api import FrozenDateTimeFactory import pytest import ring_doorbell +from ring_doorbell.webrtcstream import RingWebRtcMessage from syrupy.assertion import SnapshotAssertion -from homeassistant.components import camera +from homeassistant.components.camera import ( + CameraEntityFeature, + StreamType, + async_get_image, + async_get_mjpeg_stream, + get_camera_from_entity_id, +) from homeassistant.components.ring.camera import FORCE_REFRESH_INTERVAL from homeassistant.components.ring.const import SCAN_INTERVAL from homeassistant.config_entries import SOURCE_REAUTH @@ -19,8 +27,10 @@ from homeassistant.util.aiohttp import MockStreamReader from .common import MockConfigEntry, setup_platform +from .device_mocks import FRONT_DEVICE_ID from tests.common import async_fire_time_changed, snapshot_platform +from tests.typing import WebSocketGenerator SMALLEST_VALID_JPEG = ( "ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060" @@ -30,6 +40,7 @@ SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_states( hass: HomeAssistant, mock_ring_client: Mock, @@ -48,11 +59,12 @@ async def test_states( @pytest.mark.parametrize( ("entity_name", "expected_state", "friendly_name"), [ - ("camera.internal", True, "Internal"), - ("camera.front", None, "Front"), + ("camera.internal_last_recording", True, "Internal Last recording"), + ("camera.front_last_recording", None, "Front Last recording"), ], ids=["On", "Off"], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_camera_motion_detection_state_reports_correctly( hass: HomeAssistant, mock_ring_client, @@ -68,40 +80,43 @@ async def test_camera_motion_detection_state_reports_correctly( assert state.attributes.get("friendly_name") == friendly_name +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_camera_motion_detection_can_be_turned_on_and_off( - hass: HomeAssistant, mock_ring_client + hass: HomeAssistant, + mock_ring_client, ) -> None: """Tests the siren turns on correctly.""" await setup_platform(hass, Platform.CAMERA) - state = hass.states.get("camera.front") + state = hass.states.get("camera.front_last_recording") assert state.attributes.get("motion_detection") is not True await hass.services.async_call( "camera", "enable_motion_detection", - {"entity_id": "camera.front"}, + {"entity_id": "camera.front_last_recording"}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("camera.front") + state = hass.states.get("camera.front_last_recording") assert state.attributes.get("motion_detection") is True await hass.services.async_call( "camera", "disable_motion_detection", - {"entity_id": "camera.front"}, + {"entity_id": "camera.front_last_recording"}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("camera.front") + state = hass.states.get("camera.front_last_recording") assert state.attributes.get("motion_detection") is None +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_camera_motion_detection_not_supported( hass: HomeAssistant, mock_ring_client, @@ -121,21 +136,22 @@ def _has_capability(capability): await setup_platform(hass, Platform.CAMERA) - state = hass.states.get("camera.front") + state = hass.states.get("camera.front_last_recording") assert state.attributes.get("motion_detection") is None await hass.services.async_call( "camera", "enable_motion_detection", - {"entity_id": "camera.front"}, + {"entity_id": "camera.front_last_recording"}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("camera.front") + state = hass.states.get("camera.front_last_recording") assert state.attributes.get("motion_detection") is None assert ( - "Entity camera.front does not have motion detection capability" in caplog.text + "Entity camera.front_last_recording does not have motion detection capability" + in caplog.text ) @@ -148,6 +164,7 @@ def _has_capability(capability): ], ids=["Authentication", "Timeout", "Other"], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_motion_detection_errors_when_turned_on( hass: HomeAssistant, mock_ring_client, @@ -168,7 +185,7 @@ async def test_motion_detection_errors_when_turned_on( await hass.services.async_call( "camera", "enable_motion_detection", - {"entity_id": "camera.front"}, + {"entity_id": "camera.front_last_recording"}, blocking=True, ) await hass.async_block_till_done() @@ -183,6 +200,7 @@ async def test_motion_detection_errors_when_turned_on( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_camera_handle_mjpeg_stream( hass: HomeAssistant, mock_ring_client, @@ -195,7 +213,7 @@ async def test_camera_handle_mjpeg_stream( front_camera_mock = mock_ring_devices.get_device(765432) front_camera_mock.async_recording_url.return_value = None - state = hass.states.get("camera.front") + state = hass.states.get("camera.front_last_recording") assert state is not None mock_request = make_mocked_request("GET", "/", headers={"token": "x"}) @@ -203,7 +221,9 @@ async def test_camera_handle_mjpeg_stream( # history not updated yet front_camera_mock.async_history.assert_not_called() front_camera_mock.async_recording_url.assert_not_called() - stream = await camera.async_get_mjpeg_stream(hass, mock_request, "camera.front") + stream = await async_get_mjpeg_stream( + hass, mock_request, "camera.front_last_recording" + ) assert stream is None # Video url will be none so no stream @@ -211,9 +231,11 @@ async def test_camera_handle_mjpeg_stream( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) front_camera_mock.async_history.assert_called_once() - front_camera_mock.async_recording_url.assert_called_once() + front_camera_mock.async_recording_url.assert_called() - stream = await camera.async_get_mjpeg_stream(hass, mock_request, "camera.front") + stream = await async_get_mjpeg_stream( + hass, mock_request, "camera.front_last_recording" + ) assert stream is None # Stop the history updating so we can update the values manually @@ -222,8 +244,10 @@ async def test_camera_handle_mjpeg_stream( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - front_camera_mock.async_recording_url.assert_called_once() - stream = await camera.async_get_mjpeg_stream(hass, mock_request, "camera.front") + front_camera_mock.async_recording_url.assert_called() + stream = await async_get_mjpeg_stream( + hass, mock_request, "camera.front_last_recording" + ) assert stream is None # If the history id hasn't changed the camera will not check again for the video url @@ -235,13 +259,15 @@ async def test_camera_handle_mjpeg_stream( await hass.async_block_till_done(wait_background_tasks=True) front_camera_mock.async_recording_url.assert_not_called() - stream = await camera.async_get_mjpeg_stream(hass, mock_request, "camera.front") + stream = await async_get_mjpeg_stream( + hass, mock_request, "camera.front_last_recording" + ) assert stream is None freezer.tick(FORCE_REFRESH_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - front_camera_mock.async_recording_url.assert_called_once() + front_camera_mock.async_recording_url.assert_called() # Now the stream should be returned stream_reader = MockStreamReader(SMALLEST_VALID_JPEG_BYTES) @@ -250,7 +276,9 @@ async def test_camera_handle_mjpeg_stream( mock_camera.return_value.open_camera = AsyncMock() mock_camera.return_value.close = AsyncMock() - stream = await camera.async_get_mjpeg_stream(hass, mock_request, "camera.front") + stream = await async_get_mjpeg_stream( + hass, mock_request, "camera.front_last_recording" + ) assert stream is not None # Check the stream has been read assert not await stream_reader.read(-1) @@ -267,7 +295,7 @@ async def test_camera_image( front_camera_mock = mock_ring_devices.get_device(765432) - state = hass.states.get("camera.front") + state = hass.states.get("camera.front_live_view") assert state is not None # history not updated yet @@ -280,7 +308,7 @@ async def test_camera_image( ), pytest.raises(HomeAssistantError), ): - image = await camera.async_get_image(hass, "camera.front") + image = await async_get_image(hass, "camera.front_live_view") freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) @@ -293,5 +321,145 @@ async def test_camera_image( "homeassistant.components.ring.camera.ffmpeg.async_get_image", return_value=SMALLEST_VALID_JPEG_BYTES, ): - image = await camera.async_get_image(hass, "camera.front") + image = await async_get_image(hass, "camera.front_live_view") assert image.content == SMALLEST_VALID_JPEG_BYTES + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_camera_stream_attributes( + hass: HomeAssistant, + mock_ring_client: Mock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test stream attributes.""" + await setup_platform(hass, Platform.CAMERA) + + # Live view + state = hass.states.get("camera.front_live_view") + supported_features = state.attributes.get("supported_features") + assert supported_features is CameraEntityFeature.STREAM + camera = get_camera_from_entity_id(hass, "camera.front_live_view") + assert camera.camera_capabilities.frontend_stream_types == {StreamType.WEB_RTC} + + # Last recording + state = hass.states.get("camera.front_last_recording") + supported_features = state.attributes.get("supported_features") + assert supported_features is CameraEntityFeature(0) + camera = get_camera_from_entity_id(hass, "camera.front_last_recording") + assert camera.camera_capabilities.frontend_stream_types == set() + + +async def test_camera_webrtc( + hass: HomeAssistant, + mock_ring_client: Mock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_ring_devices, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test WebRTC interactions.""" + caplog.set_level(logging.ERROR) + await setup_platform(hass, Platform.CAMERA) + client = await hass_ws_client(hass) + + # sdp offer + await client.send_json_auto_id( + { + "type": "camera/webrtc/offer", + "entity_id": "camera.front_live_view", + "offer": "v=0\r\n", + } + ) + response = await client.receive_json() + assert response + assert response.get("success") is True + subscription_id = response["id"] + assert not caplog.text + + front_camera_mock = mock_ring_devices.get_device(FRONT_DEVICE_ID) + front_camera_mock.generate_async_webrtc_stream.assert_called_once() + args = front_camera_mock.generate_async_webrtc_stream.call_args.args + session_id = args[1] + on_message = args[2] + + # receive session + response = await client.receive_json() + event = response.get("event") + assert event + assert event.get("type") == "session" + assert not caplog.text + + # Ring candidate + on_message(RingWebRtcMessage(candidate="candidate", sdp_m_line_index=1)) + response = await client.receive_json() + event = response.get("event") + assert event + assert event.get("type") == "candidate" + assert not caplog.text + + # Error message + on_message(RingWebRtcMessage(error_code=1, error_message="error")) + response = await client.receive_json() + event = response.get("event") + assert event + assert event.get("type") == "error" + assert not caplog.text + + # frontend candidate + await client.send_json_auto_id( + { + "type": "camera/webrtc/candidate", + "entity_id": "camera.front_live_view", + "session_id": session_id, + "candidate": {"candidate": "candidate", "sdpMLineIndex": 1}, + } + ) + response = await client.receive_json() + assert response + assert response.get("success") is True + assert not caplog.text + front_camera_mock.on_webrtc_candidate.assert_called_once() + + # Invalid frontend candidate + await client.send_json_auto_id( + { + "type": "camera/webrtc/candidate", + "entity_id": "camera.front_live_view", + "session_id": session_id, + "candidate": {"candidate": "candidate", "sdpMid": "1"}, + } + ) + response = await client.receive_json() + assert response + assert response.get("success") is False + assert response["error"]["code"] == "home_assistant_error" + msg = "The sdp_m_line_index is required for ring webrtc streaming" + assert msg in response["error"].get("message") + assert msg in caplog.text + front_camera_mock.on_webrtc_candidate.assert_called_once() + + # Answer message + caplog.clear() + on_message(RingWebRtcMessage(answer="v=0\r\n")) + response = await client.receive_json() + event = response.get("event") + assert event + assert event.get("type") == "answer" + assert not caplog.text + + # Unsubscribe/Close session + front_camera_mock.sync_close_webrtc_stream.assert_not_called() + await client.send_json_auto_id( + { + "type": "unsubscribe_events", + "subscription": subscription_id, + } + ) + + response = await client.receive_json() + assert response + assert response.get("success") is True + front_camera_mock.sync_close_webrtc_stream.assert_called_once() diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 1b5ee68c659c3..27d4813f02d1b 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -11,7 +11,11 @@ from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.ring import DOMAIN -from homeassistant.components.ring.const import CONF_LISTEN_CREDENTIALS, SCAN_INTERVAL +from homeassistant.components.ring.const import ( + CONF_CONFIG_ENTRY_MINOR_VERSION, + CONF_LISTEN_CREDENTIALS, + SCAN_INTERVAL, +) from homeassistant.components.ring.coordinator import RingEventListener from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_TOKEN, CONF_USERNAME @@ -237,15 +241,14 @@ async def test_error_on_device_update( @pytest.mark.parametrize( - ("domain", "old_unique_id"), + ("domain", "old_unique_id", "new_unique_id"), [ - ( - LIGHT_DOMAIN, - 123456, - ), - ( + pytest.param(LIGHT_DOMAIN, 123456, "123456", id="Light integer"), + pytest.param( CAMERA_DOMAIN, 654321, + "654321-last_recording", + id="Camera integer", ), ], ) @@ -256,6 +259,7 @@ async def test_update_unique_id( mock_ring_client, domain: str, old_unique_id: int | str, + new_unique_id: str, ) -> None: """Test unique_id update of integration.""" entry = MockConfigEntry( @@ -266,6 +270,7 @@ async def test_update_unique_id( "token": {"access_token": "mock-token"}, }, unique_id="foo@bar.com", + minor_version=1, ) entry.add_to_hass(hass) @@ -281,8 +286,9 @@ async def test_update_unique_id( entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated - assert entity_migrated.unique_id == str(old_unique_id) + assert entity_migrated.unique_id == new_unique_id assert (f"Fixing non string unique id {old_unique_id}") in caplog.text + assert entry.minor_version == CONF_CONFIG_ENTRY_MINOR_VERSION async def test_update_unique_id_existing( @@ -301,6 +307,7 @@ async def test_update_unique_id_existing( "token": {"access_token": "mock-token"}, }, unique_id="foo@bar.com", + minor_version=1, ) entry.add_to_hass(hass) @@ -331,16 +338,17 @@ async def test_update_unique_id_existing( f"already exists for '{entity_existing.entity_id}', " "You may have to delete unavailable ring entities" ) in caplog.text + assert entry.minor_version == CONF_CONFIG_ENTRY_MINOR_VERSION -async def test_update_unique_id_no_update( +async def test_update_unique_id_camera_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, caplog: pytest.LogCaptureFixture, mock_ring_client, ) -> None: - """Test unique_id update of integration.""" - correct_unique_id = "123456" + """Test camera unique id with no suffix is updated.""" + correct_unique_id = "123456-last_recording" entry = MockConfigEntry( title="Ring", domain=DOMAIN, @@ -349,6 +357,7 @@ async def test_update_unique_id_no_update( "token": {"access_token": "mock-token"}, }, unique_id="foo@bar.com", + minor_version=1, ) entry.add_to_hass(hass) @@ -358,14 +367,16 @@ async def test_update_unique_id_no_update( unique_id="123456", config_entry=entry, ) - assert entity.unique_id == correct_unique_id + assert entity.unique_id == "123456" assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated assert entity_migrated.unique_id == correct_unique_id + assert entity.disabled is False assert "Fixing non string unique id" not in caplog.text + assert entry.minor_version == CONF_CONFIG_ENTRY_MINOR_VERSION async def test_token_updated( @@ -477,7 +488,7 @@ async def test_migrate_create_device_id( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert entry.minor_version == 2 + assert entry.minor_version == CONF_CONFIG_ENTRY_MINOR_VERSION assert CONF_DEVICE_ID in entry.data assert entry.data[CONF_DEVICE_ID] == MOCK_HARDWARE_ID From ee74a3541734593b6799a8cd4408a4f12eaa83a7 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Tue, 26 Nov 2024 15:37:31 +0100 Subject: [PATCH 0893/1070] Support time entities in time conditions (#124575) Co-authored-by: Mark Bergsma --- homeassistant/helpers/condition.py | 24 +++++-- homeassistant/helpers/config_validation.py | 4 +- tests/helpers/test_condition.py | 79 ++++++++++++++++++++++ 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 86965f86d4024..5952e28a1eb9b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -821,9 +821,15 @@ def time( after_entity.attributes.get("minute", 59), after_entity.attributes.get("second", 59), ) - elif after_entity.attributes.get( - ATTR_DEVICE_CLASS - ) == SensorDeviceClass.TIMESTAMP and after_entity.state not in ( + elif after_entity.domain == "time" and after_entity.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + after = datetime.strptime(after_entity.state, "%H:%M:%S").time() + elif ( + after_entity.attributes.get(ATTR_DEVICE_CLASS) + == SensorDeviceClass.TIMESTAMP + ) and after_entity.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, ): @@ -845,9 +851,15 @@ def time( before_entity.attributes.get("minute", 59), before_entity.attributes.get("second", 59), ) - elif before_entity.attributes.get( - ATTR_DEVICE_CLASS - ) == SensorDeviceClass.TIMESTAMP and before_entity.state not in ( + elif before_entity.domain == "time": + try: + before = datetime.strptime(before_entity.state, "%H:%M:%S").time() + except ValueError: + return False + elif ( + before_entity.attributes.get(ATTR_DEVICE_CLASS) + == SensorDeviceClass.TIMESTAMP + ) and before_entity.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, ): diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 2b35ebade761b..3681e941eee8d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1574,10 +1574,10 @@ def STATE_CONDITION_SCHEMA(value: Any) -> dict[str, Any]: **CONDITION_BASE_SCHEMA, vol.Required(CONF_CONDITION): "time", vol.Optional("before"): vol.Any( - time, vol.All(str, entity_domain(["input_datetime", "sensor"])) + time, vol.All(str, entity_domain(["input_datetime", "time", "sensor"])) ), vol.Optional("after"): vol.Any( - time, vol.All(str, entity_domain(["input_datetime", "sensor"])) + time, vol.All(str, entity_domain(["input_datetime", "time", "sensor"])) ), vol.Optional("weekday"): weekdays, } diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 31f813469cc55..1ec78b2053573 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -15,6 +15,8 @@ CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, + STATE_UNAVAILABLE, + STATE_UNKNOWN, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) @@ -992,6 +994,83 @@ async def test_time_using_input_datetime(hass: HomeAssistant) -> None: condition.time(hass, before="input_datetime.not_existing") +async def test_time_using_time(hass: HomeAssistant) -> None: + """Test time conditions using time entities.""" + hass.states.async_set( + "time.am", + "06:00:00", # 6 am local time + ) + hass.states.async_set( + "time.pm", + "18:00:00", # 6 pm local time + ) + hass.states.async_set( + "time.unknown_state", + STATE_UNKNOWN, + ) + hass.states.async_set( + "time.unavailable_state", + STATE_UNAVAILABLE, + ) + + with patch( + "homeassistant.helpers.condition.dt_util.now", + return_value=dt_util.now().replace(hour=3), + ): + assert not condition.time(hass, after="time.am", before="time.pm") + assert condition.time(hass, after="time.pm", before="time.am") + + with patch( + "homeassistant.helpers.condition.dt_util.now", + return_value=dt_util.now().replace(hour=9), + ): + assert condition.time(hass, after="time.am", before="time.pm") + assert not condition.time(hass, after="time.pm", before="time.am") + + with patch( + "homeassistant.helpers.condition.dt_util.now", + return_value=dt_util.now().replace(hour=15), + ): + assert condition.time(hass, after="time.am", before="time.pm") + assert not condition.time(hass, after="time.pm", before="time.am") + + with patch( + "homeassistant.helpers.condition.dt_util.now", + return_value=dt_util.now().replace(hour=21), + ): + assert not condition.time(hass, after="time.am", before="time.pm") + assert condition.time(hass, after="time.pm", before="time.am") + + # Trigger on PM time + with patch( + "homeassistant.helpers.condition.dt_util.now", + return_value=dt_util.now().replace(hour=18, minute=0, second=0), + ): + assert condition.time(hass, after="time.pm", before="time.am") + assert not condition.time(hass, after="time.am", before="time.pm") + assert condition.time(hass, after="time.pm") + assert not condition.time(hass, before="time.pm") + + # Trigger on AM time + with patch( + "homeassistant.helpers.condition.dt_util.now", + return_value=dt_util.now().replace(hour=6, minute=0, second=0), + ): + assert not condition.time(hass, after="time.pm", before="time.am") + assert condition.time(hass, after="time.am", before="time.pm") + assert condition.time(hass, after="time.am") + assert not condition.time(hass, before="time.am") + + assert not condition.time(hass, after="time.unknown_state") + assert not condition.time(hass, before="time.unavailable_state") + + with pytest.raises(ConditionError): + condition.time(hass, after="time.not_existing") + + with pytest.raises(ConditionError): + condition.time(hass, before="time.not_existing") + + async def test_time_using_sensor(hass: HomeAssistant) -> None: """Test time conditions using sensor entities.""" hass.states.async_set( From 0e88e22fd23581e6cd685f3dd2989ffe20db3601 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 26 Nov 2024 15:14:39 +0000 Subject: [PATCH 0894/1070] Bump ring_doorbell to 0.9.13 (#131627) --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 22a7171332f0b..86758b2679486 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "requirements": ["ring-doorbell==0.9.12"] + "requirements": ["ring-doorbell==0.9.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9519ac823939d..f758f2d9cbb47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2565,7 +2565,7 @@ rfk101py==0.0.1 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell==0.9.12 +ring-doorbell==0.9.13 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82be50ab1ef49..13ab6ecf718e5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2053,7 +2053,7 @@ reolink-aio==0.11.3 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell==0.9.12 +ring-doorbell==0.9.13 # homeassistant.components.roku rokuecp==0.19.3 From a2ebfe6e83ee86b129a1b7572f909604c9cc5735 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 26 Nov 2024 16:19:41 +0100 Subject: [PATCH 0895/1070] Add Reolink binning mode select entity (#131570) --- homeassistant/components/reolink/icons.json | 3 +++ homeassistant/components/reolink/select.py | 14 ++++++++++++++ homeassistant/components/reolink/strings.json | 14 +++++++++++--- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 4fbc8e82ae3ac..cee044189ea16 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -222,6 +222,9 @@ "hdr": { "default": "mdi:hdr" }, + "binning_mode": { + "default": "mdi:code-block-brackets" + }, "hub_alarm_ringtone": { "default": "mdi:music-note", "state": { diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index f1147c503288f..8625f7fb60013 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -8,6 +8,7 @@ from typing import Any from reolink_aio.api import ( + BinningModeEnum, Chime, ChimeToneEnum, DayNightEnum, @@ -175,6 +176,19 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: value=lambda api, ch: HDREnum(api.HDR_state(ch)).name, method=lambda api, ch, name: api.set_HDR(ch, HDREnum[name].value), ), + ReolinkSelectEntityDescription( + key="binning_mode", + cmd_key="GetIsp", + translation_key="binning_mode", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + get_options=[method.name for method in BinningModeEnum], + supported=lambda api, ch: api.supported(ch, "binning_mode"), + value=lambda api, ch: BinningModeEnum(api.binning_mode(ch)).name, + method=lambda api, ch, name: api.set_binning_mode( + ch, BinningModeEnum[name].value + ), + ), ReolinkSelectEntityDescription( key="main_frame_rate", cmd_key="GetEnc", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5e885d9ac7754..3fe7fe14ec5fb 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -490,7 +490,7 @@ "name": "Floodlight mode", "state": { "off": "[%key:common::state::off%]", - "auto": "Auto", + "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]", "onatnight": "On at night", "schedule": "Schedule", "adaptive": "Adaptive", @@ -529,7 +529,7 @@ "name": "Doorbell LED", "state": { "stayoff": "Stay off", - "auto": "Auto", + "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]", "alwaysonatnight": "Auto & always on at night", "alwayson": "Always on" } @@ -539,7 +539,15 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", - "auto": "Auto" + "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]" + } + }, + "binning_mode": { + "name": "Binning mode", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]" } }, "hub_alarm_ringtone": { From 1a71fbe427e237e3d2f6634002d684b384fa1905 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 26 Nov 2024 16:59:41 +0100 Subject: [PATCH 0896/1070] Add intent to cancel all timers (#130873) * Add intent to cancel all timers * Add intent to llm test --- homeassistant/components/intent/__init__.py | 2 + homeassistant/components/intent/timers.py | 26 +++ homeassistant/helpers/intent.py | 1 + tests/components/intent/test_timers.py | 178 ++++++++++++++++++++ tests/helpers/test_llm.py | 1 + 5 files changed, 208 insertions(+) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 30aaa933072c3..1ffb8747d9116 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -45,6 +45,7 @@ from .const import DOMAIN, TIMER_DATA from .timers import ( + CancelAllTimersIntentHandler, CancelTimerIntentHandler, DecreaseTimerIntentHandler, IncreaseTimerIntentHandler, @@ -130,6 +131,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.async_register(hass, SetPositionIntentHandler()) intent.async_register(hass, StartTimerIntentHandler()) intent.async_register(hass, CancelTimerIntentHandler()) + intent.async_register(hass, CancelAllTimersIntentHandler()) intent.async_register(hass, IncreaseTimerIntentHandler()) intent.async_register(hass, DecreaseTimerIntentHandler()) intent.async_register(hass, PauseTimerIntentHandler()) diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index 639744abc66ea..0be123dcd189e 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -887,6 +887,32 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse return intent_obj.create_response() +class CancelAllTimersIntentHandler(intent.IntentHandler): + """Intent handler for cancelling all timers.""" + + intent_type = intent.INTENT_CANCEL_ALL_TIMERS + description = "Cancels all timers" + slot_schema = { + vol.Optional("area"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + canceled = 0 + + for timer in _find_timers(hass, intent_obj.device_id, slots): + timer_manager.cancel_timer(timer.id) + canceled += 1 + + response = intent_obj.create_response() + response.async_set_speech_slots({"canceled": canceled}) + + return response + + class IncreaseTimerIntentHandler(intent.IntentHandler): """Intent handler for increasing the time of a timer.""" diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index b38f769b302d8..468539f5a9d01 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -49,6 +49,7 @@ INTENT_SET_POSITION = "HassSetPosition" INTENT_START_TIMER = "HassStartTimer" INTENT_CANCEL_TIMER = "HassCancelTimer" +INTENT_CANCEL_ALL_TIMERS = "HassCancelAllTimers" INTENT_INCREASE_TIMER = "HassIncreaseTimer" INTENT_DECREASE_TIMER = "HassDecreaseTimer" INTENT_PAUSE_TIMER = "HassPauseTimer" diff --git a/tests/components/intent/test_timers.py b/tests/components/intent/test_timers.py index d194d532513c9..7c4a87902063a 100644 --- a/tests/components/intent/test_timers.py +++ b/tests/components/intent/test_timers.py @@ -1587,3 +1587,181 @@ def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: # After handler registration assert async_device_supports_timers(hass, device_id) + + +async def test_cancel_all_timers(hass: HomeAssistant, init_components) -> None: + """Test cancelling all timers.""" + device_id = "test_device" + + started_event = asyncio.Event() + num_started = 0 + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal num_started + + if event_type == TimerEventType.STARTED: + num_started += 1 + if num_started == 3: + started_event.set() + + async_register_timer_handler(hass, device_id, handle_timer) + + # Start timers + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "pizza"}, "minutes": {"value": 10}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "tv"}, "minutes": {"value": 10}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result2 = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "media"}, "minutes": {"value": 15}}, + device_id=device_id, + ) + assert result2.response_type == intent.IntentResponseType.ACTION_DONE + + # Wait for all timers to start + async with asyncio.timeout(1): + await started_event.wait() + + # Cancel all timers + result = await intent.async_handle( + hass, "test", intent.INTENT_CANCEL_ALL_TIMERS, {}, device_id=device_id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + assert result.speech_slots.get("canceled", 0) == 3 + + # No timers should be running for test_device + result = await intent.async_handle( + hass, "test", intent.INTENT_TIMER_STATUS, {}, device_id=device_id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 0 + + +async def test_cancel_all_timers_area( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test cancelling all timers in an area.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + + area_kitchen = area_registry.async_create("kitchen") + device_kitchen = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "kitchen-device")}, + ) + device_registry.async_update_device(device_kitchen.id, area_id=area_kitchen.id) + + area_living_room = area_registry.async_create("living room") + device_living_room = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "living_room-device")}, + ) + device_registry.async_update_device( + device_living_room.id, area_id=area_living_room.id + ) + + started_event = asyncio.Event() + num_timers = 3 + num_started = 0 + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal num_started + + if event_type == TimerEventType.STARTED: + num_started += 1 + if num_started == num_timers: + started_event.set() + + async_register_timer_handler(hass, device_kitchen.id, handle_timer) + async_register_timer_handler(hass, device_living_room.id, handle_timer) + + # Start timers in different areas + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "pizza"}, "minutes": {"value": 10}}, + device_id=device_kitchen.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "tv"}, "minutes": {"value": 10}}, + device_id=device_living_room.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "media"}, "minutes": {"value": 15}}, + device_id=device_living_room.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Wait for all timers to start + async with asyncio.timeout(1): + await started_event.wait() + + # Cancel all timers in kitchen + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_ALL_TIMERS, + {"area": {"value": "kitchen"}}, + device_id=device_kitchen.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + assert result.speech_slots.get("canceled", 0) == 1 + + # No timers should be running in kitchen + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"area": {"value": "kitchen"}}, + device_id=device_kitchen.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 0 + + # timers should be running in living room + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"area": {"value": "living room"}}, + device_id=device_living_room.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 2 diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index cd36fe1893345..7174d77886a13 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -306,6 +306,7 @@ class MyIntentHandler(intent.IntentHandler): "HassSetPosition", "HassStartTimer", "HassCancelTimer", + "HassCancelAllTimers", "HassIncreaseTimer", "HassDecreaseTimer", "HassPauseTimer", From bf9e7e4a0c256b5cb383d6931f89c1caaf108b30 Mon Sep 17 00:00:00 2001 From: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com> Date: Tue, 26 Nov 2024 17:00:51 +0100 Subject: [PATCH 0897/1070] Bump Weheat wh-python to 2024.11.26 (#131630) --- homeassistant/components/weheat/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index ef89a2f1acba3..61d6a110dbde7 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2024.11.02"] + "requirements": ["weheat==2024.11.26"] } diff --git a/requirements_all.txt b/requirements_all.txt index f758f2d9cbb47..7911e16f316e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2993,7 +2993,7 @@ webio-api==0.1.8 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.11.02 +weheat==2024.11.26 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 13ab6ecf718e5..30f31ea6af4d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2388,7 +2388,7 @@ webio-api==0.1.8 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.11.02 +weheat==2024.11.26 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 From 883c6121cfaf465bb6226d3c5f37c3f30970fb88 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 26 Nov 2024 17:17:10 +0100 Subject: [PATCH 0898/1070] Prevent changing email address in inexogy reauth (#131632) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/discovergy/config_flow.py | 48 +++++-------------- .../components/discovergy/strings.json | 7 +-- .../components/discovergy/test_config_flow.py | 33 +++++++++++-- 3 files changed, 42 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index 05ed90bf3544a..f24fdd1e43dde 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -11,12 +11,7 @@ import pydiscovergy.error as discovergyError import voluptuous as vol -from homeassistant.config_entries import ( - SOURCE_REAUTH, - ConfigEntry, - ConfigFlow, - ConfigFlowResult, -) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( @@ -57,35 +52,14 @@ class DiscovergyConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - _existing_entry: ConfigEntry - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", - data_schema=CONFIG_SCHEMA, - ) - - return await self._validate_and_save(user_input) - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle the initial step.""" - self._existing_entry = self._get_reauth_entry() - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the reauth step.""" - return await self._validate_and_save(user_input, step_id="reauth_confirm") + return await self.async_step_user() - async def _validate_and_save( - self, user_input: Mapping[str, Any] | None = None, step_id: str = "user" + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None ) -> ConfigFlowResult: """Validate user input and create config entry.""" errors = {} @@ -106,17 +80,17 @@ async def _validate_and_save( _LOGGER.exception("Unexpected error occurred while getting meters") errors["base"] = "unknown" else: + await self.async_set_unique_id(user_input[CONF_EMAIL].lower()) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="account_mismatch") return self.async_update_reload_and_abort( - entry=self._existing_entry, - data={ - CONF_EMAIL: user_input[CONF_EMAIL], + entry=self._get_reauth_entry(), + data_updates={ CONF_PASSWORD: user_input[CONF_PASSWORD], }, ) - # set unique id to title which is the account email - await self.async_set_unique_id(user_input[CONF_EMAIL].lower()) self._abort_if_unique_id_configured() return self.async_create_entry( @@ -124,10 +98,10 @@ async def _validate_and_save( ) return self.async_show_form( - step_id=step_id, + step_id="user", data_schema=self.add_suggested_values_to_schema( CONFIG_SCHEMA, - self._existing_entry.data + self._get_reauth_entry().data if self.source == SOURCE_REAUTH else user_input, ), diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index 9a91fa92dc44c..b626a11ea1ea3 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -6,12 +6,6 @@ "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } - }, - "reauth_confirm": { - "data": { - "email": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]" - } } }, "error": { @@ -21,6 +15,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "account_mismatch": "The inexogy account authenticated with, does not match the account needed re-authentication.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index 470ef65fccdd7..23c4a0f7cee7a 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -20,7 +20,7 @@ async def test_form(hass: HomeAssistant, discovergy: AsyncMock) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["errors"] == {} with patch( "homeassistant.components.discovergy.async_setup_entry", @@ -51,7 +51,7 @@ async def test_reauth( config_entry.add_to_hass(hass) init_result = await config_entry.start_reauth_flow(hass) assert init_result["type"] is FlowResultType.FORM - assert init_result["step_id"] == "reauth_confirm" + assert init_result["step_id"] == "user" with patch( "homeassistant.components.discovergy.async_setup_entry", @@ -60,7 +60,7 @@ async def test_reauth( configure_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], { - CONF_EMAIL: "test@example.com", + CONF_EMAIL: "user@example.org", CONF_PASSWORD: "test-password", }, ) @@ -111,3 +111,30 @@ async def test_form_fail( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test@example.com" assert "errors" not in result + + +async def test_reauth_unique_id_mismatch( + hass: HomeAssistant, config_entry: MockConfigEntry, discovergy: AsyncMock +) -> None: + """Test reauth flow with unique id mismatch.""" + config_entry.add_to_hass(hass) + + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.discovergy.async_setup_entry", + return_value=True, + ): + configure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "user2@example.org", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert configure_result["type"] is FlowResultType.ABORT + assert configure_result["reason"] == "account_mismatch" From 15bf0c728cbe6d02e580bd817c2f0bcf99fac68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexey=20ALERT=20Rubash=D1=91ff?= Date: Tue, 26 Nov 2024 18:45:28 +0200 Subject: [PATCH 0899/1070] Sync overkiz Atlantic Water Heater datetime before switching the away mode on (#127408) Set device datetime before turning on the away mode --- ...stic_hot_water_production_mlb_component.py | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py index 1b2a1e218d43e..8ba2c1678c2a3 100644 --- a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py +++ b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py @@ -13,6 +13,7 @@ WaterHeaterEntityFeature, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.util import dt as dt_util from .. import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -153,11 +154,11 @@ async def async_set_operation_mode(self, operation_mode: str) -> None: async def async_turn_away_mode_on(self) -> None: """Turn away mode on. - This requires the start date and the end date to be also set. + This requires the start date and the end date to be also set, and those dates have to match the device datetime. The API accepts setting dates in the format of the core:DateTimeState state for the DHW - {'day': 11, 'hour': 21, 'minute': 12, 'month': 7, 'second': 53, 'weekday': 3, 'year': 2024}) - The dict is then passed as an away mode start date, and then as an end date, but with the year incremented by 1, - so the away mode is getting turned on for the next year. + {'day': 11, 'hour': 21, 'minute': 12, 'month': 7, 'second': 53, 'weekday': 3, 'year': 2024} + The dict is then passed as an actual device date, the away mode start date, and then as an end date, + but with the year incremented by 1, so the away mode is getting turned on for the next year. The weekday number seems to have no effect so the calculation of the future date's weekday number is redundant, but possible via homeassistant dt_util to form both start and end dates dictionaries from scratch based on datetime.now() and datetime.timedelta into the future. @@ -167,13 +168,19 @@ async def async_turn_away_mode_on(self) -> None: With `refresh_afterwards=False` on the first commands, and `refresh_afterwards=True` only the last command, the API is not choking and the transition is smooth without the unavailability state. """ - now_date = cast( - dict, - self.executor.select_state(OverkizState.CORE_DATETIME), - ) + now = dt_util.now() + now_date = { + "month": now.month, + "hour": now.hour, + "year": now.year, + "weekday": now.weekday(), + "day": now.day, + "minute": now.minute, + "second": now.second, + } await self.executor.async_execute_command( - OverkizCommand.SET_ABSENCE_MODE, - OverkizCommandParam.PROG, + OverkizCommand.SET_DATE_TIME, + now_date, refresh_afterwards=False, ) await self.executor.async_execute_command( @@ -183,7 +190,11 @@ async def async_turn_away_mode_on(self) -> None: await self.executor.async_execute_command( OverkizCommand.SET_ABSENCE_END_DATE, now_date, refresh_afterwards=False ) - + await self.executor.async_execute_command( + OverkizCommand.SET_ABSENCE_MODE, + OverkizCommandParam.PROG, + refresh_afterwards=False, + ) await self.coordinator.async_refresh() async def async_turn_away_mode_off(self) -> None: From 192ffc09ee9ffdbb344d4b9f096ed1b841bbb8a0 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 26 Nov 2024 10:58:39 -0600 Subject: [PATCH 0900/1070] Add area slot to response for cancel all timers (#131638) Add area slot to response --- homeassistant/components/intent/timers.py | 6 +++++- tests/components/intent/test_timers.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index 0be123dcd189e..84b96492241cc 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -908,7 +908,11 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse canceled += 1 response = intent_obj.create_response() - response.async_set_speech_slots({"canceled": canceled}) + speech_slots = {"canceled": canceled} + if "area" in slots: + speech_slots["area"] = slots["area"]["value"] + + response.async_set_speech_slots(speech_slots) return response diff --git a/tests/components/intent/test_timers.py b/tests/components/intent/test_timers.py index 7c4a87902063a..1789e981e2d38 100644 --- a/tests/components/intent/test_timers.py +++ b/tests/components/intent/test_timers.py @@ -1741,6 +1741,7 @@ def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: ) assert result.response_type == intent.IntentResponseType.ACTION_DONE assert result.speech_slots.get("canceled", 0) == 1 + assert result.speech_slots.get("area") == "kitchen" # No timers should be running in kitchen result = await intent.async_handle( From e31d398811e6ebf501ef1f5e9e81b88a11da26cf Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 26 Nov 2024 19:01:19 +0100 Subject: [PATCH 0901/1070] Add binary sensor to SABnzbd (#131651) --- homeassistant/components/sabnzbd/__init__.py | 2 +- .../components/sabnzbd/binary_sensor.py | 61 +++++++++++++++++++ homeassistant/components/sabnzbd/strings.json | 5 ++ tests/components/sabnzbd/fixtures/queue.json | 2 +- .../sabnzbd/snapshots/test_binary_sensor.ambr | 48 +++++++++++++++ .../components/sabnzbd/test_binary_sensor.py | 23 +++++++ 6 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/sabnzbd/binary_sensor.py create mode 100644 tests/components/sabnzbd/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/sabnzbd/test_binary_sensor.py diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 19b114a452555..cf2eb5d0a7d4b 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -27,7 +27,7 @@ from .coordinator import SabnzbdUpdateCoordinator from .helpers import get_client -PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) SERVICES = ( diff --git a/homeassistant/components/sabnzbd/binary_sensor.py b/homeassistant/components/sabnzbd/binary_sensor.py new file mode 100644 index 0000000000000..8b1b1c37c8917 --- /dev/null +++ b/homeassistant/components/sabnzbd/binary_sensor.py @@ -0,0 +1,61 @@ +"""Binary sensor platform for SABnzbd.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SabnzbdConfigEntry +from .entity import SabnzbdEntity + + +@dataclass(frozen=True, kw_only=True) +class SabnzbdBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Sabnzbd binary sensor entity.""" + + is_on_fn: Callable[[dict[str, Any]], bool] + + +BINARY_SENSORS: tuple[SabnzbdBinarySensorEntityDescription, ...] = ( + SabnzbdBinarySensorEntityDescription( + key="warnings", + translation_key="warnings", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda data: data["have_warnings"] != "0", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SabnzbdConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Sabnzbd sensor entry.""" + coordinator = config_entry.runtime_data + + async_add_entities( + [SabnzbdBinarySensor(coordinator, sensor) for sensor in BINARY_SENSORS] + ) + + +class SabnzbdBinarySensor(SabnzbdEntity, BinarySensorEntity): + """Representation of an SABnzbd binary sensor.""" + + entity_description: SabnzbdBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return latest sensor data.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 186682e78e771..0ac8b93c57f52 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -22,6 +22,11 @@ } }, "entity": { + "binary_sensor": { + "warnings": { + "name": "Warnings" + } + }, "button": { "pause": { "name": "[%key:common::action::pause%]" diff --git a/tests/components/sabnzbd/fixtures/queue.json b/tests/components/sabnzbd/fixtures/queue.json index 342500aea394f..7acef65f2e9e7 100644 --- a/tests/components/sabnzbd/fixtures/queue.json +++ b/tests/components/sabnzbd/fixtures/queue.json @@ -15,7 +15,7 @@ "diskspacetotal2": "7448.42", "speedlimit": "85", "speedlimit_abs": "22282240", - "have_warnings": "0", + "have_warnings": "1", "finishaction": null, "quota": "0 ", "have_quota": false, diff --git a/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr b/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..9f3087df3d12e --- /dev/null +++ b/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_sensor[binary_sensor.sabnzbd_warnings-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.sabnzbd_warnings', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Warnings', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'warnings', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_warnings', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[binary_sensor.sabnzbd_warnings-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Sabnzbd Warnings', + }), + 'context': , + 'entity_id': 'binary_sensor.sabnzbd_warnings', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/sabnzbd/test_binary_sensor.py b/tests/components/sabnzbd/test_binary_sensor.py new file mode 100644 index 0000000000000..48a3c00648871 --- /dev/null +++ b/tests/components/sabnzbd/test_binary_sensor.py @@ -0,0 +1,23 @@ +"""Binary sensor tests for the Sabnzbd component.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.sabnzbd.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test binary sensor setup.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) From a5becfaff051095e0f1f26139fd2f1acfbcb8008 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 26 Nov 2024 19:03:50 +0100 Subject: [PATCH 0902/1070] Add more supported lines to London Underground (#131650) --- homeassistant/components/london_underground/const.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/london_underground/const.py b/homeassistant/components/london_underground/const.py index 532f4333ba925..447ed4461f3a7 100644 --- a/homeassistant/components/london_underground/const.py +++ b/homeassistant/components/london_underground/const.py @@ -24,4 +24,10 @@ "Piccadilly", "Victoria", "Waterloo & City", + "Liberty", + "Lioness", + "Mildmay", + "Suffragette", + "Weaver", + "Windrush", ] From a9cab284748c115d1199fdd5487e9c002b9b4110 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 26 Nov 2024 19:17:04 +0100 Subject: [PATCH 0903/1070] Add DHCP configuration update in HomeWizard (#131547) --- .../components/homewizard/config_flow.py | 27 +++++++ .../components/homewizard/manifest.json | 5 ++ .../components/homewizard/quality_scale.yaml | 6 +- homeassistant/generated/dhcp.py | 4 + .../components/homewizard/test_config_flow.py | 76 ++++++++++++++++++- 5 files changed, 112 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index aab6ce055a235..21d264030cf09 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -12,6 +12,7 @@ from voluptuous import Required, Schema from homeassistant.components import onboarding, zeroconf +from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH from homeassistant.data_entry_flow import AbortFlow @@ -110,6 +111,32 @@ async def async_step_zeroconf( return await self.async_step_discovery_confirm() + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle dhcp discovery to update existing entries. + + This flow is triggered only by DHCP discovery of known devices. + """ + try: + device = await self._async_try_connect(discovery_info.ip) + except RecoverableError as ex: + _LOGGER.error(ex) + return self.async_abort(reason="unknown") + + await self.async_set_unique_id( + f"{device.product_type}_{discovery_info.macaddress}" + ) + + self._abort_if_unique_id_configured( + updates={CONF_IP_ADDRESS: discovery_info.ip} + ) + + # This situation should never happen, as Home Assistant will only + # send updates for existing entries. In case it does, we'll just + # abort the flow with an unknown error. + return self.async_abort(reason="unknown") + async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 0ba2ac0eea76a..78b80dd788e16 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -3,6 +3,11 @@ "name": "HomeWizard Energy", "codeowners": ["@DCSBL"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/homewizard", "iot_class": "local_polling", "loggers": ["homewizard_energy"], diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml index 1dbdba8212d79..e71c4aa8c1837 100644 --- a/homeassistant/components/homewizard/quality_scale.yaml +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -46,11 +46,7 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: - status: todo - comment: | - The integration doesn't update the device info based on DHCP discovery - of known existing devices. + discovery-update-info: done discovery: done docs-data-update: done docs-examples: done diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 1ef91841db868..e37fb2332b175 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -236,6 +236,10 @@ "hostname": "guardian*", "macaddress": "30AEA4*", }, + { + "domain": "homewizard", + "registered_devices": True, + }, { "domain": "hunterdouglas_powerview", "registered_devices": True, diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 84bdb0ba921de..5ae0e0ef88ae5 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -8,7 +8,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries -from homeassistant.components import zeroconf +from homeassistant.components import dhcp, zeroconf from homeassistant.components.homewizard.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant @@ -263,6 +263,80 @@ async def test_discovery_invalid_api(hass: HomeAssistant) -> None: assert result["reason"] == "unsupported_api_version" +async def test_dhcp_discovery_updates_entry( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery updates config entries.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip="1.0.0.127", + hostname="HW-p1meter-aabbcc", + macaddress="5c2fafabcdef", + ), + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception"), + [(DisabledError), (RequestError)], +) +async def test_dhcp_discovery_updates_entry_fails( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test DHCP discovery updates config entries, but fails to connect.""" + mock_homewizardenergy.device.side_effect = exception + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip="1.0.0.127", + hostname="HW-p1meter-aabbcc", + macaddress="5c2fafabcdef", + ), + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "unknown" + + +async def test_dhcp_discovery_ignores_unknown( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, +) -> None: + """Test DHCP discovery is only used for updates. + + Anything else will just abort the flow. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip="127.0.0.1", + hostname="HW-p1meter-aabbcc", + macaddress="5c2fafabcdef", + ), + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "unknown" + + async def test_discovery_flow_updates_new_ip( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 7ba0f54412ece558f7a7a31cad450a9c3104b2b2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 26 Nov 2024 19:19:27 +0100 Subject: [PATCH 0904/1070] Clarify 'item' and 'rename' descriptions of 'update_item' action (#131336) --- homeassistant/components/todo/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 717aa310ecdf0..45e378c3de523 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -44,11 +44,11 @@ "fields": { "item": { "name": "Item name", - "description": "The name for the to-do list item." + "description": "The current name of the to-do item." }, "rename": { "name": "Rename item", - "description": "The new name of the to-do item" + "description": "The new name for the to-do item" }, "status": { "name": "Set status", From a252faf9af96356db3dc4f6bc28ff64e792b3dc7 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 26 Nov 2024 19:20:50 +0100 Subject: [PATCH 0905/1070] Add reconfiguration flow in HomeWizard (#131535) --- .../components/homewizard/config_flow.py | 46 ++++++- .../components/homewizard/quality_scale.yaml | 2 +- .../components/homewizard/strings.json | 13 +- .../components/homewizard/test_config_flow.py | 128 ++++++++++++++++++ 4 files changed, 183 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 21d264030cf09..a6e4356328e8a 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -9,7 +9,7 @@ from homewizard_energy import HomeWizardEnergyV1 from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError from homewizard_energy.v1.models import Device -from voluptuous import Required, Schema +import voluptuous as vol from homeassistant.components import onboarding, zeroconf from homeassistant.components.dhcp import DhcpServiceInfo @@ -17,6 +17,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import TextSelector from .const import ( CONF_API_ENABLED, @@ -69,11 +70,11 @@ async def async_step_user( user_input = user_input or {} return self.async_show_form( step_id="user", - data_schema=Schema( + data_schema=vol.Schema( { - Required( + vol.Required( CONF_IP_ADDRESS, default=user_input.get(CONF_IP_ADDRESS) - ): str, + ): TextSelector(), } ), errors=errors, @@ -197,6 +198,43 @@ async def async_step_reauth_confirm( return self.async_show_form(step_id="reauth_confirm", errors=errors) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + if user_input: + try: + device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) + except RecoverableError as ex: + _LOGGER.error(ex) + errors = {"base": ex.error_code} + else: + await self.async_set_unique_id( + f"{device_info.product_type}_{device_info.serial}" + ) + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=user_input, + ) + reconfigure_entry = self._get_reconfigure_entry() + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_IP_ADDRESS, + default=reconfigure_entry.data.get(CONF_IP_ADDRESS), + ): TextSelector(), + } + ), + description_placeholders={ + "title": reconfigure_entry.title, + }, + errors=errors, + ) + @staticmethod async def _async_try_connect(ip_address: str) -> Device: """Try to connect. diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml index e71c4aa8c1837..423bc4dea49ea 100644 --- a/homeassistant/components/homewizard/quality_scale.yaml +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -65,7 +65,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index b3fd5a1fef2d0..4309664c4c881 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -17,6 +17,15 @@ }, "reauth_confirm": { "description": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings." + }, + "reconfigure": { + "description": "Update configuration for {title}.", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "ip_address": "[%key:component::homewizard::config::step::user::data_description::ip_address%]" + } } }, "error": { @@ -29,7 +38,9 @@ "device_not_supported": "This device is not supported", "unknown_error": "[%key:common::config_flow::error::unknown%]", "unsupported_api_version": "Detected unsupported API version", - "reauth_successful": "Enabling API was successful" + "reauth_successful": "Enabling API was successful", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "The configured device is not the same found on this IP address." } }, "entity": { diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 5ae0e0ef88ae5..984fda8e7a427 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -480,3 +480,131 @@ async def test_reauth_error( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "api_not_enabled"} + + +async def test_reconfigure( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # original entry + assert mock_config_entry.data[CONF_IP_ADDRESS] == "127.0.0.1" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.0.0.127", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # changed entry + assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" + + +async def test_reconfigure_nochange( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration without changing values.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # original entry + assert mock_config_entry.data[CONF_IP_ADDRESS] == "127.0.0.1" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # changed entry + assert mock_config_entry.data[CONF_IP_ADDRESS] == "127.0.0.1" + + +async def test_reconfigure_wrongdevice( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entering ip of other device and prevent changing it based on serial.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # simulate different serial number, as if user entered wrong IP + mock_homewizardenergy.device.return_value.serial = "not_5c2fafabcdef" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.0.0.127", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" + + # entry should still be original entry + assert mock_config_entry.data[CONF_IP_ADDRESS] == "127.0.0.1" + + +@pytest.mark.parametrize( + ("exception", "reason"), + [(DisabledError, "api_not_enabled"), (RequestError, "network_error")], +) +async def test_reconfigure_cannot_connect( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test reconfiguration fails when not able to connect.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + mock_homewizardenergy.device.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.0.0.127", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": reason} + assert result["data_schema"]({}) == {CONF_IP_ADDRESS: "127.0.0.1"} + + # attempt with valid IP should work + mock_homewizardenergy.device.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.0.0.127", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # changed entry + assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" From 7d5ba342c66f58d572033fe08fa4e05b38207db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20=C3=98yvind=20=C3=98ygard?= <17528+peroo@users.noreply.github.com> Date: Tue, 26 Nov 2024 19:23:18 +0100 Subject: [PATCH 0906/1070] Add base entity class for Touchline zones (#131094) Co-authored-by: Joost Lekkerkerker --- .../components/touchline_sl/climate.py | 31 ++------------- .../components/touchline_sl/entity.py | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/touchline_sl/entity.py diff --git a/homeassistant/components/touchline_sl/climate.py b/homeassistant/components/touchline_sl/climate.py index 0035bd07c34da..8a0ffc4cd8686 100644 --- a/homeassistant/components/touchline_sl/climate.py +++ b/homeassistant/components/touchline_sl/climate.py @@ -2,8 +2,6 @@ from typing import Any -from pytouchlinesl import Zone - from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -12,13 +10,11 @@ ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import TouchlineSLConfigEntry -from .const import DOMAIN from .coordinator import TouchlineSLModuleCoordinator +from .entity import TouchlineSLZoneEntity async def async_setup_entry( @@ -38,10 +34,9 @@ async def async_setup_entry( CONSTANT_TEMPERATURE = "constant_temperature" -class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEntity): +class TouchlineSLZone(TouchlineSLZoneEntity, ClimateEntity): """Roth Touchline SL Zone.""" - _attr_has_entity_name = True _attr_hvac_action = HVACAction.IDLE _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] @@ -54,22 +49,12 @@ class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEn def __init__(self, coordinator: TouchlineSLModuleCoordinator, zone_id: int) -> None: """Construct a Touchline SL climate zone.""" - super().__init__(coordinator) - self.zone_id: int = zone_id + super().__init__(coordinator, zone_id) self._attr_unique_id = ( f"module-{self.coordinator.data.module.id}-zone-{self.zone_id}" ) - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(zone_id))}, - name=self.zone.name, - manufacturer="Roth", - via_device=(DOMAIN, coordinator.data.module.id), - model="zone", - suggested_area=self.zone.name, - ) - # Call this in __init__ so data is populated right away, since it's # already available in the coordinator data. self.set_attr() @@ -80,16 +65,6 @@ def _handle_coordinator_update(self) -> None: self.set_attr() super()._handle_coordinator_update() - @property - def zone(self) -> Zone: - """Return the device object from the coordinator data.""" - return self.coordinator.data.zones[self.zone_id] - - @property - def available(self) -> bool: - """Return if the device is available.""" - return super().available and self.zone_id in self.coordinator.data.zones - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: diff --git a/homeassistant/components/touchline_sl/entity.py b/homeassistant/components/touchline_sl/entity.py new file mode 100644 index 0000000000000..637ad8955eb83 --- /dev/null +++ b/homeassistant/components/touchline_sl/entity.py @@ -0,0 +1,38 @@ +"""Base class for Touchline SL zone entities.""" + +from pytouchlinesl import Zone + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TouchlineSLModuleCoordinator + + +class TouchlineSLZoneEntity(CoordinatorEntity[TouchlineSLModuleCoordinator]): + """Defines a base Touchline SL zone entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: TouchlineSLModuleCoordinator, zone_id: int) -> None: + """Initialize touchline entity.""" + super().__init__(coordinator) + self.zone_id = zone_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(zone_id))}, + name=self.zone.name, + manufacturer="Roth", + via_device=(DOMAIN, coordinator.data.module.id), + model="zone", + suggested_area=self.zone.name, + ) + + @property + def zone(self) -> Zone: + """Return the device object from the coordinator data.""" + return self.coordinator.data.zones[self.zone_id] + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self.zone_id in self.coordinator.data.zones From f1655c5d1aa71a66ae5dca45439cfecbe6b95d60 Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Tue, 26 Nov 2024 19:25:00 +0100 Subject: [PATCH 0907/1070] Use SensorEntityDescription in emoncms (#130451) --- homeassistant/components/emoncms/sensor.py | 191 +++++++++++++++--- homeassistant/components/emoncms/strings.json | 46 +++++ .../emoncms/snapshots/test_sensor.ambr | 16 +- 3 files changed, 219 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index c696a56913565..9273c24c7dc62 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -10,16 +10,31 @@ PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, CONF_API_KEY, CONF_ID, CONF_UNIT_OF_MEASUREMENT, CONF_URL, CONF_VALUE_TEMPLATE, + PERCENTAGE, + UnitOfApparentPower, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, UnitOfPower, + UnitOfPressure, + UnitOfSoundPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType @@ -41,6 +56,146 @@ ) from .coordinator import EmoncmsCoordinator +SENSORS: dict[str | None, SensorEntityDescription] = { + "kWh": SensorEntityDescription( + key="energy|kWh", + translation_key="energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "Wh": SensorEntityDescription( + key="energy|Wh", + translation_key="energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "kW": SensorEntityDescription( + key="power|kW", + translation_key="power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + "W": SensorEntityDescription( + key="power|W", + translation_key="power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + "V": SensorEntityDescription( + key="voltage", + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + "A": SensorEntityDescription( + key="current", + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + "VA": SensorEntityDescription( + key="apparent_power", + translation_key="apparent_power", + device_class=SensorDeviceClass.APPARENT_POWER, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + "°C": SensorEntityDescription( + key="temperature|celsius", + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + "°F": SensorEntityDescription( + key="temperature|fahrenheit", + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + ), + "K": SensorEntityDescription( + key="temperature|kelvin", + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.KELVIN, + state_class=SensorStateClass.MEASUREMENT, + ), + "Hz": SensorEntityDescription( + key="frequency", + translation_key="frequency", + device_class=SensorDeviceClass.FREQUENCY, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + ), + "hPa": SensorEntityDescription( + key="pressure", + translation_key="pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.HPA, + state_class=SensorStateClass.MEASUREMENT, + ), + "dB": SensorEntityDescription( + key="decibel", + translation_key="decibel", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + state_class=SensorStateClass.MEASUREMENT, + ), + "m³": SensorEntityDescription( + key="volume|cubic_meter", + translation_key="volume", + device_class=SensorDeviceClass.VOLUME_STORAGE, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.MEASUREMENT, + ), + "m³/h": SensorEntityDescription( + key="flow|cubic_meters_per_hour", + translation_key="flow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + "l/m": SensorEntityDescription( + key="flow|liters_per_minute", + translation_key="flow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + state_class=SensorStateClass.MEASUREMENT, + ), + "m/s": SensorEntityDescription( + key="speed|meters_per_second", + translation_key="speed", + device_class=SensorDeviceClass.SPEED, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + ), + "µg/m³": SensorEntityDescription( + key="concentration|microgram_per_cubic_meter", + translation_key="concentration", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + "ppm": SensorEntityDescription( + key="concentration|microgram_parts_per_million", + translation_key="concentration", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + "%": SensorEntityDescription( + key="percent", + translation_key="percent", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +} + ATTR_FEEDID = "FeedId" ATTR_FEEDNAME = "FeedName" ATTR_LASTUPDATETIME = "LastUpdated" @@ -173,6 +328,8 @@ async def async_setup_entry( class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity): """Implementation of an Emoncms sensor.""" + _attr_has_entity_name = True + def __init__( self, coordinator: EmoncmsCoordinator, @@ -187,33 +344,15 @@ def __init__( elem = {} if self.coordinator.data: elem = self.coordinator.data[self.idx] - self._attr_name = f"{name} {elem[FEED_NAME]}" - self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_translation_placeholders = { + "emoncms_details": f"{elem[FEED_TAG]} {elem[FEED_NAME]}", + } self._attr_unique_id = f"{unique_id}-{elem[FEED_ID]}" - if unit_of_measurement in ("kWh", "Wh"): - self._attr_device_class = SensorDeviceClass.ENERGY - self._attr_state_class = SensorStateClass.TOTAL_INCREASING - elif unit_of_measurement == "W": - self._attr_device_class = SensorDeviceClass.POWER - self._attr_state_class = SensorStateClass.MEASUREMENT - elif unit_of_measurement == "V": - self._attr_device_class = SensorDeviceClass.VOLTAGE - self._attr_state_class = SensorStateClass.MEASUREMENT - elif unit_of_measurement == "A": - self._attr_device_class = SensorDeviceClass.CURRENT - self._attr_state_class = SensorStateClass.MEASUREMENT - elif unit_of_measurement == "VA": - self._attr_device_class = SensorDeviceClass.APPARENT_POWER - self._attr_state_class = SensorStateClass.MEASUREMENT - elif unit_of_measurement in ("°C", "°F", "K"): - self._attr_device_class = SensorDeviceClass.TEMPERATURE - self._attr_state_class = SensorStateClass.MEASUREMENT - elif unit_of_measurement == "Hz": - self._attr_device_class = SensorDeviceClass.FREQUENCY - self._attr_state_class = SensorStateClass.MEASUREMENT - elif unit_of_measurement == "hPa": - self._attr_device_class = SensorDeviceClass.PRESSURE - self._attr_state_class = SensorStateClass.MEASUREMENT + description = SENSORS.get(unit_of_measurement) + if description is not None: + self.entity_description = description + else: + self._attr_native_unit_of_measurement = unit_of_measurement self._update_attributes(elem) def _update_attributes(self, elem: dict[str, Any]) -> None: diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index 0d841f2efb464..5769e8259449a 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -24,6 +24,52 @@ "already_configured": "This server is already configured" } }, + "entity": { + "sensor": { + "energy": { + "name": "Energy {emoncms_details}" + }, + "power": { + "name": "Power {emoncms_details}" + }, + "percent": { + "name": "Percentage {emoncms_details}" + }, + "voltage": { + "name": "Voltage {emoncms_details}" + }, + "current": { + "name": "Current {emoncms_details}" + }, + "apparent_power": { + "name": "Apparent power {emoncms_details}" + }, + "temperature": { + "name": "Temperature {emoncms_details}" + }, + "frequency": { + "name": "Frequency {emoncms_details}" + }, + "pressure": { + "name": "Pressure {emoncms_details}" + }, + "decibel": { + "name": "Decibel {emoncms_details}" + }, + "volume": { + "name": "Volume {emoncms_details}" + }, + "flow": { + "name": "Flow rate {emoncms_details}" + }, + "speed": { + "name": "Speed {emoncms_details}" + }, + "concentration": { + "name": "Concentration {emoncms_details}" + } + } + }, "options": { "error": { "api_error": "[%key:component::emoncms::config::error::api_error%]" diff --git a/tests/components/emoncms/snapshots/test_sensor.ambr b/tests/components/emoncms/snapshots/test_sensor.ambr index f6a2745fb1ad5..210196ce414f0 100644 --- a/tests/components/emoncms/snapshots/test_sensor.ambr +++ b/tests/components/emoncms/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_coordinator_update[sensor.emoncms_1_1_1_1_parameter_1-entry] +# name: test_coordinator_update[sensor.temperature_tag_parameter_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -13,8 +13,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.emoncms_1_1_1_1_parameter_1', - 'has_entity_name': False, + 'entity_id': 'sensor.temperature_tag_parameter_1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -25,16 +25,16 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'emoncms@1.1.1.1 parameter 1', + 'original_name': 'Temperature tag parameter 1', 'platform': 'emoncms', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'temperature', 'unique_id': '123-53535292-1', 'unit_of_measurement': , }) # --- -# name: test_coordinator_update[sensor.emoncms_1_1_1_1_parameter_1-state] +# name: test_coordinator_update[sensor.temperature_tag_parameter_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'FeedId': '1', @@ -45,12 +45,12 @@ 'Tag': 'tag', 'UserId': '1', 'device_class': 'temperature', - 'friendly_name': 'emoncms@1.1.1.1 parameter 1', + 'friendly_name': 'Temperature tag parameter 1', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.emoncms_1_1_1_1_parameter_1', + 'entity_id': 'sensor.temperature_tag_parameter_1', 'last_changed': , 'last_reported': , 'last_updated': , From dfa7ababfb2ded700d60fe64e2654fc034c89212 Mon Sep 17 00:00:00 2001 From: Jake Martin Date: Tue, 26 Nov 2024 18:27:17 +0000 Subject: [PATCH 0908/1070] Raise HomeAssistantError if update fails (#129727) --- homeassistant/components/monzo/coordinator.py | 16 ++++++++++++++-- tests/components/monzo/test_sensor.py | 10 +++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/monzo/coordinator.py b/homeassistant/components/monzo/coordinator.py index 223d7b05ffe0e..caac551f986a9 100644 --- a/homeassistant/components/monzo/coordinator.py +++ b/homeassistant/components/monzo/coordinator.py @@ -3,13 +3,14 @@ from dataclasses import dataclass from datetime import timedelta import logging +from pprint import pformat from typing import Any -from monzopy import AuthorisationExpiredError +from monzopy import AuthorisationExpiredError, InvalidMonzoAPIResponseError from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import AuthenticatedMonzoAPI from .const import DOMAIN @@ -45,5 +46,16 @@ async def _async_update_data(self) -> MonzoData: pots = await self.api.user_account.pots() except AuthorisationExpiredError as err: raise ConfigEntryAuthFailed from err + except InvalidMonzoAPIResponseError as err: + message = "Invalid Monzo API response." + if err.missing_key: + _LOGGER.debug( + "%s\nMissing key: %s\nResponse:\n%s", + message, + err.missing_key, + pformat(err.response), + ) + message += " Enabling debug logging for details." + raise UpdateFailed(message) from err return MonzoData(accounts, pots) diff --git a/tests/components/monzo/test_sensor.py b/tests/components/monzo/test_sensor.py index bf88ce1493118..a57466fdbd4b2 100644 --- a/tests/components/monzo/test_sensor.py +++ b/tests/components/monzo/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory +from monzopy import InvalidMonzoAPIResponseError import pytest from syrupy import SnapshotAssertion @@ -123,15 +124,22 @@ async def test_update_failed( monzo: AsyncMock, polling_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test all entities.""" await setup_integration(hass, polling_config_entry) - monzo.user_account.accounts.side_effect = Exception + monzo.user_account.accounts.side_effect = InvalidMonzoAPIResponseError( + {"acc_id": None}, "account_id" + ) freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) await hass.async_block_till_done() + assert "Invalid Monzo API response." in caplog.text + assert "account_id" in caplog.text + assert "acc_id" in caplog.text + entity_id = await async_get_entity_id( hass, TEST_ACCOUNTS[0]["id"], ACCOUNT_SENSORS[0] ) From ccbbcbb264139f29f2f3db582549d4772cedb195 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 26 Nov 2024 19:27:59 +0100 Subject: [PATCH 0909/1070] Make set value template number option required (#131625) --- .../components/template/config_flow.py | 2 +- tests/components/template/test_config_flow.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index c1c023c0ea415..8ecef8539d335 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -157,7 +157,7 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: type=selector.TextSelectorType.TEXT, multiline=False ) ), - vol.Optional(CONF_SET_VALUE): selector.ActionSelector(), + vol.Required(CONF_SET_VALUE): selector.ActionSelector(), } if domain == Platform.SELECT: diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 18b55d05672cb..e0d95ff968d92 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -273,11 +273,21 @@ async def test_config_flow( "min": "0", "max": "100", "step": "0.1", + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, { "min": 0, "max": 100, "step": 0.1, + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, ), ( @@ -1263,11 +1273,21 @@ async def test_option_flow_sensor_preview_config_entry_removed( "min": 0, "max": 100, "step": 0.1, + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, { "min": 0, "max": 100, "step": 0.1, + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, ), ( From 132a8cc31b4da5ef9bb6926c7c5f97f51c1e960b Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 26 Nov 2024 18:30:05 +0000 Subject: [PATCH 0910/1070] Detect ingress host used when adding a Mealie integration (#130418) Co-authored-by: Franck Nijhof --- .../components/mealie/config_flow.py | 4 +++ homeassistant/components/mealie/strings.json | 3 +- tests/components/mealie/test_config_flow.py | 34 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mealie/config_flow.py b/homeassistant/components/mealie/config_flow.py index 2f90ceaf97afc..2addd23284ee4 100644 --- a/homeassistant/components/mealie/config_flow.py +++ b/homeassistant/components/mealie/config_flow.py @@ -38,6 +38,10 @@ async def check_connection( ) -> tuple[dict[str, str], str | None]: """Check connection to the Mealie API.""" assert self.host is not None + + if "/hassio/ingress/" in self.host: + return {"base": "ingress_url"}, None + client = MealieClient( self.host, token=api_token, diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index 5555d3ffa21f6..830d43d8f931d 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -8,7 +8,7 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "The URL of your Mealie instance." + "host": "The URL of your Mealie instance, for example, http://192.168.1.123:1234" } }, "reauth_confirm": { @@ -29,6 +29,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "ingress_url": "Ingress URLs are only used for accessing the Mealie UI. Use your Home Assistant IP address and the network port within the configuration tab of the Mealie add-on.", "unknown": "[%key:common::config_flow::error::unknown%]", "mealie_version": "Minimum required version is v1.0.0. Please upgrade Mealie and then retry." }, diff --git a/tests/components/mealie/test_config_flow.py b/tests/components/mealie/test_config_flow.py index 15c629ec3da33..628f0290f43e5 100644 --- a/tests/components/mealie/test_config_flow.py +++ b/tests/components/mealie/test_config_flow.py @@ -85,6 +85,40 @@ async def test_flow_errors( assert result["type"] is FlowResultType.CREATE_ENTRY +async def test_ingress_host( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test disallow ingress host.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "http://homeassistant/hassio/ingress/db21ed7f_mealie", + CONF_API_TOKEN: "token", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "ingress_url"} + + mock_mealie_client.get_user_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "http://homeassistant:9001", CONF_API_TOKEN: "token"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + @pytest.mark.parametrize( ("version"), [ From 35f6ae07594f0de52b416aea1bca44796f1bb0f7 Mon Sep 17 00:00:00 2001 From: blackovercoat Date: Wed, 27 Nov 2024 01:38:52 +0700 Subject: [PATCH 0911/1070] Add support for single phase power meter aqcz in Tuya (#126470) --- homeassistant/components/tuya/sensor.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index b9677037b7edd..81c8e64be6d57 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -254,6 +254,31 @@ class TuyaSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), ), + # Single Phase power meter + # Note: Undocumented + "aqcz": ( + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + ), # CO Detector # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v "cobj": ( From f095aea5c342e4aacd7392e880be12793e5b6739 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 26 Nov 2024 19:59:19 +0100 Subject: [PATCH 0912/1070] Record current IQS state for Stookwijzer (#131592) * Record current IQS state for Stookwijzer * Also mark test coverage * Process review comment --- .../components/stookwijzer/quality_scale.yaml | 89 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/stookwijzer/quality_scale.yaml diff --git a/homeassistant/components/stookwijzer/quality_scale.yaml b/homeassistant/components/stookwijzer/quality_scale.yaml new file mode 100644 index 0000000000000..67fadc00b646e --- /dev/null +++ b/homeassistant/components/stookwijzer/quality_scale.yaml @@ -0,0 +1,89 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration doesn't provide any additional service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration doesn't provide any additional service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + The integration doesn't subscribe to any events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: todo + test-before-setup: done + unique-config-entry: todo + + # Silver + action-exceptions: + status: exempt + comment: | + This integration is read-only and doesn't provide any actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: | + This integration is read-only and doesn't provide any actions. Querying + the service for data is handled centrally using a data update coordinator. + reauthentication-flow: + status: exempt + comment: | + This integration doesn't require re-authentication. + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + The integration cannot be discovered, as it is an external service. + discovery: + status: exempt + comment: | + The integration cannot be discovered, as it is an external service. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration provides a single device entry for the service. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: todo + reconfiguration-flow: todo + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration provides a single device entry for the service. + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index bbb2d3e4d0a17..2c60d06e3e10e 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -968,7 +968,6 @@ "steamist", "stiebel_eltron", "stookalert", - "stookwijzer", "stream", "streamlabswater", "subaru", From 6e8f3d939385433abca0ead38e19546898b7d5fd Mon Sep 17 00:00:00 2001 From: Marco Aceti Date: Tue, 26 Nov 2024 20:00:13 +0100 Subject: [PATCH 0913/1070] Add missing sensors to Tuya CO2 Detector (#131313) --- homeassistant/components/tuya/number.py | 11 +++++++++++ homeassistant/components/tuya/select.py | 9 +++++++++ homeassistant/components/tuya/sensor.py | 6 ++++++ homeassistant/components/tuya/siren.py | 9 +++++++++ homeassistant/components/tuya/strings.json | 3 +++ 5 files changed, 38 insertions(+) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index d2e381d998249..8d5b5dbfa19e1 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -292,6 +292,17 @@ device_class=NumberDeviceClass.TEMPERATURE, ), ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + NumberEntityDescription( + key=DPCode.ALARM_TIME, + translation_key="alarm_duration", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + ), } diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index abc5e4c496b47..831d3cb3e0c91 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -307,6 +307,15 @@ entity_category=EntityCategory.CONFIG, ), ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + SelectEntityDescription( + key=DPCode.ALARM_VOLUME, + translation_key="volume", + entity_category=EntityCategory.CONFIG, + ), + ), } # Socket (duplicate of `kg`) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 81c8e64be6d57..f766c744998a8 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -214,6 +214,12 @@ class TuyaSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.PM25_VALUE, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), *BATTERY_SENSORS, ), # Two-way temperature and humidity switch diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 334dced134d3e..6f7dfe4c96c10 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -11,6 +11,7 @@ SirenEntityDescription, SirenEntityFeature, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -43,6 +44,14 @@ key=DPCode.SIREN_SWITCH, ), ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + SirenEntityDescription( + key=DPCode.ALARM_SWITCH, + entity_category=EntityCategory.CONFIG, + ), + ), } diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 0f005821cbb01..8ec61cc8aa51d 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -119,6 +119,9 @@ } }, "number": { + "alarm_duration": { + "name": "Alarm duration" + }, "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, From 2edcda47b04f2760ca88156274c03701c4fbc794 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 26 Nov 2024 20:02:01 +0100 Subject: [PATCH 0914/1070] Add diagnostics platform to Habitica (#131489) --- .../components/habitica/diagnostics.py | 27 + tests/components/habitica/conftest.py | 9 + .../habitica/snapshots/test_diagnostics.ambr | 715 ++++++++++++++++++ tests/components/habitica/test_diagnostics.py | 27 + 4 files changed, 778 insertions(+) create mode 100644 homeassistant/components/habitica/diagnostics.py create mode 100644 tests/components/habitica/snapshots/test_diagnostics.ambr create mode 100644 tests/components/habitica/test_diagnostics.py diff --git a/homeassistant/components/habitica/diagnostics.py b/homeassistant/components/habitica/diagnostics.py new file mode 100644 index 0000000000000..bca7994650312 --- /dev/null +++ b/homeassistant/components/habitica/diagnostics.py @@ -0,0 +1,27 @@ +"""Diagnostics platform for Habitica integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from .const import CONF_API_USER +from .types import HabiticaConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: HabiticaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + habitica_data = await config_entry.runtime_data.api.user.anonymized.get() + + return { + "config_entry_data": { + CONF_URL: config_entry.data[CONF_URL], + CONF_API_USER: config_entry.data[CONF_API_USER], + }, + "habitica_data": habitica_data, + } diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 8d729f4358fcd..f76987c5ce683 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -61,6 +61,15 @@ def mock_habitica(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: params={"language": "en"}, json=load_json_object_fixture("content.json", DOMAIN), ) + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/user/anonymized", + json={ + "data": { + "user": load_json_object_fixture("user.json", DOMAIN)["data"], + "tasks": load_json_object_fixture("tasks.json", DOMAIN)["data"], + } + }, + ) return aioclient_mock diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..bb9371a4c685d --- /dev/null +++ b/tests/components/habitica/snapshots/test_diagnostics.ambr @@ -0,0 +1,715 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry_data': dict({ + 'api_user': 'test-api-user', + 'url': 'https://habitica.com', + }), + 'habitica_data': dict({ + 'tasks': list([ + dict({ + '_id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + }), + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': '2024-07-07T17:51:53.268Z', + 'down': True, + 'frequency': 'daily', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'history': list([ + ]), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', + 'notes': '', + 'priority': 1, + 'reminders': list([ + ]), + 'tags': list([ + ]), + 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', + 'up': True, + 'updatedAt': '2024-07-07T17:51:53.268Z', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 0, + }), + dict({ + '_id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + }), + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': '2024-07-07T17:51:53.266Z', + 'down': False, + 'frequency': 'daily', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'history': list([ + dict({ + 'date': 1720376763324, + 'scoredDown': 0, + 'scoredUp': 1, + 'value': 1, + }), + ]), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', + 'notes': '', + 'priority': 1, + 'reminders': list([ + ]), + 'tags': list([ + ]), + 'text': 'Eine kurze Pause machen', + 'type': 'habit', + 'up': True, + 'updatedAt': '2024-07-12T09:58:45.438Z', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 0, + }), + dict({ + '_id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + }), + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': '2024-07-07T17:51:53.265Z', + 'down': True, + 'frequency': 'daily', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'history': list([ + ]), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', + 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', + 'priority': 1, + 'reminders': list([ + ]), + 'tags': list([ + ]), + 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', + 'up': False, + 'updatedAt': '2024-07-07T17:51:53.265Z', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 0, + }), + dict({ + '_id': 'e97659e0-2c42-4599-a7bb-00282adc410d', + 'alias': 'create_a_task', + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + }), + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': '2024-07-07T17:51:53.264Z', + 'down': False, + 'frequency': 'daily', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'history': list([ + dict({ + 'date': 1720376763140, + 'scoredDown': 0, + 'scoredUp': 1, + 'value': 1, + }), + ]), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', + 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', + 'priority': 1, + 'reminders': list([ + ]), + 'tags': list([ + ]), + 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', + 'up': True, + 'updatedAt': '2024-07-12T09:58:45.438Z', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 0, + }), + dict({ + '_id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': True, + 'createdAt': '2024-07-07T17:51:53.268Z', + 'daysOfMonth': list([ + ]), + 'everyX': 1, + 'frequency': 'weekly', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'history': list([ + dict({ + 'completed': True, + 'date': 1720376766749, + 'isDue': True, + 'value': 1, + }), + dict({ + 'completed': False, + 'date': 1720545311292, + 'isDue': True, + 'value': 0.02529999999999999, + }), + dict({ + 'completed': False, + 'date': 1720564306719, + 'isDue': True, + 'value': -0.9740518837628547, + }), + dict({ + 'completed': True, + 'date': 1720691096907, + 'isDue': True, + 'value': 0.051222853419153, + }), + dict({ + 'completed': True, + 'date': 1720778325243, + 'isDue': True, + 'value': 1.0499115128458676, + }), + dict({ + 'completed': False, + 'date': 1724185196447, + 'isDue': True, + 'value': 0.07645736684721605, + }), + dict({ + 'completed': False, + 'date': 1724255707692, + 'isDue': True, + 'value': -0.921585289356988, + }), + dict({ + 'completed': False, + 'date': 1726846163640, + 'isDue': True, + 'value': -1.9454824860630637, + }), + dict({ + 'completed': False, + 'date': 1726953787542, + 'isDue': True, + 'value': -2.9966001649571803, + }), + dict({ + 'completed': False, + 'date': 1726956115608, + 'isDue': True, + 'value': -4.07641493832036, + }), + dict({ + 'completed': True, + 'date': 1726957460150, + 'isDue': True, + 'value': -2.9663035443712333, + }), + ]), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + 'isDue': True, + 'nextDue': list([ + 'Mon Sep 23 2024 00:00:00 GMT+0200', + 'Tue Sep 24 2024 00:00:00 GMT+0200', + 'Wed Sep 25 2024 00:00:00 GMT+0200', + 'Thu Sep 26 2024 00:00:00 GMT+0200', + 'Fri Sep 27 2024 00:00:00 GMT+0200', + 'Sat Sep 28 2024 00:00:00 GMT+0200', + ]), + 'notes': 'Klicke um Änderungen zu machen!', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': '2024-07-06T22:00:00.000Z', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Zahnseide benutzen', + 'type': 'daily', + 'updatedAt': '2024-09-21T22:24:20.154Z', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -2.9663035443712333, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + '_id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'createdAt': '2024-07-07T17:51:53.266Z', + 'daysOfMonth': list([ + ]), + 'everyX': 1, + 'frequency': 'weekly', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'history': list([ + dict({ + 'completed': True, + 'date': 1720374903074, + 'isDue': True, + 'value': 1, + }), + dict({ + 'completed': False, + 'date': 1720545311291, + 'isDue': True, + 'value': 0.02529999999999999, + }), + dict({ + 'completed': False, + 'date': 1720564306717, + 'isDue': True, + 'value': -0.9740518837628547, + }), + dict({ + 'completed': True, + 'date': 1720682459722, + 'isDue': True, + 'value': 0.051222853419153, + }), + dict({ + 'completed': True, + 'date': 1720778325246, + 'isDue': True, + 'value': 1.0499115128458676, + }), + dict({ + 'completed': True, + 'date': 1720778492219, + 'isDue': True, + 'value': 2.023365658844519, + }), + dict({ + 'completed': False, + 'date': 1724255707691, + 'isDue': True, + 'value': 1.0738942424964806, + }), + dict({ + 'completed': False, + 'date': 1726846163638, + 'isDue': True, + 'value': 0.10103816898038132, + }), + dict({ + 'completed': False, + 'date': 1726953787540, + 'isDue': True, + 'value': -0.8963760215867302, + }), + dict({ + 'completed': False, + 'date': 1726956115607, + 'isDue': True, + 'value': -1.919611992979862, + }), + ]), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + 'isDue': True, + 'nextDue': list([ + '2024-09-22T22:00:00.000Z', + '2024-09-23T22:00:00.000Z', + '2024-09-24T22:00:00.000Z', + '2024-09-25T22:00:00.000Z', + '2024-09-26T22:00:00.000Z', + '2024-09-27T22:00:00.000Z', + ]), + 'notes': 'Klicke um Deinen Terminplan festzulegen!', + 'priority': 1, + 'reminders': list([ + dict({ + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', + 'time': '2024-09-22T20:00:00.0000Z', + }), + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': '2024-07-06T22:00:00.000Z', + 'streak': 0, + 'tags': list([ + ]), + 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', + 'updatedAt': '2024-09-21T22:51:41.756Z', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -1.919611992979862, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + '_id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'createdAt': '2024-09-22T11:44:43.774Z', + 'daysOfMonth': list([ + ]), + 'everyX': 1, + 'frequency': 'weekly', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'history': list([ + ]), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', + 'isDue': True, + 'nextDue': list([ + '2024-09-24T22:00:00.000Z', + '2024-09-27T22:00:00.000Z', + '2024-09-28T22:00:00.000Z', + '2024-10-01T22:00:00.000Z', + '2024-10-04T22:00:00.000Z', + '2024-10-08T22:00:00.000Z', + ]), + 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'priority': 2, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': True, + 'su': True, + 't': False, + 'th': False, + 'w': True, + }), + 'startDate': '2024-09-21T22:00:00.000Z', + 'streak': 0, + 'tags': list([ + '51076966-2970-4b40-b6ba-d58c6a756dd7', + ]), + 'text': 'Fitnessstudio besuchen', + 'type': 'daily', + 'updatedAt': '2024-09-22T11:44:43.774Z', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', + 'value': 0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + '_id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'createdAt': '2024-09-21T22:17:57.816Z', + 'date': '2024-09-27T22:17:00.000Z', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', + 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', + 'priority': 1, + 'reminders': list([ + ]), + 'tags': list([ + ]), + 'text': 'Buch zu Ende lesen', + 'type': 'todo', + 'updatedAt': '2024-09-21T22:17:57.816Z', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 0, + }), + dict({ + '_id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', + 'alias': 'pay_bills', + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'createdAt': '2024-09-21T22:17:19.513Z', + 'date': '2024-08-31T22:16:00.000Z', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', + 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', + 'priority': 1, + 'reminders': list([ + dict({ + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', + 'time': '2024-09-22T02:00:00.0000Z', + }), + ]), + 'tags': list([ + ]), + 'text': 'Rechnungen bezahlen', + 'type': 'todo', + 'updatedAt': '2024-09-21T22:19:35.576Z', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 0, + }), + dict({ + '_id': '1aa3137e-ef72-4d1f-91ee-41933602f438', + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'createdAt': '2024-09-21T22:16:38.153Z', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', + 'notes': 'Rasen mähen und die Pflanzen gießen.', + 'priority': 1, + 'reminders': list([ + ]), + 'tags': list([ + ]), + 'text': 'Garten pflegen', + 'type': 'todo', + 'updatedAt': '2024-09-21T22:16:38.153Z', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 0, + }), + dict({ + '_id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'createdAt': '2024-09-21T22:16:16.756Z', + 'date': '2024-09-21T22:00:00.000Z', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', + 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', + 'priority': 1, + 'reminders': list([ + ]), + 'tags': list([ + '51076966-2970-4b40-b6ba-d58c6a756dd7', + ]), + 'text': 'Wochenendausflug planen', + 'type': 'todo', + 'updatedAt': '2024-09-21T22:16:16.756Z', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 0, + }), + dict({ + '_id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + }), + 'createdAt': '2024-07-07T17:51:53.266Z', + 'group': dict({ + 'assignedUsers': list([ + ]), + 'completedBy': dict({ + }), + }), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', + 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', + 'priority': 1, + 'reminders': list([ + ]), + 'tags': list([ + ]), + 'text': 'Belohne Dich selbst', + 'type': 'reward', + 'updatedAt': '2024-07-07T17:51:53.266Z', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 10, + }), + ]), + 'user': dict({ + 'api_user': 'test-api-user', + 'auth': dict({ + 'local': dict({ + 'username': 'test-username', + }), + }), + 'flags': dict({ + 'classSelected': True, + }), + 'id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303', + 'items': dict({ + 'gear': dict({ + 'equipped': dict({ + 'armor': 'armor_warrior_5', + 'back': 'back_special_heroicAureole', + 'body': 'body_special_aetherAmulet', + 'eyewear': 'eyewear_armoire_plagueDoctorMask', + 'head': 'head_warrior_5', + 'headAccessory': 'headAccessory_armoire_gogglesOfBookbinding', + 'shield': 'shield_warrior_5', + 'weapon': 'weapon_warrior_5', + }), + }), + }), + 'lastCron': '2024-09-21T22:01:55.586Z', + 'needsCron': True, + 'party': dict({ + '_id': '94cd398c-2240-4320-956e-6d345cf2c0de', + 'quest': dict({ + 'RSVPNeeded': True, + 'key': 'dustbunnies', + }), + }), + 'preferences': dict({ + 'automaticAllocation': True, + 'disableClasses': False, + 'language': 'en', + 'sleep': False, + }), + 'profile': dict({ + 'name': 'test-user', + }), + 'stats': dict({ + 'buffs': dict({ + 'con': 26, + 'int': 26, + 'per': 26, + 'seafoam': False, + 'shinySeed': False, + 'snowball': False, + 'spookySparkles': False, + 'stealth': 0, + 'str': 26, + 'streaks': False, + }), + 'class': 'wizard', + 'con': 15, + 'exp': 737, + 'gp': 137.62587214609795, + 'hp': 0, + 'int': 15, + 'lvl': 38, + 'maxHealth': 50, + 'maxMP': 166, + 'mp': 50.89999999999998, + 'per': 15, + 'points': 5, + 'str': 15, + 'toNextLevel': 880, + }), + 'tasksOrder': dict({ + 'dailys': list([ + 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', + 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', + 'e97659e0-2c42-4599-a7bb-00282adc410d', + '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', + ]), + 'habits': list([ + '1d147de6-5c02-4740-8e2f-71d3015a37f4', + ]), + 'rewards': list([ + '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', + ]), + 'todos': list([ + '88de7cd9-af2b-49ce-9afd-bf941d87336b', + '2f6fcabc-f670-4ec3-ba65-817e8deea490', + '1aa3137e-ef72-4d1f-91ee-41933602f438', + '86ea2475-d1b5-4020-bdcc-c188c7996afa', + ]), + }), + }), + }), + }) +# --- diff --git a/tests/components/habitica/test_diagnostics.py b/tests/components/habitica/test_diagnostics.py new file mode 100644 index 0000000000000..68b40fe254aeb --- /dev/null +++ b/tests/components/habitica/test_diagnostics.py @@ -0,0 +1,27 @@ +"""Tests for Habitica diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_habitica") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From f3964596de543f28601040fd83565fae4b5b97c0 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 26 Nov 2024 19:50:26 +0000 Subject: [PATCH 0915/1070] tplink: forward compatible typing and test changes for kasa 0.8 (#131623) --- homeassistant/components/tplink/__init__.py | 2 +- .../components/tplink/binary_sensor.py | 4 ++-- homeassistant/components/tplink/climate.py | 8 ++++--- homeassistant/components/tplink/number.py | 4 ++-- homeassistant/components/tplink/select.py | 2 +- homeassistant/components/tplink/sensor.py | 8 ++++++- homeassistant/components/tplink/switch.py | 4 ++-- tests/components/tplink/__init__.py | 14 ++++++++---- tests/components/tplink/test_init.py | 22 +++++++++---------- 9 files changed, 41 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index ee1d90e70b4d6..a7ffce686be87 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -148,7 +148,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo if conn_params_dict := entry.data.get(CONF_CONNECTION_PARAMETERS): try: conn_params = Device.ConnectionParameters.from_dict(conn_params_dict) - except KasaException: + except (KasaException, TypeError, ValueError, LookupError): _LOGGER.warning( "Invalid connection parameters dict for %s: %s", host, conn_params_dict ) diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index 34375bccf4f79..e14ecf017496c 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Final +from typing import Final, cast from kasa import Feature @@ -98,4 +98,4 @@ class TPLinkBinarySensorEntity(CoordinatedTPLinkFeatureEntity, BinarySensorEntit @callback def _async_update_attrs(self) -> None: """Update the entity's attributes.""" - self._attr_is_on = self._feature.value + self._attr_is_on = cast(bool | None, self._feature.value) diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index f86992ea0cfd6..0bd25d9f80c92 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -116,8 +116,8 @@ async def async_turn_off(self) -> None: @callback def _async_update_attrs(self) -> None: """Update the entity's attributes.""" - self._attr_current_temperature = self._temp_feature.value - self._attr_target_temperature = self._target_feature.value + self._attr_current_temperature = cast(float | None, self._temp_feature.value) + self._attr_target_temperature = cast(float | None, self._target_feature.value) self._attr_hvac_mode = ( HVACMode.HEAT if self._state_feature.value else HVACMode.OFF @@ -134,7 +134,9 @@ def _async_update_attrs(self) -> None: self._attr_hvac_action = HVACAction.OFF return - self._attr_hvac_action = STATE_TO_ACTION[self._mode_feature.value] + self._attr_hvac_action = STATE_TO_ACTION[ + cast(ThermostatState, self._mode_feature.value) + ] def _get_unique_id(self) -> str: """Return unique id.""" diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 5f80d5479d2f8..b51c00db7c022 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -4,7 +4,7 @@ from dataclasses import dataclass import logging -from typing import Final +from typing import Final, cast from kasa import Device, Feature @@ -108,4 +108,4 @@ async def async_set_native_value(self, value: float) -> None: @callback def _async_update_attrs(self) -> None: """Update the entity's attributes.""" - self._attr_native_value = self._feature.value + self._attr_native_value = cast(float | None, self._feature.value) diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py index 41e3224215bfd..3755a1d0be2ab 100644 --- a/homeassistant/components/tplink/select.py +++ b/homeassistant/components/tplink/select.py @@ -93,4 +93,4 @@ async def async_select_option(self, option: str) -> None: @callback def _async_update_attrs(self) -> None: """Update the entity's attributes.""" - self._attr_current_option = self._feature.value + self._attr_current_option = cast(str | None, self._feature.value) diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 809d900276800..8b7351f8d7d96 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import cast +from typing import TYPE_CHECKING, cast from kasa import Feature @@ -161,6 +161,12 @@ def _async_update_attrs(self) -> None: # We probably do not need this, when we are rounding already? self._attr_suggested_display_precision = self._feature.precision_hint + if TYPE_CHECKING: + # pylint: disable-next=import-outside-toplevel + from datetime import date, datetime + + assert isinstance(value, str | int | float | date | datetime | None) + self._attr_native_value = value # Map to homeassistant units and fallback to upstream one if none found if (unit := self._feature.unit) is not None: diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index c9285d86ba60c..7e22375266548 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -4,7 +4,7 @@ from dataclasses import dataclass import logging -from typing import Any +from typing import Any, cast from kasa import Feature @@ -99,4 +99,4 @@ async def async_turn_off(self, **kwargs: Any) -> None: @callback def _async_update_attrs(self) -> None: """Update the entity's attributes.""" - self._attr_is_on = self._feature.value + self._attr_is_on = cast(bool | None, self._feature.value) diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 75eab8eeb7326..809ab3bfd78b2 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from kasa import ( + BaseProtocol, Device, DeviceConfig, DeviceConnectionParameters, @@ -17,7 +18,6 @@ Module, ) from kasa.interfaces import Fan, Light, LightEffect, LightState -from kasa.protocol import BaseProtocol from kasa.smart.modules.alarm import Alarm from syrupy import SnapshotAssertion @@ -62,7 +62,9 @@ DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor ) DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS) -DEVICE_CONFIG_DICT_LEGACY = DEVICE_CONFIG_LEGACY.to_dict(exclude_credentials=True) +DEVICE_CONFIG_DICT_LEGACY = { + k: v for k, v in DEVICE_CONFIG_LEGACY.to_dict().items() if k != "credentials" +} CREDENTIALS = Credentials("foo", "bar") CREDENTIALS_HASH_AES = "AES/abcdefghijklmnopqrstuvabcdefghijklmnopqrstuv==" CREDENTIALS_HASH_KLAP = "KLAP/abcdefghijklmnopqrstuv==" @@ -86,8 +88,12 @@ uses_http=True, aes_keys=AES_KEYS, ) -DEVICE_CONFIG_DICT_KLAP = DEVICE_CONFIG_KLAP.to_dict(exclude_credentials=True) -DEVICE_CONFIG_DICT_AES = DEVICE_CONFIG_AES.to_dict(exclude_credentials=True) +DEVICE_CONFIG_DICT_KLAP = { + k: v for k, v in DEVICE_CONFIG_KLAP.to_dict().items() if k != "credentials" +} +DEVICE_CONFIG_DICT_AES = { + k: v for k, v in DEVICE_CONFIG_AES.to_dict().items() if k != "credentials" +} CREATE_ENTRY_DATA_LEGACY = { CONF_HOST: IP_ADDRESS, CONF_ALIAS: ALIAS, diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index dd01c381adfa2..766e6784c8bf7 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -45,6 +45,7 @@ CREDENTIALS_HASH_AES, CREDENTIALS_HASH_KLAP, DEVICE_CONFIG_AES, + DEVICE_CONFIG_DICT_KLAP, DEVICE_CONFIG_KLAP, DEVICE_CONFIG_LEGACY, DEVICE_ID, @@ -538,9 +539,8 @@ async def test_move_credentials_hash( from the device. """ device_config = { - **DEVICE_CONFIG_KLAP.to_dict( - exclude_credentials=True, credentials_hash="theHash" - ) + **DEVICE_CONFIG_DICT_KLAP, + "credentials_hash": "theHash", } entry_data = {**CREATE_ENTRY_DATA_KLAP, CONF_DEVICE_CONFIG: device_config} @@ -586,9 +586,8 @@ async def test_move_credentials_hash_auth_error( in async_setup_entry. """ device_config = { - **DEVICE_CONFIG_KLAP.to_dict( - exclude_credentials=True, credentials_hash="theHash" - ) + **DEVICE_CONFIG_DICT_KLAP, + "credentials_hash": "theHash", } entry_data = {**CREATE_ENTRY_DATA_KLAP, CONF_DEVICE_CONFIG: device_config} @@ -630,9 +629,8 @@ async def test_move_credentials_hash_other_error( at the end of the test. """ device_config = { - **DEVICE_CONFIG_KLAP.to_dict( - exclude_credentials=True, credentials_hash="theHash" - ) + **DEVICE_CONFIG_DICT_KLAP, + "credentials_hash": "theHash", } entry_data = {**CREATE_ENTRY_DATA_KLAP, CONF_DEVICE_CONFIG: device_config} @@ -729,7 +727,7 @@ async def test_credentials_hash_auth_error( await hass.async_block_till_done() expected_config = DeviceConfig.from_dict( - DEVICE_CONFIG_KLAP.to_dict(exclude_credentials=True, credentials_hash="theHash") + {**DEVICE_CONFIG_DICT_KLAP, "credentials_hash": "theHash"} ) expected_config.uses_http = False expected_config.http_client = "Foo" @@ -767,7 +765,9 @@ async def test_migrate_remove_device_config( CONF_HOST: expected_entry_data[CONF_HOST], CONF_ALIAS: ALIAS, CONF_MODEL: MODEL, - CONF_DEVICE_CONFIG: device_config.to_dict(exclude_credentials=True), + CONF_DEVICE_CONFIG: { + k: v for k, v in device_config.to_dict().items() if k != "credentials" + }, } entry = MockConfigEntry( From 7a107cac41da43c2b961e52ba4ad15ceeb55cd33 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:09:45 +0100 Subject: [PATCH 0916/1070] Add PARALLEL_UPDATES to Husqvarna Automower (#131662) --- homeassistant/components/husqvarna_automower/button.py | 2 ++ homeassistant/components/husqvarna_automower/lawn_mower.py | 7 ++++--- homeassistant/components/husqvarna_automower/number.py | 2 ++ homeassistant/components/husqvarna_automower/select.py | 1 + homeassistant/components/husqvarna_automower/switch.py | 2 ++ 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 22a732ec54c98..ce3033254963c 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -22,6 +22,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class AutomowerButtonEntityDescription(ButtonEntityDescription): diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index eeabaa09f7983..9b3ce7dab1afd 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -22,6 +22,10 @@ from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerAvailableEntity, handle_sending_exception +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + DOCKED_ACTIVITIES = (MowerActivities.PARKED_IN_CS, MowerActivities.CHARGING) MOWING_ACTIVITIES = ( MowerActivities.MOWING, @@ -42,9 +46,6 @@ OVERRIDE_MODES = [MOW, PARK] -_LOGGER = logging.getLogger(__name__) - - async def async_setup_entry( hass: HomeAssistant, entry: AutomowerConfigEntry, diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index d6d794f2d83a0..e69b52fab93a9 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -24,6 +24,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + @callback def _async_get_cutting_height(data: MowerAttributes) -> int: diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py index a9431acaae361..65960e897e48b 100644 --- a/homeassistant/components/husqvarna_automower/select.py +++ b/homeassistant/components/husqvarna_automower/select.py @@ -16,6 +16,7 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 HEADLIGHT_MODES: list = [ HeadlightModes.ALWAYS_OFF.lower(), diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py index 2bbe5c87624f7..352b4c59ba162 100644 --- a/homeassistant/components/husqvarna_automower/switch.py +++ b/homeassistant/components/husqvarna_automower/switch.py @@ -19,6 +19,8 @@ handle_sending_exception, ) +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) From a7113cff68ff07fa620512af647755f9b53ee50e Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:14:52 +0100 Subject: [PATCH 0917/1070] Record current IQS state for acaia (#131086) --- .../components/acaia/quality_scale.yaml | 106 ++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/acaia/quality_scale.yaml diff --git a/homeassistant/components/acaia/quality_scale.yaml b/homeassistant/components/acaia/quality_scale.yaml new file mode 100644 index 0000000000000..9f9f8da8d5dd1 --- /dev/null +++ b/homeassistant/components/acaia/quality_scale.yaml @@ -0,0 +1,106 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + No explicit event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: exempt + comment: | + Device is expected to be offline most of the time, but needs to connect quickly once available. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: | + Handled by coordinator. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + No authentication required. + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + No IP discovery. + discovery: + status: done + comment: | + Bluetooth discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Device type integration. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No noisy/non-essential entities. + entity-translations: done + exception-translations: + status: exempt + comment: | + No custom exceptions. + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + Only parameter that could be changed (MAC = unique_id) would force a new config entry. + repair-issues: + status: exempt + comment: | + No repairs/issues. + stale-devices: + status: exempt + comment: | + Device type integration. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + Bluetooth connection. + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 2c60d06e3e10e..4db761393603b 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -78,7 +78,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "abode", - "acaia", "accuweather", "acer_projector", "acmeda", From 06f9678414425aaadb8eca7750d49e44d8d28936 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:24:57 +0100 Subject: [PATCH 0918/1070] Add quality scale for solarlog (#131440) Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> --- .../components/solarlog/manifest.json | 1 + .../components/solarlog/quality_scale.yaml | 81 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/solarlog/quality_scale.yaml diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index 5bea017781dbe..486b30edfd3c2 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/solarlog", "iot_class": "local_polling", "loggers": ["solarlog_cli"], + "quality_scale": "platinum", "requirements": ["solarlog_cli==0.4.0"] } diff --git a/homeassistant/components/solarlog/quality_scale.yaml b/homeassistant/components/solarlog/quality_scale.yaml new file mode 100644 index 0000000000000..543889ee18c3a --- /dev/null +++ b/homeassistant/components/solarlog/quality_scale.yaml @@ -0,0 +1,81 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: No explicit event subscriptions. + dependency-transparency: done + action-setup: + status: exempt + comment: No custom action. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: No custom action. + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: No custom action. + reauthentication-flow: done + parallel-updates: + status: exempt + comment: Coordinator and sensor only platform. + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: No options flow. + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: + status: exempt + comment: Solar-Log device cannot be discovered. + stale-devices: done + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: done + discovery-update-info: + status: exempt + comment: Solar-Log device cannot be discovered. + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: + status: exempt + comment: | + This integration doesn't have known issues that could be resolved by the user. + docs-examples: done + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 4db761393603b..c57a1beb6b614 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -938,7 +938,6 @@ "snooz", "solaredge", "solaredge_local", - "solarlog", "solax", "soma", "somfy_mylink", From 859daefeb83bf256907039291852db672e509083 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:32:51 +0100 Subject: [PATCH 0919/1070] Record current quality scale in renault (#131394) --- .../components/renault/quality_scale.yaml | 66 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/renault/quality_scale.yaml diff --git a/homeassistant/components/renault/quality_scale.yaml b/homeassistant/components/renault/quality_scale.yaml new file mode 100644 index 0000000000000..aa693e8e86d6d --- /dev/null +++ b/homeassistant/components/renault/quality_scale.yaml @@ -0,0 +1,66 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: + status: todo + comment: Tests are not asserting the unique id + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Discovery not possible + discovery: + status: exempt + comment: Discovery not possible + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: done + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index c57a1beb6b614..d842b17f98d8b 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -839,7 +839,6 @@ "rejseplanen", "remember_the_milk", "remote_rpi_gpio", - "renault", "renson", "reolink", "repetier", From a0893bb9f7e528847f5b281c5ea8fa7a9c9b89b9 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:33:45 +0100 Subject: [PATCH 0920/1070] Mark HomeWizard quality scale as platinum (#131663) --- homeassistant/components/homewizard/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 78b80dd788e16..13bfc51255151 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -11,6 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/homewizard", "iot_class": "local_polling", "loggers": ["homewizard_energy"], + "quality_scale": "platinum", "requirements": ["python-homewizard-energy==v7.0.0"], "zeroconf": ["_hwenergy._tcp.local."] } From 055c38a3c819ccfc042a155af7759e39fe0d38c1 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:38:46 +0100 Subject: [PATCH 0921/1070] Don't enable number of collisions by default for Husqvarna Automower (#131665) --- homeassistant/components/husqvarna_automower/sensor.py | 1 + tests/components/husqvarna_automower/test_sensor.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index ebb6803391879..70b5510de3699 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -349,6 +349,7 @@ class AutomowerSensorEntityDescription(SensorEntityDescription): key="number_of_collisions", translation_key="number_of_collisions", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL, exists_fn=lambda data: data.statistics.number_of_collisions is not None, value_fn=attrgetter("statistics.number_of_collisions"), diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index 06fcc30e40c2b..08ed525134495 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -111,6 +111,7 @@ async def test_work_area_sensor( assert state.state == "my_lawn" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("sensor_to_test"), [ @@ -167,6 +168,7 @@ async def test_error_sensor( assert state.state == expected_state +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_snapshot( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 1e6b96131a9fc4cd4b39cb6fdef8c451a27aab6a Mon Sep 17 00:00:00 2001 From: prabhjotsbhatia-ca <56749856+prabhjotsbhatia-ca@users.noreply.github.com> Date: Tue, 26 Nov 2024 15:57:57 -0500 Subject: [PATCH 0922/1070] Bump androidtv to 0.0.75 (#131642) --- homeassistant/components/androidtv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 2d0b062c75016..fe8e36f0c2f0a 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -9,7 +9,7 @@ "loggers": ["adb_shell", "androidtv", "pure_python_adb"], "requirements": [ "adb-shell[async]==0.4.4", - "androidtv[async]==0.0.73", + "androidtv[async]==0.0.75", "pure-python-adb[async]==0.3.0.dev0" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 7911e16f316e7..41c114872f3aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -453,7 +453,7 @@ amberelectric==2.0.12 amcrest==1.9.8 # homeassistant.components.androidtv -androidtv[async]==0.0.73 +androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote androidtvremote2==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30f31ea6af4d2..549e5f920b4ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -429,7 +429,7 @@ airtouch5py==0.2.11 amberelectric==2.0.12 # homeassistant.components.androidtv -androidtv[async]==0.0.73 +androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote androidtvremote2==0.1.2 From 4093a68cc099ce73d4802733e6eb69b40df1199f Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:04:42 +0000 Subject: [PATCH 0923/1070] Bump tplink python-kasa dependency to 0.8.0 (#131249) --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 67ae1af90034c..3f19f50cdb684 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -300,5 +300,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], - "requirements": ["python-kasa[speedups]==0.7.7"] + "requirements": ["python-kasa[speedups]==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 41c114872f3aa..8f5518e07dcdf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2362,7 +2362,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.7 +python-kasa[speedups]==0.8.0 # homeassistant.components.linkplay python-linkplay==0.0.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 549e5f920b4ff..87f5f4e2d501e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1889,7 +1889,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.7 +python-kasa[speedups]==0.8.0 # homeassistant.components.linkplay python-linkplay==0.0.20 From ce20670d844373d49a64525ee59abfc8507ccd2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Nov 2024 14:04:39 -0800 Subject: [PATCH 0924/1070] Add a constraint for aiofiles to ensure it does not get downgraded (#131666) --- homeassistant/package_constraints.txt | 8 ++++++++ script/gen_requirements_all.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 19bfee3c80a51..b11c27489184f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -197,3 +197,11 @@ tenacity!=8.4.0 # 5.0.0 breaks Timeout as a context manager # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 + +# aiofiles keeps getting downgraded by custom components +# causing newer methods to not be available and breaking +# some integrations at startup +# https://github.com/home-assistant/core/issues/127529 +# https://github.com/home-assistant/core/issues/122508 +# https://github.com/home-assistant/core/issues/118004 +aiofiles>=24.1.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e9e4cf5bbb36f..97ffcac79a467 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -230,6 +230,14 @@ # 5.0.0 breaks Timeout as a context manager # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 + +# aiofiles keeps getting downgraded by custom components +# causing newer methods to not be available and breaking +# some integrations at startup +# https://github.com/home-assistant/core/issues/127529 +# https://github.com/home-assistant/core/issues/122508 +# https://github.com/home-assistant/core/issues/118004 +aiofiles>=24.1.0 """ GENERATED_MESSAGE = ( From 70c8c57401bb8439618eeb00d2393cadd1ccd73a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 27 Nov 2024 00:09:04 +0100 Subject: [PATCH 0925/1070] Dump ffmpeg stderr to ESPhome debug log (#130808) * dump the stderr from ffmpeg to debug log * add pid to indentify the ffmpeg process * be more explosive :) * move stderr task into _write_ffmpeg_data --- .../components/esphome/ffmpeg_proxy.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/esphome/ffmpeg_proxy.py b/homeassistant/components/esphome/ffmpeg_proxy.py index 2dacae52f75a0..9484d1e7593b7 100644 --- a/homeassistant/components/esphome/ffmpeg_proxy.py +++ b/homeassistant/components/esphome/ffmpeg_proxy.py @@ -212,6 +212,10 @@ async def _write_ffmpeg_data( assert proc.stdout is not None assert proc.stderr is not None + stderr_task = self.hass.async_create_background_task( + self._dump_ffmpeg_stderr(proc), "ESPHome media proxy dump stderr" + ) + try: # Pull audio chunks from ffmpeg and pass them to the HTTP client while ( @@ -230,18 +234,14 @@ async def _write_ffmpeg_data( raise # don't log error except: _LOGGER.exception("Unexpected error during ffmpeg conversion") - - # Process did not exit successfully - stderr_text = "" - while line := await proc.stderr.readline(): - stderr_text += line.decode() - _LOGGER.error("FFmpeg output: %s", stderr_text) - raise finally: # Allow conversion info to be removed self.convert_info.is_finished = True + # stop dumping ffmpeg stderr task + stderr_task.cancel() + # Terminate hangs, so kill is used if proc.returncode is None: proc.kill() @@ -250,6 +250,16 @@ async def _write_ffmpeg_data( if request.transport and not request.transport.is_closing(): await writer.write_eof() + async def _dump_ffmpeg_stderr( + self, + proc: asyncio.subprocess.Process, + ) -> None: + assert proc.stdout is not None + assert proc.stderr is not None + + while self.hass.is_running and (chunk := await proc.stderr.readline()): + _LOGGER.debug("ffmpeg[%s] output: %s", proc.pid, chunk.decode().rstrip()) + class FFmpegProxyView(HomeAssistantView): """FFmpeg web view to convert audio and stream back to client.""" From dc62ef8bef3222017a2efbdec87f1b94b2041bb1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Nov 2024 16:03:24 -0800 Subject: [PATCH 0926/1070] Bump PySwitchbot to 0.54.0 (#131664) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 64a2ec7563322..5a328650acad5 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.53.2"] + "requirements": ["PySwitchbot==0.54.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8f5518e07dcdf..1cc1ac50051e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.53.2 +PySwitchbot==0.54.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87f5f4e2d501e..74e7bfd4f66db 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.53.2 +PySwitchbot==0.54.0 # homeassistant.components.syncthru PySyncThru==0.7.10 From f04c50c59e0475d022d1c636a32dde893fc9063d Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Wed, 27 Nov 2024 02:48:46 +0100 Subject: [PATCH 0927/1070] Fix Bang & Olufsen WebSocket debug log and test (#131671) * Fix test and debug message * Reorder dict order --- .../components/bang_olufsen/websocket.py | 18 ++++++++---------- .../components/bang_olufsen/test_websocket.py | 12 +++++------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index ff3ad849e927e..bc817226b61e3 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -204,13 +204,11 @@ async def on_software_update_state(self, notification: SoftwareUpdateState) -> N def on_all_notifications_raw(self, notification: BaseWebSocketResponse) -> None: """Receive all notifications.""" - - _LOGGER.debug("%s", notification) - self.hass.bus.async_fire( - BANG_OLUFSEN_WEBSOCKET_EVENT, - { - "device_id": self._device.id, - "serial_number": int(self._unique_id), - **notification, - }, - ) + debug_notification = { + "device_id": self._device.id, + "serial_number": int(self._unique_id), + **notification, + } + + _LOGGER.debug("%s", debug_notification) + self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, debug_notification) diff --git a/tests/components/bang_olufsen/test_websocket.py b/tests/components/bang_olufsen/test_websocket.py index b17859a4f4eb6..ecf5b2d011e00 100644 --- a/tests/components/bang_olufsen/test_websocket.py +++ b/tests/components/bang_olufsen/test_websocket.py @@ -135,7 +135,6 @@ async def test_on_all_notifications_raw( }, "eventType": "WebSocketEventVolume", } - raw_notification_full = raw_notification # Get device ID for the modified notification that is sent as an event and in the log assert mock_config_entry.unique_id @@ -144,12 +143,11 @@ async def test_on_all_notifications_raw( identifiers={(DOMAIN, mock_config_entry.unique_id)} ) ) - raw_notification_full.update( - { - "device_id": device.id, - "serial_number": mock_config_entry.unique_id, - } - ) + raw_notification_full = { + "device_id": device.id, + "serial_number": int(mock_config_entry.unique_id), + **raw_notification, + } caplog.set_level(logging.DEBUG) From 40a4ff1c8451e8e95f4e8db1a88b99b11b4106a5 Mon Sep 17 00:00:00 2001 From: Jozef Kruszynski <60214390+jozefKruszynski@users.noreply.github.com> Date: Wed, 27 Nov 2024 02:52:08 +0100 Subject: [PATCH 0928/1070] Adds media_browser functionality to the music assistant integration (#131577) * Add test fixtures for all library loading * Add media browser * Add tests for media_browser --- .../music_assistant/media_browser.py | 351 +++++++++++ .../music_assistant/media_player.py | 6 +- tests/components/music_assistant/common.py | 70 ++- .../fixtures/library_album_tracks.json | 364 ++++++++++++ .../fixtures/library_albums.json | 148 +++++ .../fixtures/library_artist_albums.json | 88 +++ .../fixtures/library_artists.json | 60 ++ .../fixtures/library_playlist_tracks.json | 262 +++++++++ .../fixtures/library_playlists.json | 63 ++ .../fixtures/library_radios.json | 66 +++ .../fixtures/library_tracks.json | 556 ++++++++++++++++++ .../music_assistant/test_media_browser.py | 65 ++ 12 files changed, 2096 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/music_assistant/media_browser.py create mode 100644 tests/components/music_assistant/fixtures/library_album_tracks.json create mode 100644 tests/components/music_assistant/fixtures/library_albums.json create mode 100644 tests/components/music_assistant/fixtures/library_artist_albums.json create mode 100644 tests/components/music_assistant/fixtures/library_artists.json create mode 100644 tests/components/music_assistant/fixtures/library_playlist_tracks.json create mode 100644 tests/components/music_assistant/fixtures/library_playlists.json create mode 100644 tests/components/music_assistant/fixtures/library_radios.json create mode 100644 tests/components/music_assistant/fixtures/library_tracks.json create mode 100644 tests/components/music_assistant/test_media_browser.py diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py new file mode 100644 index 0000000000000..e65d6d4a975d1 --- /dev/null +++ b/homeassistant/components/music_assistant/media_browser.py @@ -0,0 +1,351 @@ +"""Media Source Implementation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from music_assistant_models.media_items import MediaItemType + +from homeassistant.components import media_source +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, + MediaType, +) +from homeassistant.core import HomeAssistant + +from .const import DEFAULT_NAME, DOMAIN + +if TYPE_CHECKING: + from music_assistant_client import MusicAssistantClient + +MEDIA_TYPE_RADIO = "radio" + +PLAYABLE_MEDIA_TYPES = [ + MediaType.PLAYLIST, + MediaType.ALBUM, + MediaType.ARTIST, + MEDIA_TYPE_RADIO, + MediaType.TRACK, +] + +LIBRARY_ARTISTS = "artists" +LIBRARY_ALBUMS = "albums" +LIBRARY_TRACKS = "tracks" +LIBRARY_PLAYLISTS = "playlists" +LIBRARY_RADIO = "radio" + + +LIBRARY_TITLE_MAP = { + LIBRARY_ARTISTS: "Artists", + LIBRARY_ALBUMS: "Albums", + LIBRARY_TRACKS: "Tracks", + LIBRARY_PLAYLISTS: "Playlists", + LIBRARY_RADIO: "Radio stations", +} + +LIBRARY_MEDIA_CLASS_MAP = { + LIBRARY_ARTISTS: MediaClass.ARTIST, + LIBRARY_ALBUMS: MediaClass.ALBUM, + LIBRARY_TRACKS: MediaClass.TRACK, + LIBRARY_PLAYLISTS: MediaClass.PLAYLIST, + LIBRARY_RADIO: MediaClass.MUSIC, # radio is not accepted by HA +} + +MEDIA_CONTENT_TYPE_FLAC = "audio/flac" +THUMB_SIZE = 200 + + +def media_source_filter(item: BrowseMedia) -> bool: + """Filter media sources.""" + return item.media_content_type.startswith("audio/") + + +async def async_browse_media( + hass: HomeAssistant, + mass: MusicAssistantClient, + media_content_id: str | None, + media_content_type: str | None, +) -> BrowseMedia: + """Browse media.""" + if media_content_id is None: + return await build_main_listing(hass) + + assert media_content_type is not None + + if media_source.is_media_source_id(media_content_id): + return await media_source.async_browse_media( + hass, media_content_id, content_filter=media_source_filter + ) + + if media_content_id == LIBRARY_ARTISTS: + return await build_artists_listing(mass) + if media_content_id == LIBRARY_ALBUMS: + return await build_albums_listing(mass) + if media_content_id == LIBRARY_TRACKS: + return await build_tracks_listing(mass) + if media_content_id == LIBRARY_PLAYLISTS: + return await build_playlists_listing(mass) + if media_content_id == LIBRARY_RADIO: + return await build_radio_listing(mass) + if "artist" in media_content_id: + return await build_artist_items_listing(mass, media_content_id) + if "album" in media_content_id: + return await build_album_items_listing(mass, media_content_id) + if "playlist" in media_content_id: + return await build_playlist_items_listing(mass, media_content_id) + + raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}") + + +async def build_main_listing(hass: HomeAssistant) -> BrowseMedia: + """Build main browse listing.""" + children: list[BrowseMedia] = [] + for library, media_class in LIBRARY_MEDIA_CLASS_MAP.items(): + child_source = BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=library, + media_content_type=DOMAIN, + title=LIBRARY_TITLE_MAP[library], + children_media_class=media_class, + can_play=False, + can_expand=True, + ) + children.append(child_source) + + try: + item = await media_source.async_browse_media( + hass, None, content_filter=media_source_filter + ) + # If domain is None, it's overview of available sources + if item.domain is None and item.children is not None: + children.extend(item.children) + else: + children.append(item) + except media_source.BrowseError: + pass + + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type=DOMAIN, + title=DEFAULT_NAME, + can_play=False, + can_expand=True, + children=children, + ) + + +async def build_playlists_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Playlists browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_PLAYLISTS] + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_PLAYLISTS, + media_content_type=MediaType.PLAYLIST, + title=LIBRARY_TITLE_MAP[LIBRARY_PLAYLISTS], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=sorted( + [ + build_item(mass, item, can_expand=True) + # we only grab the first page here because the + # HA media browser does not support paging + for item in await mass.music.get_library_playlists(limit=500) + if item.available + ], + key=lambda x: x.title, + ), + ) + + +async def build_playlist_items_listing( + mass: MusicAssistantClient, identifier: str +) -> BrowseMedia: + """Build Playlist items browse listing.""" + playlist = await mass.music.get_item_by_uri(identifier) + + return BrowseMedia( + media_class=MediaClass.PLAYLIST, + media_content_id=playlist.uri, + media_content_type=MediaType.PLAYLIST, + title=playlist.name, + can_play=True, + can_expand=True, + children_media_class=MediaClass.TRACK, + children=[ + build_item(mass, item, can_expand=False) + # we only grab the first page here because the + # HA media browser does not support paging + for item in await mass.music.get_playlist_tracks( + playlist.item_id, playlist.provider + ) + if item.available + ], + ) + + +async def build_artists_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Albums browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_ARTISTS] + + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_ARTISTS, + media_content_type=MediaType.ARTIST, + title=LIBRARY_TITLE_MAP[LIBRARY_ARTISTS], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=sorted( + [ + build_item(mass, artist, can_expand=True) + # we only grab the first page here because the + # HA media browser does not support paging + for artist in await mass.music.get_library_artists(limit=500) + if artist.available + ], + key=lambda x: x.title, + ), + ) + + +async def build_artist_items_listing( + mass: MusicAssistantClient, identifier: str +) -> BrowseMedia: + """Build Artist items browse listing.""" + artist = await mass.music.get_item_by_uri(identifier) + albums = await mass.music.get_artist_albums(artist.item_id, artist.provider) + + return BrowseMedia( + media_class=MediaType.ARTIST, + media_content_id=artist.uri, + media_content_type=MediaType.ARTIST, + title=artist.name, + can_play=True, + can_expand=True, + children_media_class=MediaClass.ALBUM, + children=[ + build_item(mass, album, can_expand=True) + for album in albums + if album.available + ], + ) + + +async def build_albums_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Albums browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_ALBUMS] + + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_ALBUMS, + media_content_type=MediaType.ALBUM, + title=LIBRARY_TITLE_MAP[LIBRARY_ALBUMS], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=sorted( + [ + build_item(mass, album, can_expand=True) + # we only grab the first page here because the + # HA media browser does not support paging + for album in await mass.music.get_library_albums(limit=500) + if album.available + ], + key=lambda x: x.title, + ), + ) + + +async def build_album_items_listing( + mass: MusicAssistantClient, identifier: str +) -> BrowseMedia: + """Build Album items browse listing.""" + album = await mass.music.get_item_by_uri(identifier) + tracks = await mass.music.get_album_tracks(album.item_id, album.provider) + + return BrowseMedia( + media_class=MediaType.ALBUM, + media_content_id=album.uri, + media_content_type=MediaType.ALBUM, + title=album.name, + can_play=True, + can_expand=True, + children_media_class=MediaClass.TRACK, + children=[ + build_item(mass, track, False) for track in tracks if track.available + ], + ) + + +async def build_tracks_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Tracks browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_TRACKS] + + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_TRACKS, + media_content_type=MediaType.TRACK, + title=LIBRARY_TITLE_MAP[LIBRARY_TRACKS], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=sorted( + [ + build_item(mass, track, can_expand=False) + # we only grab the first page here because the + # HA media browser does not support paging + for track in await mass.music.get_library_tracks(limit=500) + if track.available + ], + key=lambda x: x.title, + ), + ) + + +async def build_radio_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Radio browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_RADIO] + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_RADIO, + media_content_type=DOMAIN, + title=LIBRARY_TITLE_MAP[LIBRARY_RADIO], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=[ + build_item(mass, track, can_expand=False, media_class=media_class) + # we only grab the first page here because the + # HA media browser does not support paging + for track in await mass.music.get_library_radios(limit=500) + if track.available + ], + ) + + +def build_item( + mass: MusicAssistantClient, + item: MediaItemType, + can_expand: bool = True, + media_class: Any = None, +) -> BrowseMedia: + """Return BrowseMedia for MediaItem.""" + if artists := getattr(item, "artists", None): + title = f"{artists[0].name} - {item.name}" + else: + title = item.name + img_url = mass.get_media_item_image_url(item) + + return BrowseMedia( + media_class=media_class or item.media_type.value, + media_content_id=item.uri, + media_content_type=MediaType.MUSIC, + title=title, + can_play=True, + can_expand=can_expand, + thumbnail=img_url, + ) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index d898322c29304..8789bb36d33a8 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -43,6 +43,7 @@ from . import MusicAssistantConfigEntry from .const import ATTR_ACTIVE_QUEUE, ATTR_MASS_PLAYER_TYPE, DOMAIN from .entity import MusicAssistantEntity +from .media_browser import async_browse_media if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient @@ -440,10 +441,11 @@ async def async_browse_media( media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" - return await media_source.async_browse_media( + return await async_browse_media( self.hass, + self.mass, media_content_id, - content_filter=lambda item: item.media_content_type.startswith("audio/"), + media_content_type, ) def _update_media_image_url( diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 307a928f2cc6e..c8293b5622fb8 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -3,9 +3,10 @@ from __future__ import annotations from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from music_assistant_models.enums import EventType +from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track from music_assistant_models.player import Player from music_assistant_models.player_queue import PlayerQueue from syrupy import SnapshotAssertion @@ -42,6 +43,25 @@ async def setup_integration_from_fixtures( data={"url": MOCK_URL}, unique_id=music_assistant_client.server_info.server_id, ) + music = music_assistant_client.music + library_artists = create_library_artists_from_fixture() + music.get_library_artists = AsyncMock(return_value=library_artists) + library_artist_albums = create_library_artist_albums_from_fixture() + music.get_artist_albums = AsyncMock(return_value=library_artist_albums) + library_albums = create_library_albums_from_fixture() + music.get_library_albums = AsyncMock(return_value=library_albums) + library_album_tracks = create_library_album_tracks_from_fixture() + music.get_album_tracks = AsyncMock(return_value=library_album_tracks) + library_tracks = create_library_tracks_from_fixture() + music.get_library_tracks = AsyncMock(return_value=library_tracks) + library_playlists = create_library_playlists_from_fixture() + music.get_library_playlists = AsyncMock(return_value=library_playlists) + library_playlist_tracks = create_library_playlist_tracks_from_fixture() + music.get_playlist_tracks = AsyncMock(return_value=library_playlist_tracks) + library_radios = create_library_radios_from_fixture() + music.get_library_radios = AsyncMock(return_value=library_radios) + music.get_item_by_uri = AsyncMock() + config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -61,6 +81,54 @@ def create_player_queues_from_fixture() -> list[Player]: ] +def create_library_albums_from_fixture() -> list[Album]: + """Create MA Albums from fixture.""" + fixture_data = load_and_parse_fixture("library_albums") + return [Album.from_dict(album_data) for album_data in fixture_data] + + +def create_library_album_tracks_from_fixture() -> list[Track]: + """Create MA Tracks from fixture.""" + fixture_data = load_and_parse_fixture("library_album_tracks") + return [Track.from_dict(track_data) for track_data in fixture_data] + + +def create_library_tracks_from_fixture() -> list[Track]: + """Create MA Tracks from fixture.""" + fixture_data = load_and_parse_fixture("library_tracks") + return [Track.from_dict(track_data) for track_data in fixture_data] + + +def create_library_artists_from_fixture() -> list[Artist]: + """Create MA Artists from fixture.""" + fixture_data = load_and_parse_fixture("library_artists") + return [Artist.from_dict(artist_data) for artist_data in fixture_data] + + +def create_library_artist_albums_from_fixture() -> list[Album]: + """Create MA Albums from fixture.""" + fixture_data = load_and_parse_fixture("library_artist_albums") + return [Album.from_dict(album_data) for album_data in fixture_data] + + +def create_library_playlists_from_fixture() -> list[Playlist]: + """Create MA Playlists from fixture.""" + fixture_data = load_and_parse_fixture("library_playlists") + return [Playlist.from_dict(playlist_data) for playlist_data in fixture_data] + + +def create_library_playlist_tracks_from_fixture() -> list[Track]: + """Create MA Tracks from fixture.""" + fixture_data = load_and_parse_fixture("library_playlist_tracks") + return [Track.from_dict(track_data) for track_data in fixture_data] + + +def create_library_radios_from_fixture() -> list[Radio]: + """Create MA Radios from fixture.""" + fixture_data = load_and_parse_fixture("library_radios") + return [Radio.from_dict(radio_data) for radio_data in fixture_data] + + async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, diff --git a/tests/components/music_assistant/fixtures/library_album_tracks.json b/tests/components/music_assistant/fixtures/library_album_tracks.json new file mode 100644 index 0000000000000..562ee84fe3583 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_album_tracks.json @@ -0,0 +1,364 @@ +{ + "library_album_tracks": [ + { + "item_id": "247", + "provider": "library", + "name": "Le Mirage", + "version": "", + "sort_name": "mirage, le", + "uri": "library://track/247", + "external_ids": [["isrc", "FR10S1794640"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "70953631", + "provider_domain": "tidal", + "provider_instance": "tidal--63Pkq9Aw", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/70953631", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "Dana Murray", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 35, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 352, + "artists": [ + { + "item_id": 195, + "provider": "library", + "name": "Dana Jean Phoenix", + "version": "", + "sort_name": "dana jean phoenix", + "uri": "library://artist/195", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 95, + "provider": "library", + "name": "Synthwave (The 80S Revival)", + "version": "", + "sort_name": "synthwave (the 80s revival)", + "uri": "library://album/95", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 1 + }, + { + "item_id": "362", + "provider": "library", + "name": "Rabbit in the Headlights", + "version": "", + "sort_name": "rabbit in the headlights", + "uri": "library://track/362", + "external_ids": [["isrc", "GBLFP1645070"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "70953636", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/70953636", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "Michael Oakley", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 34, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 253, + "artists": [ + { + "item_id": 90, + "provider": "library", + "name": "Michael Oakley", + "version": "", + "sort_name": "michael oakley", + "uri": "library://artist/90", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 95, + "provider": "library", + "name": "Synthwave (The 80S Revival)", + "version": "", + "sort_name": "synthwave (the 80s revival)", + "uri": "library://album/95", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 6 + }, + { + "item_id": "1", + "provider": "library", + "name": "1988 Girls", + "version": "", + "sort_name": "1988 girls", + "uri": "library://track/1", + "external_ids": [["isrc", "DEBL60768604"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "70953637", + "provider_domain": "tidal", + "provider_instance": "tidal--56X5qDS7", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/70953637", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "Kiez Beats", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 14, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 258, + "artists": [ + { + "item_id": 110, + "provider": "library", + "name": "Futurecop!", + "version": "", + "sort_name": "futurecop!", + "uri": "library://artist/110", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 95, + "provider": "library", + "name": "Synthwave (The 80S Revival)", + "version": "", + "sort_name": "synthwave (the 80s revival)", + "uri": "library://album/95", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 7 + }, + { + "item_id": "495", + "provider": "library", + "name": "Timmy Goes to Space", + "version": "", + "sort_name": "timmy goes to space", + "uri": "library://track/495", + "external_ids": [["isrc", "NO2D81710001"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "70953643", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/70953643", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "Jens Kristian Espevik", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 4, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 212, + "artists": [ + { + "item_id": 453, + "provider": "library", + "name": "Mr. Maen", + "version": "", + "sort_name": "mr. maen", + "uri": "library://artist/453", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 95, + "provider": "library", + "name": "Synthwave (The 80S Revival)", + "version": "", + "sort_name": "synthwave (the 80s revival)", + "uri": "library://album/95", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 13 + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_albums.json b/tests/components/music_assistant/fixtures/library_albums.json new file mode 100644 index 0000000000000..6936a96adc8ef --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_albums.json @@ -0,0 +1,148 @@ +{ + "library_albums": [ + { + "item_id": "396", + "provider": "library", + "name": "Synth Punk EP", + "version": "", + "sort_name": "synth punk ep", + "uri": "library://album/396", + "external_ids": [["barcode", "872133626743"]], + "media_type": "album", + "provider_mappings": [ + { + "item_id": "48563817", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/album/48563817", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/99c8bc2f/ed43/4fb2/adfb/e7e3157089d2/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "586446 Records DK", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 7, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "year": 2015, + "artists": [ + { + "item_id": 289, + "provider": "library", + "name": "A Space Love Adventure", + "version": "", + "sort_name": "space love adventure, a", + "uri": "library://artist/289", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album_type": "ep" + }, + { + "item_id": "95", + "provider": "library", + "name": "Synthwave (The 80S Revival)", + "version": "The 80S Revival", + "sort_name": "synthwave (the 80s revival)", + "uri": "library://album/95", + "external_ids": [["barcode", "3614974086112"]], + "media_type": "album", + "provider_mappings": [ + { + "item_id": "70953630", + "provider_domain": "tidal", + "provider_instance": "tidal--56X5qDS7", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/album/70953630", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "Kiez Beats", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 43, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "year": 2017, + "artists": [ + { + "item_id": 96, + "provider": "library", + "name": "Various Artists", + "version": "", + "sort_name": "various artists", + "uri": "library://artist/96", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album_type": "compilation" + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_artist_albums.json b/tests/components/music_assistant/fixtures/library_artist_albums.json new file mode 100644 index 0000000000000..318855287345e --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_artist_albums.json @@ -0,0 +1,88 @@ +{ + "library_artist_albums": [ + { + "item_id": "115", + "provider": "library", + "name": "A Sea of Stars", + "version": "", + "sort_name": "sea of stars, a", + "uri": "library://album/115", + "external_ids": [["barcode", "859741010126"]], + "media_type": "album", + "provider_mappings": [ + { + "item_id": "157401232", + "provider_domain": "tidal", + "provider_instance": "tidal--56X5qDS7", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/album/157401232", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/f55c749b/6642/40e3/a291/ff01fd2915cf/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "2021 NRW Records, under exclusive license to NewRetroWave, LLC", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 0, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "year": 2021, + "artists": [ + { + "item_id": 127, + "provider": "library", + "name": "W O L F C L U B", + "version": "", + "sort_name": "w o l f c l u b", + "uri": "library://artist/127", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + }, + { + "item_id": 128, + "provider": "library", + "name": "Dora Pereli", + "version": "", + "sort_name": "dora pereli", + "uri": "library://artist/128", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album_type": "single" + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_artists.json b/tests/components/music_assistant/fixtures/library_artists.json new file mode 100644 index 0000000000000..803ce003b6ca8 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_artists.json @@ -0,0 +1,60 @@ +{ + "library_artists": [ + { + "item_id": "127", + "provider": "library", + "name": "W O L F C L U B", + "version": "", + "sort_name": "w o l f c l u b", + "uri": "library://artist/127", + "external_ids": [], + "media_type": "artist", + "provider_mappings": [ + { + "item_id": "8741977", + "provider_domain": "tidal", + "provider_instance": "tidal--56X5qDS7", + "available": 1, + "audio_format": { + "content_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": "https://tidal.com/artist/8741977", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/1e01cdb6/f15d/4d8b/8440/a047976c1cac/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": null + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_playlist_tracks.json b/tests/components/music_assistant/fixtures/library_playlist_tracks.json new file mode 100644 index 0000000000000..1fb1c3309577e --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_playlist_tracks.json @@ -0,0 +1,262 @@ +{ + "library_playlist_tracks": [ + { + "item_id": "77616130", + "provider": "tidal--Ah76MuMg", + "name": "Won't Get Fooled Again", + "version": "", + "sort_name": "won't get fooled again", + "uri": "tidal--Ah76MuMg://track/77616130", + "external_ids": [["isrc", "GBUM71405419"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "77616130", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": true, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 24, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/77616130", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/3496a8ad/ea69/4d7e/bbda/045417ab59e1/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 1971 Polydor Ltd. (UK)", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 30, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": 0, + "duration": 516, + "artists": [ + { + "item_id": "24915", + "provider": "tidal--Ah76MuMg", + "name": "The Who", + "version": "", + "sort_name": "who, the", + "uri": "tidal--Ah76MuMg://artist/24915", + "external_ids": [], + "media_type": "artist", + "provider_mappings": [ + { + "item_id": "24915", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": true, + "audio_format": { + "content_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": "https://tidal.com/artist/24915", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/0f782232/18c8/40b7/bb13/91c6039e40e6/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": null + } + ], + "album": { + "item_id": "77616121", + "provider": "tidal--Ah76MuMg", + "name": "Who's Next", + "version": "", + "sort_name": "who's next", + "uri": "tidal--Ah76MuMg://album/77616121", + "external_ids": [], + "media_type": "album", + "available": true, + "image": null + }, + "disc_number": 1, + "track_number": 9 + }, + { + "item_id": "153795", + "provider": "tidal--Ah76MuMg", + "name": "We're An American Band", + "version": "Remastered 2002", + "sort_name": "we're an american band", + "uri": "tidal--Ah76MuMg://track/153795", + "external_ids": [["isrc", "USCA20200334"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "153795", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": true, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/153795", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/a6d86e02/84c1/41f7/84f5/41be8571fc40/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 2002 Capitol Records, LLC", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 48, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": 1, + "duration": 207, + "artists": [ + { + "item_id": "9380", + "provider": "tidal--Ah76MuMg", + "name": "Grand Funk Railroad", + "version": "", + "sort_name": "grand funk railroad", + "uri": "tidal--Ah76MuMg://artist/9380", + "external_ids": [], + "media_type": "artist", + "provider_mappings": [ + { + "item_id": "9380", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": true, + "audio_format": { + "content_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": "https://tidal.com/artist/9380", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/6535bf95/a06d/4d23/8262/604fa41d8126/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": null + } + ], + "album": { + "item_id": "153794", + "provider": "tidal--Ah76MuMg", + "name": "We're An American Band (Expanded Edition / Remastered 2002)", + "version": "", + "sort_name": "we're an american band (expanded edition / remastered 2002)", + "uri": "tidal--Ah76MuMg://album/153794", + "external_ids": [], + "media_type": "album", + "available": true, + "image": null + }, + "disc_number": 1, + "track_number": 1 + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_playlists.json b/tests/components/music_assistant/fixtures/library_playlists.json new file mode 100644 index 0000000000000..7f88c5f3e243c --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_playlists.json @@ -0,0 +1,63 @@ +{ + "library_playlists": [ + { + "item_id": "40", + "provider": "library", + "name": "1970s Rock Hits", + "version": "", + "sort_name": "1970s rock hits", + "uri": "library://playlist/40", + "external_ids": [], + "media_type": "playlist", + "provider_mappings": [ + { + "item_id": "30da0578-0ca0-4716-b66e-5f02bcd96702", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": "https://tidal.com/browse/playlist/30da0578-0ca0-4716-b66e-5f02bcd96702", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/95913801/41c1/4cc9/bf94/a0fba657bba5/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "owner": "TIDAL", + "is_editable": 0, + "cache_checksum": "2023-10-09 07: 09: 23.446000+00: 00" + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_radios.json b/tests/components/music_assistant/fixtures/library_radios.json new file mode 100644 index 0000000000000..1a6a4666ce4ba --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_radios.json @@ -0,0 +1,66 @@ +{ + "library_radios": [ + { + "item_id": "1", + "provider": "library", + "name": "fm4 | ORF | HQ", + "version": "", + "sort_name": "fm4 | orf | hq", + "uri": "library://radio/1", + "external_ids": [], + "media_type": "radio", + "provider_mappings": [ + { + "item_id": "1e13ed4e-daa9-4728-8550-e08d89c1c8e7", + "provider_domain": "radiobrowser", + "provider_instance": "radiobrowser--FRc3pD3t", + "available": 1, + "audio_format": { + "content_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "https://tubestatic.orf.at/mojo/1_3/storyserver//tube/fm4/images/touch-icon-iphone-retina.png", + "provider": "radiobrowser", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": [ + { + "type": "website", + "url": "https://fm4.orf.at/" + } + ], + "performers": null, + "preview": null, + "popularity": 166, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 172800 + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_tracks.json b/tests/components/music_assistant/fixtures/library_tracks.json new file mode 100644 index 0000000000000..c4ed83e9342b5 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_tracks.json @@ -0,0 +1,556 @@ +{ + "library_tracks": [ + { + "item_id": "456", + "provider": "library", + "name": "Tennessee Whiskey", + "version": "", + "sort_name": "tennessee whiskey", + "uri": "library://track/456", + "external_ids": [["isrc", "USUM71418088"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "44832786", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/44832786", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/4894ff62/9de2/4ed8/a7b9/69e217bbbdda/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 2015 Mercury Records, a Division of UMG Recordings, Inc.", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 33, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 293, + "artists": [ + { + "item_id": 433, + "provider": "library", + "name": "Chris Stapleton", + "version": "", + "sort_name": "chris stapleton", + "uri": "library://artist/433", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 463, + "provider": "library", + "name": "Traveller", + "version": "", + "sort_name": "traveller", + "uri": "library://album/463", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/4894ff62/9de2/4ed8/a7b9/69e217bbbdda/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 3 + }, + { + "item_id": "467", + "provider": "library", + "name": "Thelma + Louise", + "version": "", + "sort_name": "thelma + louise", + "uri": "library://track/467", + "external_ids": [["isrc", "GBUM72104380"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "194027388", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 24, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/194027388", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/04fc7c3c/b814/4855/874c/a2e456205b65/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 2021 Virgin Records Limited", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 20, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 137, + "artists": [ + { + "item_id": 81, + "provider": "library", + "name": "Bastille", + "version": "", + "sort_name": "bastille", + "uri": "library://artist/81", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 471, + "provider": "library", + "name": "Thelma + Louise", + "version": "", + "sort_name": "thelma + louise", + "uri": "library://album/471", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/04fc7c3c/b814/4855/874c/a2e456205b65/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 1 + }, + { + "item_id": "485", + "provider": "library", + "name": "They Don't Care About Us", + "version": "", + "sort_name": "they don't care about us", + "uri": "library://track/485", + "external_ids": [["isrc", "USSM19500629"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "5279069", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 24, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/5279069", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/a2fa5815/851d/4d2d/b6a7/17a365c838f9/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "(P) 1995 MJJ Productions Inc.", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 27, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 284, + "artists": [ + { + "item_id": 30, + "provider": "library", + "name": "Michael Jackson", + "version": "", + "sort_name": "michael jackson", + "uri": "library://artist/30", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 486, + "provider": "library", + "name": "HIStory - PAST, PRESENT AND FUTURE - BOOK I", + "version": "", + "sort_name": "history - past, present and future - book i", + "uri": "library://album/486", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/a2fa5815/851d/4d2d/b6a7/17a365c838f9/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 2, + "track_number": 2 + }, + { + "item_id": "486", + "provider": "library", + "name": "They Don't Give A F**** About Us", + "version": "", + "sort_name": "they don't give a f**** about us", + "uri": "library://track/486", + "external_ids": [["isrc", "USIR10211795"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "44066854", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/44066854", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": true, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/6b7b2b58/5dc2/4d0c/8979/7b30bb779d6f/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 2002 Amaru Entertainment, Inc., Under exclusive license to Interscope Records", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 34, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 306, + "artists": [ + { + "item_id": 159, + "provider": "library", + "name": "2Pac", + "version": "", + "sort_name": "2pac", + "uri": "library://artist/159", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + }, + { + "item_id": 451, + "provider": "library", + "name": "The Outlawz", + "version": "", + "sort_name": "outlawz, the", + "uri": "library://artist/451", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 487, + "provider": "library", + "name": "Better Dayz", + "version": "", + "sort_name": "better dayz", + "uri": "library://album/487", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/6b7b2b58/5dc2/4d0c/8979/7b30bb779d6f/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 2, + "track_number": 13 + }, + { + "item_id": "487", + "provider": "library", + "name": "Things We Lost In The Fire", + "version": "TORN Remix", + "sort_name": "things we lost in the fire", + "uri": "library://track/487", + "external_ids": [["isrc", "GBUM71304903"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "22627902", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/22627902", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/de277fd3/cc29/4d63/a60f/13b501c5f3d0/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 2013 Virgin Records Limited", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 10, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 323, + "artists": [ + { + "item_id": 81, + "provider": "library", + "name": "Bastille", + "version": "", + "sort_name": "bastille", + "uri": "library://artist/81", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 488, + "provider": "library", + "name": "Things We Lost In The Fire", + "version": "", + "sort_name": "things we lost in the fire", + "uri": "library://album/488", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/de277fd3/cc29/4d63/a60f/13b501c5f3d0/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 3 + }, + { + "item_id": "488", + "provider": "library", + "name": "Those Nights", + "version": "", + "sort_name": "those nights", + "uri": "library://track/488", + "external_ids": [["isrc", "GBUM71803866"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "110750762", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 24, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/110750762", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/713805f3/c08c/4c0f/8199/d63e6badac0d/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 2019 Virgin Records Limited", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 21, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 270, + "artists": [ + { + "item_id": 81, + "provider": "library", + "name": "Bastille", + "version": "", + "sort_name": "bastille", + "uri": "library://artist/81", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 489, + "provider": "library", + "name": "Doom Days", + "version": "", + "sort_name": "doom days", + "uri": "library://album/489", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/713805f3/c08c/4c0f/8199/d63e6badac0d/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 10 + } + ] +} diff --git a/tests/components/music_assistant/test_media_browser.py b/tests/components/music_assistant/test_media_browser.py new file mode 100644 index 0000000000000..96fd54962d8d8 --- /dev/null +++ b/tests/components/music_assistant/test_media_browser.py @@ -0,0 +1,65 @@ +"""Test Music Assistant media browser implementation.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.media_player import BrowseError, BrowseMedia, MediaType +from homeassistant.components.music_assistant.const import DOMAIN +from homeassistant.components.music_assistant.media_browser import ( + LIBRARY_ALBUMS, + LIBRARY_ARTISTS, + LIBRARY_PLAYLISTS, + LIBRARY_RADIO, + LIBRARY_TRACKS, + async_browse_media, +) +from homeassistant.core import HomeAssistant + +from .common import setup_integration_from_fixtures + + +@pytest.mark.parametrize( + ("media_content_id", "media_content_type", "expected"), + [ + (LIBRARY_PLAYLISTS, MediaType.PLAYLIST, "library://playlist/40"), + (LIBRARY_ARTISTS, MediaType.ARTIST, "library://artist/127"), + (LIBRARY_ALBUMS, MediaType.ALBUM, "library://album/396"), + (LIBRARY_TRACKS, MediaType.TRACK, "library://track/486"), + (LIBRARY_RADIO, DOMAIN, "library://radio/1"), + ("artist", MediaType.ARTIST, "library://album/115"), + ("album", MediaType.ALBUM, "library://track/247"), + ("playlist", DOMAIN, "tidal--Ah76MuMg://track/77616130"), + (None, None, "artists"), + ], +) +async def test_browse_media_root( + hass: HomeAssistant, + music_assistant_client: MagicMock, + media_content_id: str, + media_content_type: str, + expected: str, +) -> None: + """Test the async_browse_media method.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + state = hass.states.get(entity_id) + assert state + browse_item: BrowseMedia = await async_browse_media( + hass, music_assistant_client, media_content_id, media_content_type + ) + assert browse_item.children[0].media_content_id == expected + + +async def test_browse_media_not_found( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test the async_browse_media method when media is not found.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + state = hass.states.get(entity_id) + assert state + + with pytest.raises(BrowseError, match="Media not found: unknown / unknown"): + await async_browse_media(hass, music_assistant_client, "unknown", "unknown") From a97eeaf189057b606c73189fee18a3edb372c4cd Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Wed, 27 Nov 2024 02:56:36 +0100 Subject: [PATCH 0929/1070] Add Bang & Olufsen diagnostics (#131538) * Add diagnostics * Add tests for diagnostics * Add media_player diagnostics * Use media_player entity's state instead of registryentry * Update tests * Reorganize code Remove context from media_player state * Fix dict being read only Simplify naming Update test snapshot * Update test snapshot --- .../components/bang_olufsen/diagnostics.py | 40 +++++++++++ .../snapshots/test_diagnostics.ambr | 67 +++++++++++++++++++ .../bang_olufsen/test_diagnostics.py | 41 ++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 homeassistant/components/bang_olufsen/diagnostics.py create mode 100644 tests/components/bang_olufsen/snapshots/test_diagnostics.ambr create mode 100644 tests/components/bang_olufsen/test_diagnostics.py diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py new file mode 100644 index 0000000000000..cab7eae5e258b --- /dev/null +++ b/homeassistant/components/bang_olufsen/diagnostics.py @@ -0,0 +1,40 @@ +"""Support for Bang & Olufsen diagnostics.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from . import BangOlufsenConfigEntry +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: BangOlufsenConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + data: dict = { + "config_entry": config_entry.as_dict(), + "websocket_connected": config_entry.runtime_data.client.websocket_connected, + } + + if TYPE_CHECKING: + assert config_entry.unique_id + + # Add media_player entity's state + entity_registry = er.async_get(hass) + if entity_id := entity_registry.async_get_entity_id( + MEDIA_PLAYER_DOMAIN, DOMAIN, config_entry.unique_id + ): + if state := hass.states.get(entity_id): + state_dict = dict(state.as_dict()) + + # Remove context as it is not relevant + state_dict.pop("context") + data["media_player"] = state_dict + + return data diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..e9540b5cec6ec --- /dev/null +++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_async_get_config_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '192.168.0.1', + 'jid': '1111.1111111.11111111@products.bang-olufsen.com', + 'model': 'Beosound Balance', + 'name': 'Beosound Balance-11111111', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'bang_olufsen', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Beosound Balance-11111111', + 'unique_id': '11111111', + 'version': 1, + }), + 'media_player': dict({ + 'attributes': dict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'media_content_type': 'music', + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': 2095933, + }), + 'entity_id': 'media_player.beosound_balance_11111111', + 'state': 'playing', + }), + 'websocket_connected': False, + }) +# --- diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py new file mode 100644 index 0000000000000..7c99648ace460 --- /dev/null +++ b/tests/components/bang_olufsen/test_diagnostics.py @@ -0,0 +1,41 @@ +"""Test bang_olufsen config entry diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_async_get_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_mozart_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot( + exclude=props( + "created_at", + "entry_id", + "id", + "last_changed", + "last_reported", + "last_updated", + "media_position_updated_at", + "modified_at", + ) + ) From 46fe3dcbf1c3e7c7431e170afbcde8eff6eb1011 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 26 Nov 2024 21:59:49 -0600 Subject: [PATCH 0930/1070] Add wake word select for ESPHome Assist satellite (#131309) * Add wake word select * Fix linting * Move to ESPHome * Clean up and add more tests * Update homeassistant/components/esphome/select.py --------- Co-authored-by: Paulus Schoutsen --- .../components/esphome/assist_satellite.py | 27 ++- .../components/esphome/entry_data.py | 39 ++++ homeassistant/components/esphome/select.py | 77 ++++++- homeassistant/components/esphome/strings.json | 6 + .../components/assist_pipeline/test_select.py | 4 +- .../esphome/test_assist_satellite.py | 197 +++++++++++++++++- tests/components/esphome/test_select.py | 12 +- 7 files changed, 352 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index dc513a03e0233..f60668b0a06c1 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -95,11 +95,7 @@ async def async_setup_entry( if entry_data.device_info.voice_assistant_feature_flags_compat( entry_data.api_version ): - async_add_entities( - [ - EsphomeAssistSatellite(entry, entry_data), - ] - ) + async_add_entities([EsphomeAssistSatellite(entry, entry_data)]) class EsphomeAssistSatellite( @@ -198,6 +194,9 @@ async def _update_satellite_config(self) -> None: self._satellite_config.max_active_wake_words = config.max_active_wake_words _LOGGER.debug("Received satellite configuration: %s", self._satellite_config) + # Inform listeners that config has been updated + self.entry_data.async_assist_satellite_config_updated(self._satellite_config) + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -254,6 +253,13 @@ async def async_added_to_hass(self) -> None: # Will use media player for TTS/announcements self._update_tts_format() + # Update wake word select when config is updated + self.async_on_remove( + self.entry_data.async_register_assist_satellite_set_wake_word_callback( + self.async_set_wake_word + ) + ) + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() @@ -478,6 +484,17 @@ async def handle_announcement_finished( """Handle announcement finished message (also sent for TTS).""" self.tts_response_finished() + @callback + def async_set_wake_word(self, wake_word_id: str) -> None: + """Set active wake word and update config on satellite.""" + self._satellite_config.active_wake_words = [wake_word_id] + self.config_entry.async_create_background_task( + self.hass, + self.async_set_configuration(self._satellite_config), + "esphome_voice_assistant_set_config", + ) + _LOGGER.debug("Setting active wake word: %s", wake_word_id) + def _update_tts_format(self) -> None: """Update the TTS format from the first media player.""" for supported_format in chain(*self.entry_data.media_player_formats.values()): diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index f1b5218eec708..fc41ee99a003a 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -48,6 +48,7 @@ from aioesphomeapi.model import ButtonInfo from bleak_esphome.backend.device import ESPHomeBluetoothDevice +from homeassistant.components.assist_satellite import AssistSatelliteConfiguration from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -152,6 +153,12 @@ class RuntimeEntryData: media_player_formats: dict[str, list[MediaPlayerSupportedFormat]] = field( default_factory=lambda: defaultdict(list) ) + assist_satellite_config_update_callbacks: list[ + Callable[[AssistSatelliteConfiguration], None] + ] = field(default_factory=list) + assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field( + default_factory=list + ) @property def name(self) -> str: @@ -504,3 +511,35 @@ def async_on_connect( # We use this to determine if a deep sleep device should # be marked as unavailable or not. self.expected_disconnect = True + + @callback + def async_register_assist_satellite_config_updated_callback( + self, + callback_: Callable[[AssistSatelliteConfiguration], None], + ) -> CALLBACK_TYPE: + """Register to receive callbacks when the Assist satellite's configuration is updated.""" + self.assist_satellite_config_update_callbacks.append(callback_) + return lambda: self.assist_satellite_config_update_callbacks.remove(callback_) + + @callback + def async_assist_satellite_config_updated( + self, config: AssistSatelliteConfiguration + ) -> None: + """Notify listeners that the Assist satellite configuration has been updated.""" + for callback_ in self.assist_satellite_config_update_callbacks.copy(): + callback_(config) + + @callback + def async_register_assist_satellite_set_wake_word_callback( + self, + callback_: Callable[[str], None], + ) -> CALLBACK_TYPE: + """Register to receive callbacks when the Assist satellite's wake word is set.""" + self.assist_satellite_set_wake_word_callbacks.append(callback_) + return lambda: self.assist_satellite_set_wake_word_callbacks.remove(callback_) + + @callback + def async_assist_satellite_set_wake_word(self, wake_word_id: str) -> None: + """Notify listeners that the Assist satellite wake word has been set.""" + for callback_ in self.assist_satellite_set_wake_word_callbacks.copy(): + callback_(wake_word_id) diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 623946503ebf0..ab7654478a7de 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -8,8 +8,10 @@ AssistPipelineSelect, VadSensitivitySelect, ) -from homeassistant.components.select import SelectEntity +from homeassistant.components.assist_satellite import AssistSatelliteConfiguration +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import restore_state from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -47,6 +49,7 @@ async def async_setup_entry( [ EsphomeAssistPipelineSelect(hass, entry_data), EsphomeVadSensitivitySelect(hass, entry_data), + EsphomeAssistSatelliteWakeWordSelect(hass, entry_data), ] ) @@ -89,3 +92,75 @@ def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None: """Initialize a VAD sensitivity selector.""" EsphomeAssistEntity.__init__(self, entry_data) VadSensitivitySelect.__init__(self, hass, self._device_info.mac_address) + + +class EsphomeAssistSatelliteWakeWordSelect( + EsphomeAssistEntity, SelectEntity, restore_state.RestoreEntity +): + """Wake word selector for esphome devices.""" + + entity_description = SelectEntityDescription( + key="wake_word", translation_key="wake_word" + ) + _attr_should_poll = False + _attr_current_option: str | None = None + _attr_options: list[str] = [] + + def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None: + """Initialize a wake word selector.""" + EsphomeAssistEntity.__init__(self, entry_data) + + unique_id_prefix = self._device_info.mac_address + self._attr_unique_id = f"{unique_id_prefix}-wake_word" + + # name -> id + self._wake_words: dict[str, str] = {} + + @property + def available(self) -> bool: + """Return if entity is available.""" + return bool(self._attr_options) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + + # Update options when config is updated + self.async_on_remove( + self._entry_data.async_register_assist_satellite_config_updated_callback( + self.async_satellite_config_updated + ) + ) + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + if wake_word_id := self._wake_words.get(option): + # _attr_current_option will be updated on + # async_satellite_config_updated after the device sets the wake + # word. + self._entry_data.async_assist_satellite_set_wake_word(wake_word_id) + + def async_satellite_config_updated( + self, config: AssistSatelliteConfiguration + ) -> None: + """Update options with available wake words.""" + if (not config.available_wake_words) or (config.max_active_wake_words < 1): + self._attr_current_option = None + self._wake_words.clear() + self.async_write_ha_state() + return + + self._wake_words = {w.wake_word: w.id for w in config.available_wake_words} + self._attr_options = sorted(self._wake_words) + + if config.active_wake_words: + # Select first active wake word + wake_word_id = config.active_wake_words[0] + for wake_word in config.available_wake_words: + if wake_word.id == wake_word_id: + self._attr_current_option = wake_word.wake_word + else: + # Select first available wake word + self._attr_current_option = config.available_wake_words[0].wake_word + + self.async_write_ha_state() diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 971a489a9e24a..81b58de8df294 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -84,6 +84,12 @@ "aggressive": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::aggressive%]", "relaxed": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::relaxed%]" } + }, + "wake_word": { + "name": "Wake word", + "state": { + "okay_nabu": "Okay Nabu" + } } }, "climate": { diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 9fb02e228d83f..5ce3b1020d023 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -184,7 +184,7 @@ async def test_select_entity_changing_vad_sensitivity( hass: HomeAssistant, init_select: MockConfigEntry, ) -> None: - """Test entity tracking pipeline changes.""" + """Test entity tracking vad sensitivity changes.""" config_entry = init_select # nicer naming config_entry.mock_state(hass, ConfigEntryState.LOADED) @@ -192,7 +192,7 @@ async def test_select_entity_changing_vad_sensitivity( assert state is not None assert state.state == VadSensitivity.DEFAULT.value - # Change select to new pipeline + # Change select to new sensitivity await hass.services.async_call( "select", "select_option", diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index e8344e50161c1..5ca333df1e2ec 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -5,7 +5,7 @@ from dataclasses import replace import io import socket -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch import wave from aioesphomeapi import ( @@ -42,6 +42,10 @@ VoiceAssistantUDPServer, ) from homeassistant.components.media_source import PlayMedia +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, intent as intent_helper @@ -1473,3 +1477,194 @@ async def test_get_set_configuration( # Device should have been updated assert satellite.async_get_configuration() == updated_config + + +async def test_wake_word_select( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test wake word select.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), + AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]), + AssistSatelliteWakeWord("hey_mycroft", "Hey Mycroft", ["en"]), + ], + active_wake_words=["hey_jarvis"], + max_active_wake_words=1, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + # Wrap mock so we can tell when it's done + configuration_set = asyncio.Event() + + async def wrapper(*args, **kwargs): + # Update device config because entity will request it after update + device_config.active_wake_words = kwargs["active_wake_words"] + configuration_set.set() + + mock_client.set_voice_assistant_configuration = AsyncMock(side_effect=wrapper) + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + assert satellite.async_get_configuration().active_wake_words == ["hey_jarvis"] + + # Active wake word should be selected + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == "Hey Jarvis" + + # Changing the select should set the active wake word + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {"entity_id": "select.test_wake_word", "option": "Okay Nabu"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == "Okay Nabu" + + # Wait for device config to be updated + async with asyncio.timeout(1): + await configuration_set.wait() + + # Satellite config should have been updated + assert satellite.async_get_configuration().active_wake_words == ["okay_nabu"] + + +async def test_wake_word_select_no_wake_words( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test wake word select is unavailable when there are no available wake word.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[], + active_wake_words=[], + max_active_wake_words=1, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + assert not satellite.async_get_configuration().available_wake_words + + # Select should be unavailable + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_wake_word_select_zero_max_wake_words( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test wake word select is unavailable max wake words is zero.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), + ], + active_wake_words=[], + max_active_wake_words=0, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + assert satellite.async_get_configuration().max_active_wake_words == 0 + + # Select should be unavailable + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_wake_word_select_no_active_wake_words( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test wake word select uses first available wake word if none are active.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), + AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]), + ], + active_wake_words=[], + max_active_wake_words=1, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + assert not satellite.async_get_configuration().active_wake_words + + # First available wake word should be selected + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == "Okay Nabu" diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index fbe30afd042f0..6ae1260a89dbd 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -9,7 +9,7 @@ DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -38,6 +38,16 @@ async def test_vad_sensitivity_select( assert state.state == "default" +async def test_wake_word_select( + hass: HomeAssistant, + mock_voice_assistant_v1_entry, +) -> None: + """Test that wake word select is unavailable initially.""" + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + async def test_select_generic_entity( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry ) -> None: From 7e03100af26ffc7764d8029f6e65199c0ce47a0c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 27 Nov 2024 00:51:21 -0500 Subject: [PATCH 0931/1070] Allow an LLM to see script response values (#131683) --- homeassistant/helpers/llm.py | 24 +++++--------- tests/helpers/test_llm.py | 64 +++++++++++++++++++++++++++--------- 2 files changed, 58 insertions(+), 30 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index d322810b0ef8e..49ae1455006ff 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -22,15 +22,13 @@ from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.intent import async_device_supports_timers -from homeassistant.components.script import ATTR_VARIABLES, DOMAIN as SCRIPT_DOMAIN +from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN from homeassistant.components.weather import INTENT_GET_WEATHER from homeassistant.const import ( ATTR_DOMAIN, - ATTR_ENTITY_ID, ATTR_SERVICE, EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, - SERVICE_TURN_ON, ) from homeassistant.core import Context, Event, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError @@ -416,9 +414,7 @@ def _async_get_tools( ): continue - script_tool = ScriptTool(self.hass, state.entity_id) - if script_tool.parameters.schema: - tools.append(script_tool) + tools.append(ScriptTool(self.hass, state.entity_id)) return tools @@ -702,10 +698,9 @@ def __init__( script_entity_id: str, ) -> None: """Init the class.""" - self.name = split_entity_id(script_entity_id)[1] + self._object_id = self.name = split_entity_id(script_entity_id)[1] if self.name[0].isdigit(): self.name = "_" + self.name - self._entity_id = script_entity_id self.description, self.parameters = _get_cached_script_parameters( hass, script_entity_id @@ -745,14 +740,13 @@ async def async_call( floor = list(intent.find_floors(floor, floor_reg))[0].floor_id tool_input.tool_args[field] = floor - await hass.services.async_call( + result = await hass.services.async_call( SCRIPT_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: self._entity_id, - ATTR_VARIABLES: tool_input.tool_args, - }, + self._object_id, + tool_input.tool_args, context=llm_context.context, + blocking=True, + return_response=True, ) - return {"success": True} + return {"success": True, "result": result} diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 7174d77886a13..4b2fc9e5fc1b9 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -656,7 +656,10 @@ async def test_script_tool( "script": { "test_script": { "description": "This is a test script", - "sequence": [], + "sequence": [ + {"variables": {"result": {"drinks": 2}}}, + {"stop": True, "response_variable": "result"}, + ], "fields": { "beer": {"description": "Number of beers", "required": True}, "wine": {"selector": {"number": {"min": 0, "max": 3}}}, @@ -692,7 +695,7 @@ async def test_script_tool( api = await llm.async_get_api(hass, "assist", llm_context) tools = [tool for tool in api.tools if isinstance(tool, llm.ScriptTool)] - assert len(tools) == 1 + assert len(tools) == 2 tool = tools[0] assert tool.name == "test_script" @@ -719,6 +722,7 @@ async def test_script_tool( "script_with_no_fields": ("This is another test script", vol.Schema({})), } + # Test script with response tool_input = llm.ToolInput( tool_name="test_script", tool_args={ @@ -731,26 +735,56 @@ async def test_script_tool( }, ) - with patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: + with patch( + "homeassistant.core.ServiceRegistry.async_call", + side_effect=hass.services.async_call, + ) as mock_service_call: response = await api.async_call_tool(tool_input) mock_service_call.assert_awaited_once_with( "script", - "turn_on", + "test_script", { - "entity_id": "script.test_script", - "variables": { - "beer": "3", - "wine": 0, - "where": area.id, - "area_list": [area.id], - "floor": floor.floor_id, - "floor_list": [floor.floor_id], - }, + "beer": "3", + "wine": 0, + "where": area.id, + "area_list": [area.id], + "floor": floor.floor_id, + "floor_list": [floor.floor_id], }, context=context, + blocking=True, + return_response=True, + ) + assert response == { + "success": True, + "result": {"drinks": 2}, + } + + # Test script with no response + tool_input = llm.ToolInput( + tool_name="script_with_no_fields", + tool_args={}, + ) + + with patch( + "homeassistant.core.ServiceRegistry.async_call", + side_effect=hass.services.async_call, + ) as mock_service_call: + response = await api.async_call_tool(tool_input) + + mock_service_call.assert_awaited_once_with( + "script", + "script_with_no_fields", + {}, + context=context, + blocking=True, + return_response=True, ) - assert response == {"success": True} + assert response == { + "success": True, + "result": {}, + } # Test reload script with new parameters config = { @@ -782,7 +816,7 @@ async def test_script_tool( api = await llm.async_get_api(hass, "assist", llm_context) tools = [tool for tool in api.tools if isinstance(tool, llm.ScriptTool)] - assert len(tools) == 1 + assert len(tools) == 2 tool = tools[0] assert tool.name == "test_script" From b8f81abbed7406c8beebfe6faff7ded10e1ef4cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Nov 2024 23:26:28 -0800 Subject: [PATCH 0932/1070] Bump zeroconf to 0.136.2 (#131681) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 98b09f1a2512d..9ad92bb4bc746 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.136.0"] + "requirements": ["zeroconf==0.136.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b11c27489184f..67590af6185e5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -71,7 +71,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.0 -zeroconf==0.136.0 +zeroconf==0.136.2 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 1cc1ac50051e7..ea025a255a56c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3075,7 +3075,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.136.0 +zeroconf==0.136.2 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74e7bfd4f66db..1695c32d9b2a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2458,7 +2458,7 @@ yt-dlp[default]==2024.11.04 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.136.0 +zeroconf==0.136.2 # homeassistant.components.zeversolar zeversolar==0.3.2 From 81d0bcde53d0f3394a7a26538f1b299567fcb283 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Nov 2024 08:26:50 +0100 Subject: [PATCH 0933/1070] Bump docker/build-push-action from 6.9.0 to 6.10.0 (#131685) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index cc100c48fd863..c2fee9512fbca 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -509,7 +509,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 + uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -522,7 +522,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 + uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile From 8bb0fab732d5b202ebc85f4f3188d7bdb5efc700 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Wed, 27 Nov 2024 08:34:15 +0100 Subject: [PATCH 0934/1070] Bump plugwise to v1.6.0 and adapt (#131659) --- homeassistant/components/plugwise/__init__.py | 2 +- homeassistant/components/plugwise/climate.py | 29 +- .../components/plugwise/coordinator.py | 4 +- .../components/plugwise/diagnostics.py | 2 +- homeassistant/components/plugwise/entity.py | 4 +- .../components/plugwise/manifest.json | 2 +- homeassistant/components/plugwise/number.py | 6 +- homeassistant/components/plugwise/select.py | 12 +- homeassistant/components/plugwise/sensor.py | 2 +- homeassistant/components/plugwise/switch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/plugwise/conftest.py | 22 +- .../fixtures/legacy_anna/all_data.json | 2 +- .../fixtures/m_adam_cooling/all_data.json | 110 +++--- .../fixtures/m_adam_heating/all_data.json | 110 +++--- .../fixtures/m_adam_jip/all_data.json | 142 +++++--- .../all_data.json | 244 +++++++++----- .../fixtures/stretch_v31/all_data.json | 6 +- .../plugwise/snapshots/test_diagnostics.ambr | 318 +++++++++++------- tests/components/plugwise/test_climate.py | 32 +- tests/components/plugwise/test_init.py | 28 +- tests/components/plugwise/test_select.py | 4 +- 23 files changed, 697 insertions(+), 390 deletions(-) diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index 7d1b9ceac8a31..a100103b029be 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -83,7 +83,7 @@ def migrate_sensor_entities( # Migrating opentherm_outdoor_temperature # to opentherm_outdoor_air_temperature sensor for device_id, device in coordinator.data.devices.items(): - if device.get("dev_class") != "heater_central": + if device["dev_class"] != "heater_central": continue old_unique_id = f"{device_id}-outdoor_temperature" diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 46b4bff250aac..f1f54aa6647c6 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -39,11 +39,19 @@ def _add_entities() -> None: if not coordinator.new_devices: return - async_add_entities( - PlugwiseClimateEntity(coordinator, device_id) - for device_id in coordinator.new_devices - if coordinator.data.devices[device_id]["dev_class"] in MASTER_THERMOSTATS - ) + if coordinator.data.gateway["smile_name"] == "Adam": + async_add_entities( + PlugwiseClimateEntity(coordinator, device_id) + for device_id in coordinator.new_devices + if coordinator.data.devices[device_id]["dev_class"] == "climate" + ) + else: + async_add_entities( + PlugwiseClimateEntity(coordinator, device_id) + for device_id in coordinator.new_devices + if coordinator.data.devices[device_id]["dev_class"] + in MASTER_THERMOSTATS + ) _add_entities() entry.async_on_unload(coordinator.async_add_listener(_add_entities)) @@ -69,6 +77,11 @@ def __init__( super().__init__(coordinator, device_id) self._attr_extra_state_attributes = {} self._attr_unique_id = f"{device_id}-climate" + + self._location = device_id + if (location := self.device.get("location")) is not None: + self._location = location + self.cdr_gateway = coordinator.data.gateway gateway_id: str = coordinator.data.gateway["gateway_id"] self.gateway_data = coordinator.data.devices[gateway_id] @@ -222,7 +235,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: if mode := kwargs.get(ATTR_HVAC_MODE): await self.async_set_hvac_mode(mode) - await self.coordinator.api.set_temperature(self.device["location"], data) + await self.coordinator.api.set_temperature(self._location, data) @plugwise_command async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -237,7 +250,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: await self.coordinator.api.set_regulation_mode(hvac_mode) else: await self.coordinator.api.set_schedule_state( - self.device["location"], + self._location, "on" if hvac_mode == HVACMode.AUTO else "off", ) if self.hvac_mode == HVACMode.OFF: @@ -246,4 +259,4 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @plugwise_command async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" - await self.coordinator.api.set_preset(self.device["location"], preset_mode) + await self.coordinator.api.set_preset(self._location, preset_mode) diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index b897a8bf8336e..6ce6855e7d603 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -64,11 +64,11 @@ async def _connect(self) -> None: version = await self.api.connect() self._connected = isinstance(version, Version) if self._connected: - self.api.get_all_devices() + self.api.get_all_gateway_entities() async def _async_update_data(self) -> PlugwiseData: """Fetch data from Plugwise.""" - data = PlugwiseData({}, {}) + data = PlugwiseData(devices={}, gateway={}) try: if not self._connected: await self._connect() diff --git a/homeassistant/components/plugwise/diagnostics.py b/homeassistant/components/plugwise/diagnostics.py index 9d15ea4fe2870..47ff7d1a9fbb0 100644 --- a/homeassistant/components/plugwise/diagnostics.py +++ b/homeassistant/components/plugwise/diagnostics.py @@ -15,6 +15,6 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator = entry.runtime_data return { - "gateway": coordinator.data.gateway, "devices": coordinator.data.devices, + "gateway": coordinator.data.gateway, } diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index e24f3d1e1bb2f..7b28bf78342e0 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from plugwise.constants import DeviceData +from plugwise.constants import GwEntityData from homeassistant.const import ATTR_NAME, ATTR_VIA_DEVICE, CONF_HOST from homeassistant.helpers.device_registry import ( @@ -74,7 +74,7 @@ def available(self) -> bool: ) @property - def device(self) -> DeviceData: + def device(self) -> GwEntityData: """Return data for this device.""" return self.coordinator.data.devices[self._dev_id] diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 9f11433d8d31a..d4d80749a8d1d 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], - "requirements": ["plugwise==1.5.2"], + "requirements": ["plugwise==1.6.0"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 06db5faa55b27..833ea3ec76123 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -91,12 +91,12 @@ def __init__( ) -> None: """Initiate Plugwise Number.""" super().__init__(coordinator, device_id) - self.device_id = device_id - self.entity_description = description - self._attr_unique_id = f"{device_id}-{description.key}" self._attr_mode = NumberMode.BOX self._attr_native_max_value = self.device[description.key]["upper_bound"] self._attr_native_min_value = self.device[description.key]["lower_bound"] + self._attr_unique_id = f"{device_id}-{description.key}" + self.device_id = device_id + self.entity_description = description native_step = self.device[description.key]["resolution"] if description.key != "temperature_offset": diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index b7d4a0a1ded60..46b27ca622544 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PlugwiseConfigEntry -from .const import LOCATION, SelectOptionsType, SelectType +from .const import SelectOptionsType, SelectType from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command @@ -89,8 +89,12 @@ def __init__( ) -> None: """Initialise the selector.""" super().__init__(coordinator, device_id) - self.entity_description = entity_description self._attr_unique_id = f"{device_id}-{entity_description.key}" + self.entity_description = entity_description + + self._location = device_id + if (location := self.device.get("location")) is not None: + self._location = location @property def current_option(self) -> str: @@ -106,8 +110,8 @@ def options(self) -> list[str]: async def async_select_option(self, option: str) -> None: """Change to the selected entity option. - self.device[LOCATION] and STATE_ON are required for the thermostat-schedule select. + self._location and STATE_ON are required for the thermostat-schedule select. """ await self.coordinator.api.set_select( - self.entity_description.key, self.device[LOCATION], option, STATE_ON + self.entity_description.key, self._location, option, STATE_ON ) diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index ae5b4e6ed9187..41ca439451a1c 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -439,8 +439,8 @@ def __init__( ) -> None: """Initialise the sensor.""" super().__init__(coordinator, device_id) - self.entity_description = description self._attr_unique_id = f"{device_id}-{description.key}" + self.entity_description = description @property def native_value(self) -> int | float: diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index a134ab5b04416..744fc0a2b729e 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -93,8 +93,8 @@ def __init__( ) -> None: """Set up the Plugwise API.""" super().__init__(coordinator, device_id) - self.entity_description = description self._attr_unique_id = f"{device_id}-{description.key}" + self.entity_description = description @property def is_on(self) -> bool: diff --git a/requirements_all.txt b/requirements_all.txt index ea025a255a56c..100e77bff6928 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1622,7 +1622,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.5.2 +plugwise==1.6.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1695c32d9b2a7..506c5d469df84 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1329,7 +1329,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.5.2 +plugwise==1.6.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index f18c96d36c5f8..dead58e0581c0 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -93,7 +93,7 @@ def mock_smile_adam() -> Generator[MagicMock]: smile.connect.return_value = Version("3.0.15") all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile @@ -120,7 +120,7 @@ def mock_smile_adam_2() -> Generator[MagicMock]: smile.connect.return_value = Version("3.6.4") all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile @@ -147,7 +147,7 @@ def mock_smile_adam_3() -> Generator[MagicMock]: smile.connect.return_value = Version("3.6.4") all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile @@ -174,7 +174,7 @@ def mock_smile_adam_4() -> Generator[MagicMock]: smile.connect.return_value = Version("3.2.8") all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile @@ -200,7 +200,7 @@ def mock_smile_anna() -> Generator[MagicMock]: smile.connect.return_value = Version("4.0.15") all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile @@ -226,7 +226,7 @@ def mock_smile_anna_2() -> Generator[MagicMock]: smile.connect.return_value = Version("4.0.15") all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile @@ -252,7 +252,7 @@ def mock_smile_anna_3() -> Generator[MagicMock]: smile.connect.return_value = Version("4.0.15") all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile @@ -278,7 +278,7 @@ def mock_smile_p1() -> Generator[MagicMock]: smile.connect.return_value = Version("4.4.2") all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile @@ -304,7 +304,7 @@ def mock_smile_p1_2() -> Generator[MagicMock]: smile.connect.return_value = Version("4.4.2") all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile @@ -330,7 +330,7 @@ def mock_smile_legacy_anna() -> Generator[MagicMock]: smile.connect.return_value = Version("1.8.22") all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile @@ -356,7 +356,7 @@ def mock_stretch() -> Generator[MagicMock]: smile.connect.return_value = Version("3.1.11") all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile diff --git a/tests/components/plugwise/fixtures/legacy_anna/all_data.json b/tests/components/plugwise/fixtures/legacy_anna/all_data.json index c5ee4b2b103e2..2cb439950af06 100644 --- a/tests/components/plugwise/fixtures/legacy_anna/all_data.json +++ b/tests/components/plugwise/fixtures/legacy_anna/all_data.json @@ -45,7 +45,7 @@ "name": "Anna", "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], "sensors": { - "illuminance": 151, + "illuminance": 150.8, "setpoint": 20.5, "temperature": 20.4 }, diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 6edd4c5901d70..9c40e50278b03 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -31,7 +31,7 @@ "binary_sensors": { "low_battery": false }, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "f871b8c4d63549319221e294e4f88074", @@ -40,6 +40,7 @@ "name": "Tom Badkamer", "sensors": { "battery": 99, + "setpoint": 18.0, "temperature": 21.6, "temperature_difference": -0.2, "valve_position": 100 @@ -54,34 +55,16 @@ "zigbee_mac_address": "000D6F000C8FF5EE" }, "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "active_preset": "home", "available": true, - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], - "climate_mode": "cool", - "control_state": "cooling", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "model": "ThermoTouch", "model_id": "143.1", "name": "Anna", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "off", "sensors": { "setpoint": 23.5, "temperature": 25.8 }, - "thermostat": { - "lower_bound": 1.0, - "resolution": 0.01, - "setpoint": 23.5, - "upper_bound": 35.0 - }, "vendor": "Plugwise" }, "da224107914542988a88561b4452b0f6": { @@ -113,20 +96,10 @@ "zigbee_mac_address": "000D6F000D5A168D" }, "e2f4322d57924fa090fbbc48b3a140dc": { - "active_preset": "home", "available": true, - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], "binary_sensors": { "low_battery": true }, - "climate_mode": "auto", - "control_state": "preheating", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -134,8 +107,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Lisa Badkamer", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "Badkamer", "sensors": { "battery": 14, "setpoint": 23.5, @@ -147,12 +118,6 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 25.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "000D6F000C869B61" }, @@ -166,14 +131,81 @@ "name": "Test", "switches": { "relay": true - } + }, + "vendor": "Plugwise" + }, + "f2bf9048bef64cc5b6d5110154e33c81": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "cool", + "control_state": "cooling", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Living room", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 149.9, + "electricity_produced": 0.0, + "temperature": 25.8 + }, + "thermostat": { + "lower_bound": 1.0, + "resolution": 0.01, + "setpoint": 23.5, + "upper_bound": 35.0 + }, + "thermostats": { + "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "f871b8c4d63549319221e294e4f88074": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "cool", + "control_state": "auto", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bathroom", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "Badkamer", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 23.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 25.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], + "secondary": ["1772a4ea304041adb83f357b751341ff"] + }, + "vendor": "Plugwise" } }, "gateway": { "cooling_present": true, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 157, + "item_count": 89, "notifications": {}, "reboot": true, "smile_name": "Adam" diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index 7a3fb6e3b5c58..fab2cea5fdc6b 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -36,7 +36,7 @@ "binary_sensors": { "low_battery": false }, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "f871b8c4d63549319221e294e4f88074", @@ -45,6 +45,7 @@ "name": "Tom Badkamer", "sensors": { "battery": 99, + "setpoint": 18.0, "temperature": 18.6, "temperature_difference": -0.2, "valve_position": 100 @@ -59,34 +60,16 @@ "zigbee_mac_address": "000D6F000C8FF5EE" }, "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "active_preset": "home", "available": true, - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], - "climate_mode": "heat", - "control_state": "preheating", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "model": "ThermoTouch", "model_id": "143.1", "name": "Anna", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "off", "sensors": { "setpoint": 20.0, "temperature": 19.1 }, - "thermostat": { - "lower_bound": 1.0, - "resolution": 0.01, - "setpoint": 20.0, - "upper_bound": 35.0 - }, "vendor": "Plugwise" }, "da224107914542988a88561b4452b0f6": { @@ -112,20 +95,10 @@ "zigbee_mac_address": "000D6F000D5A168D" }, "e2f4322d57924fa090fbbc48b3a140dc": { - "active_preset": "home", "available": true, - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], "binary_sensors": { "low_battery": true }, - "climate_mode": "auto", - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -133,8 +106,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Lisa Badkamer", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "Badkamer", "sensors": { "battery": 14, "setpoint": 15.0, @@ -146,12 +117,6 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 15.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "000D6F000C869B61" }, @@ -165,14 +130,81 @@ "name": "Test", "switches": { "relay": true - } + }, + "vendor": "Plugwise" + }, + "f2bf9048bef64cc5b6d5110154e33c81": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "heat", + "control_state": "preheating", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Living room", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 149.9, + "electricity_produced": 0.0, + "temperature": 19.1 + }, + "thermostat": { + "lower_bound": 1.0, + "resolution": 0.01, + "setpoint": 20.0, + "upper_bound": 35.0 + }, + "thermostats": { + "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "f871b8c4d63549319221e294e4f88074": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "auto", + "control_state": "off", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bathroom", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "Badkamer", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 17.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], + "secondary": ["1772a4ea304041adb83f357b751341ff"] + }, + "vendor": "Plugwise" } }, "gateway": { "cooling_present": false, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 157, + "item_count": 89, "notifications": {}, "reboot": true, "smile_name": "Adam" diff --git a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json index 61d6baaf88fbf..4516ce2c2d0d2 100644 --- a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json @@ -1,13 +1,56 @@ { "devices": { - "1346fbd8498d4dbcab7e18d51b771f3d": { + "06aecb3d00354375924f50c47af36bd2": { "active_preset": "no_frost", + "climate_mode": "off", + "control_state": "off", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Slaapkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 24.2 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["1346fbd8498d4dbcab7e18d51b771f3d"], + "secondary": ["356b65335e274d769c338223e7af9c33"] + }, + "vendor": "Plugwise" + }, + "13228dab8ce04617af318a2888b3c548": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "off", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Woonkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 27.4 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.01, + "setpoint": 9.0, + "upper_bound": 30.0 + }, + "thermostats": { + "primary": ["f61f1a2535f54f52ad006a3d18e459ca"], + "secondary": ["833de10f269c4deab58fb9df69901b4e"] + }, + "vendor": "Plugwise" + }, + "1346fbd8498d4dbcab7e18d51b771f3d": { "available": true, "binary_sensors": { "low_battery": false }, - "climate_mode": "off", - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -15,7 +58,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Slaapkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { "battery": 92, "setpoint": 13.0, @@ -27,18 +69,12 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A03" }, "1da4d325838e4ad8aac12177214505c9": { "available": true, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "d58fec52899f4f1c92e4f8fad6d8c48c", @@ -62,7 +98,7 @@ }, "356b65335e274d769c338223e7af9c33": { "available": true, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "06aecb3d00354375924f50c47af36bd2", @@ -102,13 +138,10 @@ "zigbee_mac_address": "ABCD012345670A06" }, "6f3e9d7084214c21b9dfa46f6eeb8700": { - "active_preset": "home", "available": true, "binary_sensors": { "low_battery": false }, - "climate_mode": "heat", - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -116,7 +149,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Kinderkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { "battery": 79, "setpoint": 13.0, @@ -128,18 +160,12 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A02" }, "833de10f269c4deab58fb9df69901b4e": { "available": true, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "13228dab8ce04617af318a2888b3c548", @@ -162,13 +188,10 @@ "zigbee_mac_address": "ABCD012345670A09" }, "a6abc6a129ee499c88a4d420cc413b47": { - "active_preset": "home", "available": true, "binary_sensors": { "low_battery": false }, - "climate_mode": "heat", - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -176,7 +199,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Logeerkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { "battery": 80, "setpoint": 13.0, @@ -188,12 +210,6 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A01" }, @@ -219,9 +235,32 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670101" }, + "d27aede973b54be484f6842d1b2802ad": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "off", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Kinderkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 30.0 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["6f3e9d7084214c21b9dfa46f6eeb8700"], + "secondary": ["d4496250d0e942cfa7aea3476e9070d5"] + }, + "vendor": "Plugwise" + }, "d4496250d0e942cfa7aea3476e9070d5": { "available": true, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "d27aede973b54be484f6842d1b2802ad", @@ -243,6 +282,29 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A04" }, + "d58fec52899f4f1c92e4f8fad6d8c48c": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "off", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Logeerkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 30.0 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["a6abc6a129ee499c88a4d420cc413b47"], + "secondary": ["1da4d325838e4ad8aac12177214505c9"] + }, + "vendor": "Plugwise" + }, "e4684553153b44afbef2200885f379dc": { "available": true, "binary_sensors": { @@ -280,13 +342,10 @@ "vendor": "Remeha B.V." }, "f61f1a2535f54f52ad006a3d18e459ca": { - "active_preset": "home", "available": true, "binary_sensors": { "low_battery": false }, - "climate_mode": "heat", - "control_state": "off", "dev_class": "zone_thermometer", "firmware": "2020-09-01T02:00:00+02:00", "hardware": "1", @@ -294,7 +353,6 @@ "model": "Jip", "model_id": "168-01", "name": "Woonkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { "battery": 100, "humidity": 56.2, @@ -307,12 +365,6 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 4.0, - "resolution": 0.01, - "setpoint": 9.0, - "upper_bound": 30.0 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A08" } @@ -321,7 +373,7 @@ "cooling_present": false, "gateway_id": "b5c2386c6f6342669e50fe49dd05b188", "heater_id": "e4684553153b44afbef2200885f379dc", - "item_count": 228, + "item_count": 244, "notifications": {}, "reboot": true, "smile_name": "Adam" diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json index 7c3962b832fbc..67e8c235cc3b9 100644 --- a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json @@ -21,6 +21,73 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A15" }, + "08963fec7c53423ca5680aa4cb502c63": { + "active_preset": "away", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Badkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "Badkamer Schema", + "sensors": { + "temperature": 18.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 14.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": [ + "f1fee6043d3642a9b0a65297455f008e", + "680423ff840043738f42cc7f1ff97a36" + ], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "12493538af164a409c6a1c79e38afe1c": { + "active_preset": "away", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "heat", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bios", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 16.5 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["df4a4a8169904cdb9c03d61a21f42140"], + "secondary": ["a2c3583e0a6349358998b760cea82d2a"] + }, + "vendor": "Plugwise" + }, "21f2b542c49845e6bb416884c55778d6": { "available": true, "dev_class": "game_console_plug", @@ -42,6 +109,28 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A12" }, + "446ac08dd04d4eff8ac57489757b7314": { + "active_preset": "no_frost", + "climate_mode": "heat", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Garage", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 15.6 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 5.5, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["e7693eb9582644e5b865dba8d4447cf1"], + "secondary": [] + }, + "vendor": "Plugwise" + }, "4a810418d5394b3f82727340b91ba740": { "available": true, "dev_class": "router_plug", @@ -89,13 +178,13 @@ "binary_sensors": { "low_battery": false }, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", "location": "08963fec7c53423ca5680aa4cb502c63", "model": "Tom/Floor", "model_id": "106-03", - "name": "Thermostatic Radiator Badkamer", + "name": "Thermostatic Radiator Badkamer 1", "sensors": { "battery": 51, "setpoint": 14.0, @@ -113,20 +202,10 @@ "zigbee_mac_address": "ABCD012345670A17" }, "6a3bf693d05e48e0b460c815a4fdd09d": { - "active_preset": "asleep", "available": true, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], "binary_sensors": { "low_battery": false }, - "climate_mode": "auto", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -134,8 +213,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Zone Thermostat Jessie", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "CV Jessie", "sensors": { "battery": 37, "setpoint": 15.0, @@ -147,12 +224,6 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 15.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A03" }, @@ -176,6 +247,37 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A05" }, + "82fa13f017d240daa0d0ea1775420f24": { + "active_preset": "asleep", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Jessie", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "CV Jessie", + "sensors": { + "temperature": 17.2 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["6a3bf693d05e48e0b460c815a4fdd09d"], + "secondary": ["d3da73bde12a47d5a6b8f9dad971f2ec"] + }, + "vendor": "Plugwise" + }, "90986d591dcd426cae3ec3e8111ff730": { "binary_sensors": { "heating_state": true @@ -216,7 +318,7 @@ "binary_sensors": { "low_battery": false }, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", "location": "12493538af164a409c6a1c79e38afe1c", @@ -241,7 +343,7 @@ }, "b310b72a0e354bfab43089919b9a88bf": { "available": true, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", "location": "c50f167537524366a5af7aa3942feb1e", @@ -264,20 +366,10 @@ "zigbee_mac_address": "ABCD012345670A02" }, "b59bcebaf94b499ea7d46e4a66fb62d8": { - "active_preset": "home", "available": true, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], "binary_sensors": { "low_battery": false }, - "climate_mode": "auto", "dev_class": "zone_thermostat", "firmware": "2016-08-02T02:00:00+02:00", "hardware": "255", @@ -285,8 +377,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Zone Lisa WK", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "GF7 Woonkamer", "sensors": { "battery": 34, "setpoint": 21.5, @@ -298,14 +388,41 @@ "setpoint": 0.0, "upper_bound": 2.0 }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "c50f167537524366a5af7aa3942feb1e": { + "active_preset": "home", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Woonkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "GF7 Woonkamer", + "sensors": { + "electricity_consumed": 35.6, + "electricity_produced": 0.0, + "temperature": 20.9 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, "setpoint": 21.5, - "upper_bound": 99.9 + "upper_bound": 100.0 }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A07" + "thermostats": { + "primary": ["b59bcebaf94b499ea7d46e4a66fb62d8"], + "secondary": ["b310b72a0e354bfab43089919b9a88bf"] + }, + "vendor": "Plugwise" }, "cd0ddb54ef694e11ac18ed1cbce5dbbd": { "available": true, @@ -333,7 +450,7 @@ "binary_sensors": { "low_battery": false }, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", "location": "82fa13f017d240daa0d0ea1775420f24", @@ -357,20 +474,10 @@ "zigbee_mac_address": "ABCD012345670A10" }, "df4a4a8169904cdb9c03d61a21f42140": { - "active_preset": "away", "available": true, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], "binary_sensors": { "low_battery": false }, - "climate_mode": "heat", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -378,8 +485,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Zone Lisa Bios", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "off", "sensors": { "battery": 67, "setpoint": 13.0, @@ -391,22 +496,14 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A06" }, "e7693eb9582644e5b865dba8d4447cf1": { - "active_preset": "no_frost", "available": true, "binary_sensors": { "low_battery": false }, - "climate_mode": "heat", "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", @@ -414,7 +511,6 @@ "model": "Tom/Floor", "model_id": "106-03", "name": "CV Kraan Garage", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { "battery": 68, "setpoint": 5.5, @@ -428,39 +524,21 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 5.5, - "upper_bound": 100.0 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A11" }, "f1fee6043d3642a9b0a65297455f008e": { - "active_preset": "away", "available": true, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], "binary_sensors": { "low_battery": false }, - "climate_mode": "auto", - "dev_class": "zone_thermostat", + "dev_class": "thermostatic_radiator_valve", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", "location": "08963fec7c53423ca5680aa4cb502c63", "model": "Lisa", "model_id": "158-01", - "name": "Zone Thermostat Badkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "Badkamer Schema", + "name": "Thermostatic Radiator Badkamer 2", "sensors": { "battery": 92, "setpoint": 14.0, @@ -472,12 +550,6 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 14.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A08" }, @@ -505,7 +577,7 @@ "cooling_present": false, "gateway_id": "fe799307f1624099878210aa0b9f1475", "heater_id": "90986d591dcd426cae3ec3e8111ff730", - "item_count": 340, + "item_count": 364, "notifications": { "af82e4ccf9c548528166d38e560662a4": { "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json index a875324fc1391..b1675116bdfcf 100644 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ b/tests/components/plugwise/fixtures/stretch_v31/all_data.json @@ -96,7 +96,8 @@ "name": "Schakel", "switches": { "relay": true - } + }, + "vendor": "Plugwise" }, "d950b314e9d8499f968e6db8d82ef78c": { "dev_class": "report", @@ -111,7 +112,8 @@ "name": "Stroomvreters", "switches": { "relay": true - } + }, + "vendor": "Plugwise" }, "e1c884e7dede431dadee09506ec4f859": { "dev_class": "refrigerator", diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 2a8223a256833..bf7d4260a32c6 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -23,6 +23,90 @@ 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A15', }), + '08963fec7c53423ca5680aa4cb502c63': dict({ + 'active_preset': 'away', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Badkamer', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'Badkamer Schema', + 'sensors': dict({ + 'temperature': 18.9, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 14.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'f1fee6043d3642a9b0a65297455f008e', + '680423ff840043738f42cc7f1ff97a36', + ]), + 'secondary': list([ + ]), + }), + 'vendor': 'Plugwise', + }), + '12493538af164a409c6a1c79e38afe1c': dict({ + 'active_preset': 'away', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'heat', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Bios', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'off', + 'sensors': dict({ + 'electricity_consumed': 0.0, + 'electricity_produced': 0.0, + 'temperature': 16.5, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 13.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'df4a4a8169904cdb9c03d61a21f42140', + ]), + 'secondary': list([ + 'a2c3583e0a6349358998b760cea82d2a', + ]), + }), + 'vendor': 'Plugwise', + }), '21f2b542c49845e6bb416884c55778d6': dict({ 'available': True, 'dev_class': 'game_console_plug', @@ -44,6 +128,37 @@ 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A12', }), + '446ac08dd04d4eff8ac57489757b7314': dict({ + 'active_preset': 'no_frost', + 'climate_mode': 'heat', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Garage', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'sensors': dict({ + 'temperature': 15.6, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 5.5, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'e7693eb9582644e5b865dba8d4447cf1', + ]), + 'secondary': list([ + ]), + }), + 'vendor': 'Plugwise', + }), '4a810418d5394b3f82727340b91ba740': dict({ 'available': True, 'dev_class': 'router_plug', @@ -91,13 +206,13 @@ 'binary_sensors': dict({ 'low_battery': False, }), - 'dev_class': 'thermo_sensor', + 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', 'location': '08963fec7c53423ca5680aa4cb502c63', 'model': 'Tom/Floor', 'model_id': '106-03', - 'name': 'Thermostatic Radiator Badkamer', + 'name': 'Thermostatic Radiator Badkamer 1', 'sensors': dict({ 'battery': 51, 'setpoint': 14.0, @@ -115,20 +230,10 @@ 'zigbee_mac_address': 'ABCD012345670A17', }), '6a3bf693d05e48e0b460c815a4fdd09d': dict({ - 'active_preset': 'asleep', 'available': True, - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', - ]), 'binary_sensors': dict({ 'low_battery': False, }), - 'climate_mode': 'auto', 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', @@ -136,14 +241,6 @@ 'model': 'Lisa', 'model_id': '158-01', 'name': 'Zone Thermostat Jessie', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', - ]), - 'select_schedule': 'CV Jessie', 'sensors': dict({ 'battery': 37, 'setpoint': 15.0, @@ -155,12 +252,6 @@ 'setpoint': 0.0, 'upper_bound': 2.0, }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 15.0, - 'upper_bound': 99.9, - }), 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A03', }), @@ -184,6 +275,47 @@ 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A05', }), + '82fa13f017d240daa0d0ea1775420f24': dict({ + 'active_preset': 'asleep', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Jessie', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'CV Jessie', + 'sensors': dict({ + 'temperature': 17.2, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 15.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + '6a3bf693d05e48e0b460c815a4fdd09d', + ]), + 'secondary': list([ + 'd3da73bde12a47d5a6b8f9dad971f2ec', + ]), + }), + 'vendor': 'Plugwise', + }), '90986d591dcd426cae3ec3e8111ff730': dict({ 'binary_sensors': dict({ 'heating_state': True, @@ -224,7 +356,7 @@ 'binary_sensors': dict({ 'low_battery': False, }), - 'dev_class': 'thermo_sensor', + 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', 'location': '12493538af164a409c6a1c79e38afe1c', @@ -249,7 +381,7 @@ }), 'b310b72a0e354bfab43089919b9a88bf': dict({ 'available': True, - 'dev_class': 'thermo_sensor', + 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', 'location': 'c50f167537524366a5af7aa3942feb1e', @@ -272,20 +404,10 @@ 'zigbee_mac_address': 'ABCD012345670A02', }), 'b59bcebaf94b499ea7d46e4a66fb62d8': dict({ - 'active_preset': 'home', 'available': True, - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', - ]), 'binary_sensors': dict({ 'low_battery': False, }), - 'climate_mode': 'auto', 'dev_class': 'zone_thermostat', 'firmware': '2016-08-02T02:00:00+02:00', 'hardware': '255', @@ -293,14 +415,6 @@ 'model': 'Lisa', 'model_id': '158-01', 'name': 'Zone Lisa WK', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', - ]), - 'select_schedule': 'GF7 Woonkamer', 'sensors': dict({ 'battery': 34, 'setpoint': 21.5, @@ -312,14 +426,51 @@ 'setpoint': 0.0, 'upper_bound': 2.0, }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A07', + }), + 'c50f167537524366a5af7aa3942feb1e': dict({ + 'active_preset': 'home', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Woonkamer', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'GF7 Woonkamer', + 'sensors': dict({ + 'electricity_consumed': 35.6, + 'electricity_produced': 0.0, + 'temperature': 20.9, + }), 'thermostat': dict({ 'lower_bound': 0.0, 'resolution': 0.01, 'setpoint': 21.5, - 'upper_bound': 99.9, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'b59bcebaf94b499ea7d46e4a66fb62d8', + ]), + 'secondary': list([ + 'b310b72a0e354bfab43089919b9a88bf', + ]), }), 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A07', }), 'cd0ddb54ef694e11ac18ed1cbce5dbbd': dict({ 'available': True, @@ -347,7 +498,7 @@ 'binary_sensors': dict({ 'low_battery': False, }), - 'dev_class': 'thermo_sensor', + 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', 'location': '82fa13f017d240daa0d0ea1775420f24', @@ -371,20 +522,10 @@ 'zigbee_mac_address': 'ABCD012345670A10', }), 'df4a4a8169904cdb9c03d61a21f42140': dict({ - 'active_preset': 'away', 'available': True, - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', - ]), 'binary_sensors': dict({ 'low_battery': False, }), - 'climate_mode': 'heat', 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', @@ -392,14 +533,6 @@ 'model': 'Lisa', 'model_id': '158-01', 'name': 'Zone Lisa Bios', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', - ]), - 'select_schedule': 'off', 'sensors': dict({ 'battery': 67, 'setpoint': 13.0, @@ -411,22 +544,14 @@ 'setpoint': 0.0, 'upper_bound': 2.0, }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 13.0, - 'upper_bound': 99.9, - }), 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A06', }), 'e7693eb9582644e5b865dba8d4447cf1': dict({ - 'active_preset': 'no_frost', 'available': True, 'binary_sensors': dict({ 'low_battery': False, }), - 'climate_mode': 'heat', 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', @@ -434,13 +559,6 @@ 'model': 'Tom/Floor', 'model_id': '106-03', 'name': 'CV Kraan Garage', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', - ]), 'sensors': dict({ 'battery': 68, 'setpoint': 5.5, @@ -454,45 +572,21 @@ 'setpoint': 0.0, 'upper_bound': 2.0, }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 5.5, - 'upper_bound': 100.0, - }), 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A11', }), 'f1fee6043d3642a9b0a65297455f008e': dict({ - 'active_preset': 'away', 'available': True, - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', - ]), 'binary_sensors': dict({ 'low_battery': False, }), - 'climate_mode': 'auto', - 'dev_class': 'zone_thermostat', + 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', 'location': '08963fec7c53423ca5680aa4cb502c63', 'model': 'Lisa', 'model_id': '158-01', - 'name': 'Zone Thermostat Badkamer', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', - ]), - 'select_schedule': 'Badkamer Schema', + 'name': 'Thermostatic Radiator Badkamer 2', 'sensors': dict({ 'battery': 92, 'setpoint': 14.0, @@ -504,12 +598,6 @@ 'setpoint': 0.0, 'upper_bound': 2.0, }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 14.0, - 'upper_bound': 99.9, - }), 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A08', }), @@ -537,7 +625,7 @@ 'cooling_present': False, 'gateway_id': 'fe799307f1624099878210aa0b9f1475', 'heater_id': '90986d591dcd426cae3ec3e8111ff730', - 'item_count': 340, + 'item_count': 364, 'notifications': dict({ 'af82e4ccf9c548528166d38e560662a4': dict({ 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index f846e818b6e89..c0c1c00c68dbc 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -28,7 +28,7 @@ async def test_adam_climate_entity_attributes( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: """Test creation of adam climate device environment.""" - state = hass.states.get("climate.zone_lisa_wk") + state = hass.states.get("climate.woonkamer") assert state assert state.state == HVACMode.AUTO assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT] @@ -46,7 +46,7 @@ async def test_adam_climate_entity_attributes( assert state.attributes["max_temp"] == 35.0 assert state.attributes["target_temp_step"] == 0.1 - state = hass.states.get("climate.zone_thermostat_jessie") + state = hass.states.get("climate.jessie") assert state assert state.state == HVACMode.AUTO assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT] @@ -68,7 +68,7 @@ async def test_adam_2_climate_entity_attributes( hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry ) -> None: """Test creation of adam climate device environment.""" - state = hass.states.get("climate.anna") + state = hass.states.get("climate.living_room") assert state assert state.state == HVACMode.HEAT assert state.attributes["hvac_action"] == "preheating" @@ -78,7 +78,7 @@ async def test_adam_2_climate_entity_attributes( HVACMode.HEAT, ] - state = hass.states.get("climate.lisa_badkamer") + state = hass.states.get("climate.bathroom") assert state assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "idle" @@ -96,7 +96,7 @@ async def test_adam_3_climate_entity_attributes( freezer: FrozenDateTimeFactory, ) -> None: """Test creation of adam climate device environment.""" - state = hass.states.get("climate.anna") + state = hass.states.get("climate.living_room") assert state assert state.state == HVACMode.COOL assert state.attributes["hvac_action"] == "cooling" @@ -109,7 +109,7 @@ async def test_adam_3_climate_entity_attributes( data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( "heating" ) - data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["control_state"] = "heating" + data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = "heating" data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "cooling_state" ] = False @@ -121,7 +121,7 @@ async def test_adam_3_climate_entity_attributes( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("climate.anna") + state = hass.states.get("climate.living_room") assert state assert state.state == HVACMode.HEAT assert state.attributes["hvac_action"] == "heating" @@ -135,7 +135,7 @@ async def test_adam_3_climate_entity_attributes( data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( "cooling" ) - data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["control_state"] = "cooling" + data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = "cooling" data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "cooling_state" ] = True @@ -147,7 +147,7 @@ async def test_adam_3_climate_entity_attributes( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("climate.anna") + state = hass.states.get("climate.living_room") assert state assert state.state == HVACMode.COOL assert state.attributes["hvac_action"] == "cooling" @@ -168,7 +168,7 @@ async def test_adam_climate_adjust_negative_testing( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {"entity_id": "climate.zone_lisa_wk", "temperature": 25}, + {"entity_id": "climate.woonkamer", "temperature": 25}, blocking=True, ) @@ -180,7 +180,7 @@ async def test_adam_climate_entity_climate_changes( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {"entity_id": "climate.zone_lisa_wk", "temperature": 25}, + {"entity_id": "climate.woonkamer", "temperature": 25}, blocking=True, ) assert mock_smile_adam.set_temperature.call_count == 1 @@ -192,7 +192,7 @@ async def test_adam_climate_entity_climate_changes( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - "entity_id": "climate.zone_lisa_wk", + "entity_id": "climate.woonkamer", "hvac_mode": "heat", "temperature": 25, }, @@ -207,14 +207,14 @@ async def test_adam_climate_entity_climate_changes( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {"entity_id": "climate.zone_lisa_wk", "temperature": 150}, + {"entity_id": "climate.woonkamer", "temperature": 150}, blocking=True, ) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {"entity_id": "climate.zone_lisa_wk", "preset_mode": "away"}, + {"entity_id": "climate.woonkamer", "preset_mode": "away"}, blocking=True, ) assert mock_smile_adam.set_preset.call_count == 1 @@ -225,7 +225,7 @@ async def test_adam_climate_entity_climate_changes( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {"entity_id": "climate.zone_lisa_wk", "hvac_mode": "heat"}, + {"entity_id": "climate.woonkamer", "hvac_mode": "heat"}, blocking=True, ) assert mock_smile_adam.set_schedule_state.call_count == 2 @@ -238,7 +238,7 @@ async def test_adam_climate_entity_climate_changes( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - "entity_id": "climate.zone_thermostat_jessie", + "entity_id": "climate.jessie", "hvac_mode": "dry", }, blocking=True, diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 5b276d5018dc1..3b9881c9e3dfc 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -34,17 +34,18 @@ TOM = { "01234567890abcdefghijklmnopqrstu": { "available": True, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "f871b8c4d63549319221e294e4f88074", "model": "Tom/Floor", - "name": "Tom Zolder", + "name": "Tom Badkamer 2", "binary_sensors": { "low_battery": False, }, "sensors": { "battery": 99, + "setpoint": 18.0, "temperature": 18.6, "temperature_difference": 2.3, "valve_position": 0.0, @@ -246,7 +247,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 31 + == 38 ) assert ( len( @@ -254,11 +255,19 @@ async def test_update_device( device_registry, mock_config_entry.entry_id ) ) - == 6 + == 8 ) # Add a 2nd Tom/Floor data.devices.update(TOM) + data.devices["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( + { + "secondary": [ + "01234567890abcdefghijklmnopqrstu", + "1772a4ea304041adb83f357b751341ff", + ] + } + ) with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) @@ -270,7 +279,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 37 + == 45 ) assert ( len( @@ -278,7 +287,7 @@ async def test_update_device( device_registry, mock_config_entry.entry_id ) ) - == 7 + == 9 ) item_list: list[str] = [] for device_entry in list(device_registry.devices.values()): @@ -286,6 +295,9 @@ async def test_update_device( assert "01234567890abcdefghijklmnopqrstu" in item_list # Remove the existing Tom/Floor + data.devices["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( + {"secondary": ["01234567890abcdefghijklmnopqrstu"]} + ) data.devices.pop("1772a4ea304041adb83f357b751341ff") with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) @@ -298,7 +310,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 31 + == 38 ) assert ( len( @@ -306,7 +318,7 @@ async def test_update_device( device_registry, mock_config_entry.entry_id ) ) - == 6 + == 8 ) item_list: list[str] = [] for device_entry in list(device_registry.devices.values()): diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index f521787714b16..0fab41cdbaea4 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -18,7 +18,7 @@ async def test_adam_select_entities( ) -> None: """Test a thermostat Select.""" - state = hass.states.get("select.zone_lisa_wk_thermostat_schedule") + state = hass.states.get("select.woonkamer_thermostat_schedule") assert state assert state.state == "GF7 Woonkamer" @@ -32,7 +32,7 @@ async def test_adam_change_select_entity( SELECT_DOMAIN, SERVICE_SELECT_OPTION, { - ATTR_ENTITY_ID: "select.zone_lisa_wk_thermostat_schedule", + ATTR_ENTITY_ID: "select.woonkamer_thermostat_schedule", ATTR_OPTION: "Badkamer Schema", }, blocking=True, From 67ba44c3fa7db9c1d3b5dccfaa6b35ea2ac49a6f Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 27 Nov 2024 08:42:19 +0100 Subject: [PATCH 0935/1070] Use entity description class for Garages Amsterdam (#131672) --- .../garages_amsterdam/binary_sensor.py | 53 ++++- .../components/garages_amsterdam/const.py | 2 +- .../components/garages_amsterdam/entity.py | 3 - .../components/garages_amsterdam/sensor.py | 76 +++++-- .../components/garages_amsterdam/strings.json | 26 ++- .../components/garages_amsterdam/__init__.py | 11 + .../components/garages_amsterdam/conftest.py | 85 +++++--- .../snapshots/test_binary_sensor.ambr | 49 +++++ .../snapshots/test_sensor.ambr | 195 ++++++++++++++++++ .../garages_amsterdam/test_binary_sensor.py | 31 +++ .../garages_amsterdam/test_config_flow.py | 41 ++-- .../garages_amsterdam/test_sensor.py | 31 +++ 12 files changed, 523 insertions(+), 80 deletions(-) create mode 100644 tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/garages_amsterdam/snapshots/test_sensor.ambr create mode 100644 tests/components/garages_amsterdam/test_binary_sensor.py create mode 100644 tests/components/garages_amsterdam/test_sensor.py diff --git a/homeassistant/components/garages_amsterdam/binary_sensor.py b/homeassistant/components/garages_amsterdam/binary_sensor.py index 7c763307ddf0f..b93b43e117353 100644 --- a/homeassistant/components/garages_amsterdam/binary_sensor.py +++ b/homeassistant/components/garages_amsterdam/binary_sensor.py @@ -2,19 +2,39 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + +from odp_amsterdam import Garage + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import GaragesAmsterdamConfigEntry +from .coordinator import GaragesAmsterdamDataUpdateCoordinator from .entity import GaragesAmsterdamEntity -BINARY_SENSORS = { - "state", -} + +@dataclass(frozen=True, kw_only=True) +class GaragesAmsterdamBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Garages Amsterdam binary sensor entity.""" + + is_on: Callable[[Garage], bool] + + +BINARY_SENSORS: tuple[GaragesAmsterdamBinarySensorEntityDescription, ...] = ( + GaragesAmsterdamBinarySensorEntityDescription( + key="state", + translation_key="state", + device_class=BinarySensorDeviceClass.PROBLEM, + is_on=lambda garage: garage.state != "ok", + ), +) async def async_setup_entry( @@ -26,20 +46,33 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - GaragesAmsterdamBinarySensor(coordinator, entry.data["garage_name"], info_type) - for info_type in BINARY_SENSORS + GaragesAmsterdamBinarySensor( + coordinator=coordinator, + garage_name=entry.data["garage_name"], + description=description, + ) + for description in BINARY_SENSORS ) class GaragesAmsterdamBinarySensor(GaragesAmsterdamEntity, BinarySensorEntity): """Binary Sensor representing garages amsterdam data.""" - _attr_device_class = BinarySensorDeviceClass.PROBLEM - _attr_name = None + entity_description: GaragesAmsterdamBinarySensorEntityDescription + + def __init__( + self, + *, + coordinator: GaragesAmsterdamDataUpdateCoordinator, + garage_name: str, + description: GaragesAmsterdamBinarySensorEntityDescription, + ) -> None: + """Initialize garages amsterdam binary sensor.""" + super().__init__(coordinator, garage_name) + self.entity_description = description + self._attr_unique_id = f"{garage_name}-{description.key}" @property def is_on(self) -> bool: """If the binary sensor is currently on or off.""" - return ( - getattr(self.coordinator.data[self._garage_name], self._info_type) != "ok" - ) + return self.entity_description.is_on(self.coordinator.data[self._garage_name]) diff --git a/homeassistant/components/garages_amsterdam/const.py b/homeassistant/components/garages_amsterdam/const.py index 0f1e6505f9f62..be5e2216a81bb 100644 --- a/homeassistant/components/garages_amsterdam/const.py +++ b/homeassistant/components/garages_amsterdam/const.py @@ -7,7 +7,7 @@ from typing import Final DOMAIN: Final = "garages_amsterdam" -ATTRIBUTION = f'{"Data provided by municipality of Amsterdam"}' +ATTRIBUTION = "Data provided by municipality of Amsterdam" LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(minutes=10) diff --git a/homeassistant/components/garages_amsterdam/entity.py b/homeassistant/components/garages_amsterdam/entity.py index a8b030157bc33..433bc75b9628e 100644 --- a/homeassistant/components/garages_amsterdam/entity.py +++ b/homeassistant/components/garages_amsterdam/entity.py @@ -19,13 +19,10 @@ def __init__( self, coordinator: GaragesAmsterdamDataUpdateCoordinator, garage_name: str, - info_type: str, ) -> None: """Initialize garages amsterdam entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{garage_name}-{info_type}" self._garage_name = garage_name - self._info_type = info_type self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, garage_name)}, name=garage_name, diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py index 4f262b6e6674a..b562fff841a78 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -2,20 +2,56 @@ from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from collections.abc import Callable +from dataclasses import dataclass + +from odp_amsterdam import Garage + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import GaragesAmsterdamConfigEntry from .coordinator import GaragesAmsterdamDataUpdateCoordinator from .entity import GaragesAmsterdamEntity -SENSORS = { - "free_space_short", - "free_space_long", - "short_capacity", - "long_capacity", -} + +@dataclass(frozen=True, kw_only=True) +class GaragesAmsterdamSensorEntityDescription(SensorEntityDescription): + """Class describing Garages Amsterdam sensor entity.""" + + value_fn: Callable[[Garage], StateType] + + +SENSORS: tuple[GaragesAmsterdamSensorEntityDescription, ...] = ( + GaragesAmsterdamSensorEntityDescription( + key="free_space_short", + translation_key="free_space_short", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda garage: garage.free_space_short, + ), + GaragesAmsterdamSensorEntityDescription( + key="free_space_long", + translation_key="free_space_long", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda garage: garage.free_space_long, + ), + GaragesAmsterdamSensorEntityDescription( + key="short_capacity", + translation_key="short_capacity", + value_fn=lambda garage: garage.short_capacity, + ), + GaragesAmsterdamSensorEntityDescription( + key="long_capacity", + translation_key="long_capacity", + value_fn=lambda garage: garage.long_capacity, + ), +) async def async_setup_entry( @@ -27,26 +63,32 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - GaragesAmsterdamSensor(coordinator, entry.data["garage_name"], info_type) - for info_type in SENSORS - if getattr(coordinator.data[entry.data["garage_name"]], info_type) is not None + GaragesAmsterdamSensor( + coordinator=coordinator, + garage_name=entry.data["garage_name"], + description=description, + ) + for description in SENSORS + if description.value_fn(coordinator.data[entry.data["garage_name"]]) is not None ) class GaragesAmsterdamSensor(GaragesAmsterdamEntity, SensorEntity): """Sensor representing garages amsterdam data.""" - _attr_native_unit_of_measurement = "cars" + entity_description: GaragesAmsterdamSensorEntityDescription def __init__( self, + *, coordinator: GaragesAmsterdamDataUpdateCoordinator, garage_name: str, - info_type: str, + description: GaragesAmsterdamSensorEntityDescription, ) -> None: """Initialize garages amsterdam sensor.""" - super().__init__(coordinator, garage_name, info_type) - self._attr_translation_key = info_type + super().__init__(coordinator, garage_name) + self.entity_description = description + self._attr_unique_id = f"{garage_name}-{description.key}" @property def available(self) -> bool: @@ -56,6 +98,8 @@ def available(self) -> bool: ) @property - def native_value(self) -> str: + def native_value(self) -> StateType: """Return the state of the sensor.""" - return getattr(self.coordinator.data[self._garage_name], self._info_type) + return self.entity_description.value_fn( + self.coordinator.data[self._garage_name] + ) diff --git a/homeassistant/components/garages_amsterdam/strings.json b/homeassistant/components/garages_amsterdam/strings.json index 89a85f9744893..19157afdafb72 100644 --- a/homeassistant/components/garages_amsterdam/strings.json +++ b/homeassistant/components/garages_amsterdam/strings.json @@ -3,8 +3,13 @@ "config": { "step": { "user": { - "title": "Pick a garage to monitor", - "data": { "garage_name": "Garage name" } + "description": "Select a garage from the list", + "data": { + "garage_name": "Garage name" + }, + "data_description": { + "garage_name": "The name of the garage you want to monitor." + } } }, "abort": { @@ -16,16 +21,25 @@ "entity": { "sensor": { "free_space_short": { - "name": "Short parking free space" + "name": "Short parking free space", + "unit_of_measurement": "cars" }, "free_space_long": { - "name": "Long parking free space" + "name": "Long parking free space", + "unit_of_measurement": "cars" }, "short_capacity": { - "name": "Short parking capacity" + "name": "Short parking capacity", + "unit_of_measurement": "cars" }, "long_capacity": { - "name": "Long parking capacity" + "name": "Long parking capacity", + "unit_of_measurement": "cars" + } + }, + "binary_sensor": { + "state": { + "name": "State" } } } diff --git a/tests/components/garages_amsterdam/__init__.py b/tests/components/garages_amsterdam/__init__.py index ff430c0e7b2de..f721506b9b069 100644 --- a/tests/components/garages_amsterdam/__init__.py +++ b/tests/components/garages_amsterdam/__init__.py @@ -1 +1,12 @@ """Tests for the Garages Amsterdam integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/garages_amsterdam/conftest.py b/tests/components/garages_amsterdam/conftest.py index 8d7eb8752b012..93190d1d1eea0 100644 --- a/tests/components/garages_amsterdam/conftest.py +++ b/tests/components/garages_amsterdam/conftest.py @@ -1,7 +1,10 @@ """Fixtures for Garages Amsterdam integration tests.""" -from unittest.mock import Mock, patch +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch +from odp_amsterdam import Garage, GarageCategory, VehicleType import pytest from homeassistant.components.garages_amsterdam.const import DOMAIN @@ -10,39 +13,73 @@ @pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Return the default mocked config entry.""" - return MockConfigEntry( - title="monitor", - domain=DOMAIN, - data={}, - unique_id="unique_thingy", - version=1, - ) +def mock_setup_entry() -> Generator[AsyncMock]: + """Override setup entry.""" + with patch( + "homeassistant.components.garages_amsterdam.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry -@pytest.fixture(autouse=True) -def mock_garages_amsterdam(): +@pytest.fixture +def mock_garages_amsterdam() -> Generator[AsyncMock]: """Mock garages_amsterdam garages.""" - with patch( - "odp_amsterdam.ODPAmsterdam.all_garages", - return_value=[ - Mock( + with ( + patch( + "homeassistant.components.garages_amsterdam.ODPAmsterdam", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.garages_amsterdam.config_flow.ODPAmsterdam", + new=mock_client, + ), + ): + client = mock_client.return_value + client.all_garages.return_value = [ + Garage( + garage_id="test-id-1", garage_name="IJDok", + vehicle=VehicleType.CAR, + category=GarageCategory.GARAGE, + state="ok", free_space_short=100, free_space_long=10, short_capacity=120, long_capacity=60, - state="ok", + availability_pct=50.5, + longitude=1.111111, + latitude=2.222222, + updated_at=datetime(2023, 2, 23, 13, 44, 48, tzinfo=UTC), ), - Mock( + Garage( + garage_id="test-id-2", garage_name="Arena", + vehicle=VehicleType.CAR, + category=GarageCategory.GARAGE, + state="error", free_space_short=200, - free_space_long=20, + free_space_long=None, short_capacity=240, - long_capacity=80, - state="error", + long_capacity=None, + availability_pct=83.3, + longitude=3.333333, + latitude=4.444444, + updated_at=datetime(2023, 2, 23, 13, 44, 48, tzinfo=UTC), ), - ], - ) as mock_get_garages: - yield mock_get_garages + ] + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="monitor", + domain=DOMAIN, + data={ + "garage_name": "IJDok", + }, + unique_id="unique_thingy", + version=1, + ) diff --git a/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr b/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..5f6511090ee87 --- /dev/null +++ b/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_all_binary_sensors[binary_sensor.ijdok_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ijdok_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State', + 'platform': 'garages_amsterdam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state', + 'unique_id': 'IJDok-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensors[binary_sensor.ijdok_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by municipality of Amsterdam', + 'device_class': 'problem', + 'friendly_name': 'IJDok State', + }), + 'context': , + 'entity_id': 'binary_sensor.ijdok_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/garages_amsterdam/snapshots/test_sensor.ambr b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..55922de2f0b4d --- /dev/null +++ b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr @@ -0,0 +1,195 @@ +# serializer version: 1 +# name: test_all_sensors[sensor.ijdok_long_parking_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ijdok_long_parking_capacity', + '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': 'Long parking capacity', + 'platform': 'garages_amsterdam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'long_capacity', + 'unique_id': 'IJDok-long_capacity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.ijdok_long_parking_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by municipality of Amsterdam', + 'friendly_name': 'IJDok Long parking capacity', + }), + 'context': , + 'entity_id': 'sensor.ijdok_long_parking_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_sensors[sensor.ijdok_long_parking_free_space-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ijdok_long_parking_free_space', + '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': 'Long parking free space', + 'platform': 'garages_amsterdam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'free_space_long', + 'unique_id': 'IJDok-free_space_long', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.ijdok_long_parking_free_space-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by municipality of Amsterdam', + 'friendly_name': 'IJDok Long parking free space', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.ijdok_long_parking_free_space', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_sensors[sensor.ijdok_short_parking_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ijdok_short_parking_capacity', + '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': 'Short parking capacity', + 'platform': 'garages_amsterdam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'short_capacity', + 'unique_id': 'IJDok-short_capacity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.ijdok_short_parking_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by municipality of Amsterdam', + 'friendly_name': 'IJDok Short parking capacity', + }), + 'context': , + 'entity_id': 'sensor.ijdok_short_parking_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_all_sensors[sensor.ijdok_short_parking_free_space-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ijdok_short_parking_free_space', + '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': 'Short parking free space', + 'platform': 'garages_amsterdam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'free_space_short', + 'unique_id': 'IJDok-free_space_short', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.ijdok_short_parking_free_space-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by municipality of Amsterdam', + 'friendly_name': 'IJDok Short parking free space', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.ijdok_short_parking_free_space', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/garages_amsterdam/test_binary_sensor.py b/tests/components/garages_amsterdam/test_binary_sensor.py new file mode 100644 index 0000000000000..b7d0333f7e3cd --- /dev/null +++ b/tests/components/garages_amsterdam/test_binary_sensor.py @@ -0,0 +1,31 @@ +"""Tests the binary sensors provided by the Garages Amsterdam integration.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import snapshot_platform + + +async def test_all_binary_sensors( + hass: HomeAssistant, + mock_garages_amsterdam: AsyncMock, + mock_config_entry: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all binary sensors.""" + with patch( + "homeassistant.components.garages_amsterdam.PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/garages_amsterdam/test_config_flow.py b/tests/components/garages_amsterdam/test_config_flow.py index 9c5b10f9ecc8e..68950c96cf011 100644 --- a/tests/components/garages_amsterdam/test_config_flow.py +++ b/tests/components/garages_amsterdam/test_config_flow.py @@ -1,39 +1,40 @@ """Test the Garages Amsterdam config flow.""" from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from aiohttp import ClientResponseError import pytest -from homeassistant import config_entries from homeassistant.components.garages_amsterdam.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -async def test_full_user_flow(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_full_user_flow( + hass: HomeAssistant, + mock_garages_amsterdam: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert not result.get("errors") - with patch( - "homeassistant.components.garages_amsterdam.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"garage_name": "IJDok"}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"garage_name": "IJDok"}, + ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == "IJDok" - assert "result" in result2 - assert result2["result"].unique_id == "IJDok" + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "IJDok" + assert result.get("data") == {"garage_name": "IJDok"} + assert len(mock_garages_amsterdam.all_garages.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -50,14 +51,14 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: async def test_error_handling( side_effect: Exception, reason: str, hass: HomeAssistant ) -> None: - """Test we get the form.""" + """Test error handling in the config flow.""" with patch( "homeassistant.components.garages_amsterdam.config_flow.ODPAmsterdam.all_garages", side_effect=side_effect, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == reason diff --git a/tests/components/garages_amsterdam/test_sensor.py b/tests/components/garages_amsterdam/test_sensor.py new file mode 100644 index 0000000000000..bc36401ea475d --- /dev/null +++ b/tests/components/garages_amsterdam/test_sensor.py @@ -0,0 +1,31 @@ +"""Tests the sensors provided by the Garages Amsterdam integration.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import snapshot_platform + + +async def test_all_sensors( + hass: HomeAssistant, + mock_garages_amsterdam: AsyncMock, + mock_config_entry: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all sensors.""" + with patch( + "homeassistant.components.garages_amsterdam.PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 605651f3645ed215c9705ccdb0c0f7c2d37774aa Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 27 Nov 2024 08:42:37 +0100 Subject: [PATCH 0936/1070] Bump ZHA to 0.0.40 (#131680) --- homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/strings.json | 48 ++++++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index a2a285e61094f..ded37fc4713db 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.39"], + "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.40"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index c462bef8fb070..4706e2048720e 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -600,6 +600,12 @@ }, "self_test": { "name": "Self-test" + }, + "reset_summation_delivered": { + "name": "Reset summation delivered" + }, + "restart_device": { + "name": "Restart device" } }, "climate": { @@ -798,6 +804,24 @@ }, "off_led_intensity": { "name": "Off LED intensity" + }, + "frost_protection_temperature": { + "name": "Frost protection temperature" + }, + "valve_opening_degree": { + "name": "Valve opening degree" + }, + "valve_closing_degree": { + "name": "Valve closing degree" + }, + "siren_time": { + "name": "Siren time" + }, + "timer_time_left": { + "name": "Timer time left" + }, + "approach_distance": { + "name": "Approach distance" } }, "select": { @@ -899,6 +923,9 @@ }, "off_led_color": { "name": "Off LED color" + }, + "external_trigger_mode": { + "name": "External trigger mode" } }, "sensor": { @@ -1096,6 +1123,15 @@ }, "valve_status_2": { "name": "Status 2" + }, + "timer_state": { + "name": "Timer state" + }, + "last_valve_open_duration": { + "name": "Last valve open duration" + }, + "motion_distance": { + "name": "Motion distance" } }, "switch": { @@ -1209,6 +1245,18 @@ }, "double_up_full": { "name": "Double tap on - full" + }, + "open_window": { + "name": "Open window" + }, + "turbo_mode": { + "name": "Turbo mode" + }, + "detach_relay": { + "name": "Detach relay" + }, + "enable_siren": { + "name": "Enable siren" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 100e77bff6928..af34c0e797b25 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3081,7 +3081,7 @@ zeroconf==0.136.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.39 +zha==0.0.40 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 506c5d469df84..40d43ea825ebb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2464,7 +2464,7 @@ zeroconf==0.136.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.39 +zha==0.0.40 # homeassistant.components.zwave_js zwave-js-server-python==0.59.1 From 00c4fa4146d92a78c98db3dcc09750e8c615cf68 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 27 Nov 2024 08:45:18 +0100 Subject: [PATCH 0937/1070] Add missing section data_description to translation validator in hassfest (#131675) Add missing data_description to translation validator in hassfest --- script/hassfest/translations.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 2965ccb740601..2fb70b6e0beff 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -172,6 +172,9 @@ def gen_data_entry_schema( vol.Optional("sections"): { str: { vol.Optional("data"): {str: translation_value_validator}, + vol.Optional("data_description"): { + str: translation_value_validator + }, vol.Optional("description"): translation_value_validator, vol.Optional("name"): translation_value_validator, }, From 2b939ce6ec037c1cbcbe17af9cfb19354ed9f1a1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 27 Nov 2024 08:46:45 +0100 Subject: [PATCH 0938/1070] Add translation checks for service exceptions (#131266) * Add translation checks for service exceptions * Adjust * Remove invalid comment --- tests/components/conftest.py | 52 ++++++++++++++++++- .../components/websocket_api/test_commands.py | 3 ++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 42944a480227b..4294c0c2912ab 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -27,13 +27,14 @@ OptionsFlowManager, ) from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant, ServiceRegistry, ServiceResponse from homeassistant.data_entry_flow import ( FlowContext, FlowHandler, FlowManager, FlowResultType, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.translation import async_get_translations @@ -713,6 +714,23 @@ async def _check_create_issue_translations( ) +async def _check_exception_translation( + hass: HomeAssistant, + exception: HomeAssistantError, + translation_errors: dict[str, str], +) -> None: + if exception.translation_key is None: + return + await _validate_translation( + hass, + translation_errors, + "exceptions", + exception.translation_domain, + f"{exception.translation_key}.message", + exception.translation_placeholders, + ) + + @pytest.fixture(autouse=True) async def check_translations( ignore_translations: str | list[str], @@ -733,6 +751,7 @@ async def check_translations( # Keep reference to original functions _original_flow_manager_async_handle_step = FlowManager._async_handle_step _original_issue_registry_async_create_issue = ir.IssueRegistry.async_get_or_create + _original_service_registry_async_call = ServiceRegistry.async_call # Prepare override functions async def _flow_manager_async_handle_step( @@ -755,6 +774,33 @@ def _issue_registry_async_create_issue( ) return result + async def _service_registry_async_call( + self: ServiceRegistry, + domain: str, + service: str, + service_data: dict[str, Any] | None = None, + blocking: bool = False, + context: Context | None = None, + target: dict[str, Any] | None = None, + return_response: bool = False, + ) -> ServiceResponse: + try: + return await _original_service_registry_async_call( + self, + domain, + service, + service_data, + blocking, + context, + target, + return_response, + ) + except HomeAssistantError as err: + translation_coros.add( + _check_exception_translation(self._hass, err, translation_errors) + ) + raise + # Use override functions with ( patch( @@ -765,6 +811,10 @@ def _issue_registry_async_create_issue( "homeassistant.helpers.issue_registry.IssueRegistry.async_get_or_create", _issue_registry_async_create_issue, ), + patch( + "homeassistant.core.ServiceRegistry.async_call", + _service_registry_async_call, + ), ): yield diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c1a043f915b1a..22e839d84e4ed 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2390,6 +2390,9 @@ async def test_execute_script( ), ], ) +@pytest.mark.parametrize( + "ignore_translations", ["component.test.exceptions.test_error.message"] +) async def test_execute_script_err_localization( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, From 1e05f98ddd7da4d57baeebf2107ead2c7e72af86 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 27 Nov 2024 08:57:32 +0100 Subject: [PATCH 0939/1070] Use report_usage for deprecation warning in alarm_control_panel (#130543) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../alarm_control_panel/__init__.py | 31 ++--- .../alarm_control_panel/conftest.py | 22 +++- .../alarm_control_panel/test_init.py | 116 +++++++++--------- 3 files changed, 91 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 4389e3a9ad9df..4bcd2adb60f46 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -35,6 +35,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -163,7 +164,6 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A _alarm_control_panel_option_default_code: str | None = None __alarm_legacy_state: bool = False - __alarm_legacy_state_reported: bool = False def __init_subclass__(cls, **kwargs: Any) -> None: """Post initialisation processing.""" @@ -180,9 +180,7 @@ def __setattr__(self, name: str, value: Any, /) -> None: unless already reported. """ if name == "_attr_state": - if self.__alarm_legacy_state_reported is not True: - self._report_deprecated_alarm_state_handling() - self.__alarm_legacy_state_reported = True + self._report_deprecated_alarm_state_handling() return super().__setattr__(name, value) @callback @@ -194,7 +192,7 @@ def add_to_platform_start( ) -> None: """Start adding an entity to a platform.""" super().add_to_platform_start(hass, platform, parallel_updates) - if self.__alarm_legacy_state and not self.__alarm_legacy_state_reported: + if self.__alarm_legacy_state: self._report_deprecated_alarm_state_handling() @callback @@ -203,19 +201,16 @@ def _report_deprecated_alarm_state_handling(self) -> None: Integrations should implement alarm_state instead of using state directly. """ - self.__alarm_legacy_state_reported = True - if "custom_components" in type(self).__module__: - # Do not report on core integrations as they have been fixed. - report_issue = "report it to the custom integration author." - _LOGGER.warning( - "Entity %s (%s) is setting state directly" - " which will stop working in HA Core 2025.11." - " Entities should implement the 'alarm_state' property and" - " return its state using the AlarmControlPanelState enum, please %s", - self.entity_id, - type(self), - report_issue, - ) + report_usage( + "is setting state directly." + f" Entity {self.entity_id} ({type(self)}) should implement the 'alarm_state'" + " property and return its state using the AlarmControlPanelState enum", + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2025.11", + integration_domain=self.platform.platform_name if self.platform else None, + exclude_integrations={DOMAIN}, + ) @final @property diff --git a/tests/components/alarm_control_panel/conftest.py b/tests/components/alarm_control_panel/conftest.py index 3e82b935493fc..ddf67b2786067 100644 --- a/tests/components/alarm_control_panel/conftest.py +++ b/tests/components/alarm_control_panel/conftest.py @@ -1,7 +1,7 @@ """Fixturs for Alarm Control Panel tests.""" -from collections.abc import Generator -from unittest.mock import MagicMock +from collections.abc import AsyncGenerator, Generator +from unittest.mock import MagicMock, patch import pytest @@ -13,7 +13,7 @@ from homeassistant.components.alarm_control_panel.const import CodeFormat from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, frame from homeassistant.helpers.entity_platform import AddEntitiesCallback from .common import MockAlarm @@ -107,6 +107,22 @@ class MockFlow(ConfigFlow): """Test flow.""" +@pytest.fixture(name="mock_as_custom_component") +async def mock_frame(hass: HomeAssistant) -> AsyncGenerator[None]: + """Mock frame.""" + with patch( + "homeassistant.helpers.frame.get_integration_frame", + return_value=frame.IntegrationFrame( + custom_integration=True, + integration="alarm_control_panel", + module="test_init.py", + relative_filename="test_init.py", + frame=frame.get_current_frame(), + ), + ): + yield + + @pytest.fixture(autouse=True) def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 89a2a2a2b1af2..1523a884b889e 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -25,7 +25,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, frame from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, UndefinedType @@ -297,6 +297,7 @@ async def test_alarm_control_panel_with_default_code( mock_alarm_control_panel_entity.calls_disarm.assert_called_with("1234") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_not_log_deprecated_state_warning( hass: HomeAssistant, mock_alarm_control_panel_entity: MockAlarmControlPanel, @@ -305,9 +306,14 @@ async def test_alarm_control_panel_not_log_deprecated_state_warning( """Test correctly using alarm_state doesn't log issue or raise repair.""" state = hass.states.get(mock_alarm_control_panel_entity.entity_id) assert state is not None - assert "Entities should implement the 'alarm_state' property and" not in caplog.text + assert ( + "the 'alarm_state' property and return its state using the AlarmControlPanelState enum" + not in caplog.text + ) +@pytest.mark.usefixtures("mock_as_custom_component") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop( hass: HomeAssistant, code_format: CodeFormat | None, @@ -332,6 +338,7 @@ async def async_setup_entry_init( TEST_DOMAIN, async_setup_entry=async_setup_entry_init, ), + built_in=False, ) class MockLegacyAlarmControlPanel(MockAlarmControlPanel): @@ -373,22 +380,26 @@ async def async_setup_entry_platform( MockPlatform(async_setup_entry=async_setup_entry_platform), ) - with patch.object( - MockLegacyAlarmControlPanel, - "__module__", - "tests.custom_components.test.alarm_control_panel", - ): - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() state = hass.states.get(entity.entity_id) assert state is not None - assert "Entities should implement the 'alarm_state' property and" in caplog.text + assert ( + "Detected that custom integration 'alarm_control_panel' is setting state directly." + " Entity None (.MockLegacyAlarmControlPanel'>)" + " should implement the 'alarm_state' property and return its state using the AlarmControlPanelState enum" + " at test_init.py, line 123: yield. This will stop working in Home Assistant 2025.11, please create a bug report at" + in caplog.text + ) +@pytest.mark.usefixtures("mock_as_custom_component") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state_attr( hass: HomeAssistant, code_format: CodeFormat | None, @@ -453,44 +464,45 @@ async def async_setup_entry_platform( MockPlatform(async_setup_entry=async_setup_entry_platform), ) - with patch.object( - MockLegacyAlarmControlPanel, - "__module__", - "tests.custom_components.test.alarm_control_panel", - ): - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() state = hass.states.get(entity.entity_id) assert state is not None - assert "Entities should implement the 'alarm_state' property and" not in caplog.text + assert ( + "Detected that custom integration 'alarm_control_panel' is setting state directly." + not in caplog.text + ) - with patch.object( - MockLegacyAlarmControlPanel, - "__module__", - "tests.custom_components.test.alarm_control_panel", - ): - await help_test_async_alarm_control_panel_service( - hass, entity.entity_id, SERVICE_ALARM_DISARM - ) + await help_test_async_alarm_control_panel_service( + hass, entity.entity_id, SERVICE_ALARM_DISARM + ) - assert "Entities should implement the 'alarm_state' property and" in caplog.text + assert ( + "Detected that custom integration 'alarm_control_panel' is setting state directly." + " Entity alarm_control_panel.test_alarm_control_panel" + " (.MockLegacyAlarmControlPanel'>)" + " should implement the 'alarm_state' property and return its state using the AlarmControlPanelState enum" + " at test_init.py, line 123: yield. This will stop working in Home Assistant 2025.11, please create a bug report at" + in caplog.text + ) caplog.clear() - with patch.object( - MockLegacyAlarmControlPanel, - "__module__", - "tests.custom_components.test.alarm_control_panel", - ): - await help_test_async_alarm_control_panel_service( - hass, entity.entity_id, SERVICE_ALARM_DISARM - ) + await help_test_async_alarm_control_panel_service( + hass, entity.entity_id, SERVICE_ALARM_DISARM + ) # Test we only log once - assert "Entities should implement the 'alarm_state' property and" not in caplog.text + assert ( + "Detected that custom integration 'alarm_control_panel' is setting state directly." + not in caplog.text + ) +@pytest.mark.usefixtures("mock_as_custom_component") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_deprecated_state_does_not_break_state( hass: HomeAssistant, code_format: CodeFormat | None, @@ -556,28 +568,18 @@ async def async_setup_entry_platform( MockPlatform(async_setup_entry=async_setup_entry_platform), ) - with patch.object( - MockLegacyAlarmControlPanel, - "__module__", - "tests.custom_components.test.alarm_control_panel", - ): - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() state = hass.states.get(entity.entity_id) assert state is not None assert state.state == "armed_away" - with patch.object( - MockLegacyAlarmControlPanel, - "__module__", - "tests.custom_components.test.alarm_control_panel", - ): - await help_test_async_alarm_control_panel_service( - hass, entity.entity_id, SERVICE_ALARM_DISARM - ) + await help_test_async_alarm_control_panel_service( + hass, entity.entity_id, SERVICE_ALARM_DISARM + ) state = hass.states.get(entity.entity_id) assert state is not None From 33222436d2d3e3ca9503c42fd6e981e717d8fdc1 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 27 Nov 2024 03:18:02 -0500 Subject: [PATCH 0940/1070] Nested stop actions will now return response_variables (#126393) fix-nested-stop-variable-response --- homeassistant/helpers/script.py | 2 - tests/helpers/test_script.py | 85 +++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 86dcd858c1bc4..2ddfd2fe3130a 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -477,8 +477,6 @@ async def async_run(self) -> ScriptRunResult | None: # Let the _StopScript bubble up if this is a sub-script if not self._script.top_level: - # We already consumed the response, do not pass it on - err.response = None raise except Exception: script_execution_set("error") diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index f67519905a15c..c438e333ae630 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -5632,6 +5632,91 @@ async def test_stop_action_subscript( ) +@pytest.mark.parametrize( + ("var", "response"), + [(1, "If: Then"), (2, "Testing 123")], +) +async def test_stop_action_response_variables( + hass: HomeAssistant, + var: int, + response: str, +) -> None: + """Test setting stop response_variable in a subscript.""" + sequence = cv.SCRIPT_SCHEMA( + [ + {"variables": {"output": {"value": "Testing 123"}}}, + { + "if": { + "condition": "template", + "value_template": "{{ var == 1 }}", + }, + "then": [ + {"variables": {"output": {"value": "If: Then"}}}, + {"stop": "In the name of love", "response_variable": "output"}, + ], + }, + {"stop": "In the name of love", "response_variable": "output"}, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + run_vars = MappingProxyType({"var": var}) + result = await script_obj.async_run(run_vars, context=Context()) + assert result.service_response == {"value": response} + + +@pytest.mark.parametrize( + ("var", "if_result", "choice", "response"), + [(1, True, "then", "If: Then"), (2, False, "else", "If: Else")], +) +async def test_stop_action_nested_response_variables( + hass: HomeAssistant, + var: int, + if_result: bool, + choice: str, + response: str, +) -> None: + """Test setting stop response_variable in a subscript.""" + sequence = cv.SCRIPT_SCHEMA( + [ + {"variables": {"output": {"value": "Testing 123"}}}, + { + "if": { + "condition": "template", + "value_template": "{{ var == 1 }}", + }, + "then": [ + {"variables": {"output": {"value": "If: Then"}}}, + {"stop": "In the name of love", "response_variable": "output"}, + ], + "else": [ + {"variables": {"output": {"value": "If: Else"}}}, + {"stop": "In the name of love", "response_variable": "output"}, + ], + }, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + run_vars = MappingProxyType({"var": var}) + result = await script_obj.async_run(run_vars, context=Context()) + assert result.service_response == {"value": response} + + expected_trace = { + "0": [ + { + "variables": {"var": var, "output": {"value": "Testing 123"}}, + } + ], + "1": [{"result": {"choice": choice}}], + "1/if": [{"result": {"result": if_result}}], + "1/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], + f"1/{choice}/0": [{"variables": {"output": {"value": response}}}], + f"1/{choice}/1": [{"result": {"stop": "In the name of love", "error": False}}], + } + assert_action_trace(expected_trace) + + async def test_stop_action_with_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From 507bb4a6850ef93cbdae7a89db10af8aeb79c219 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 27 Nov 2024 09:26:19 +0100 Subject: [PATCH 0941/1070] Add data_description to devolo Home Network (#131511) --- homeassistant/components/devolo_home_network/strings.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 2996cea90cb3f..4b683b5d2faa1 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -6,11 +6,17 @@ "description": "[%key:common::config_flow::description::confirm_setup%]", "data": { "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network App on the device dashboard." } }, "reauth_confirm": { "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Password you protected the device with." } }, "zeroconf_confirm": { From 96eae1221cc8de5417464cd09a6e84c1c99ef2c9 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Wed, 27 Nov 2024 09:40:20 +0100 Subject: [PATCH 0942/1070] Fix bluesound_group attribute in bluesound integration (#130815) Co-authored-by: Robert Resch --- .../components/bluesound/media_player.py | 43 +++++++++------- .../components/bluesound/test_media_player.py | 49 +++++++++++++++++-- 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 97985a74300c5..38ef78fad3a7d 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -292,14 +292,6 @@ async def async_update_status(self) -> None: self._last_status_update = dt_util.utcnow() self._status = status - group_name = status.group_name - if group_name != self._group_name: - _LOGGER.debug("Group name change detected on device: %s", self.id) - self._group_name = group_name - - # rebuild ordered list of entity_ids that are in the group, master is first - self._group_list = self.rebuild_bluesound_group() - self.async_write_ha_state() except PlayerUnreachableError: self._attr_available = False @@ -323,6 +315,8 @@ async def update_sync_status(self) -> None: self._sync_status = sync_status + self._group_list = self.rebuild_bluesound_group() + if sync_status.master is not None: self._is_master = False master_id = f"{sync_status.master.ip}:{sync_status.master.port}" @@ -619,21 +613,32 @@ def extra_state_attributes(self) -> dict[str, Any] | None: def rebuild_bluesound_group(self) -> list[str]: """Rebuild the list of entities in speaker group.""" - if self._group_name is None: + if self.sync_status.master is None and self.sync_status.slaves is None: return [] - device_group = self._group_name.split("+") + player_entities: list[BluesoundPlayer] = self.hass.data[DATA_BLUESOUND] - sorted_entities: list[BluesoundPlayer] = sorted( - self.hass.data[DATA_BLUESOUND], - key=lambda entity: entity.is_master, - reverse=True, - ) - return [ - entity.sync_status.name - for entity in sorted_entities - if entity.bluesound_device_name in device_group + leader_sync_status: SyncStatus | None = None + if self.sync_status.master is None: + leader_sync_status = self.sync_status + else: + required_id = f"{self.sync_status.master.ip}:{self.sync_status.master.port}" + for x in player_entities: + if x.sync_status.id == required_id: + leader_sync_status = x.sync_status + break + + if leader_sync_status is None or leader_sync_status.slaves is None: + return [] + + follower_ids = [f"{x.ip}:{x.port}" for x in leader_sync_status.slaves] + follower_names = [ + x.sync_status.name + for x in player_entities + if x.sync_status.id in follower_ids ] + follower_names.insert(0, leader_sync_status.name) + return follower_names async def async_unjoin(self) -> None: """Unjoin the player from a group.""" diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index 0bf615de3da87..217225628f29d 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -325,17 +325,17 @@ async def test_attr_bluesound_group( setup_config_entry_secondary: None, player_mocks: PlayerMocks, ) -> None: - """Test the media player grouping.""" + """Test the media player grouping for leader.""" attr_bluesound_group = hass.states.get( "media_player.player_name1111" ).attributes.get("bluesound_group") assert attr_bluesound_group is None - updated_status = dataclasses.replace( - player_mocks.player_data.status_long_polling_mock.get(), - group_name="player-name1111+player-name2222", + updated_sync_status = dataclasses.replace( + player_mocks.player_data.sync_status_long_polling_mock.get(), + slaves=[PairedPlayer("2.2.2.2", 11000)], ) - player_mocks.player_data.status_long_polling_mock.set(updated_status) + player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status) # give the long polling loop a chance to update the state; this could be any async call await hass.async_block_till_done() @@ -347,6 +347,45 @@ async def test_attr_bluesound_group( assert attr_bluesound_group == ["player-name1111", "player-name2222"] +async def test_attr_bluesound_group_for_follower( + hass: HomeAssistant, + setup_config_entry: None, + setup_config_entry_secondary: None, + player_mocks: PlayerMocks, +) -> None: + """Test the media player grouping for follower.""" + attr_bluesound_group = hass.states.get( + "media_player.player_name2222" + ).attributes.get("bluesound_group") + assert attr_bluesound_group is None + + updated_sync_status = dataclasses.replace( + player_mocks.player_data.sync_status_long_polling_mock.get(), + slaves=[PairedPlayer("2.2.2.2", 11000)], + ) + player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status) + + # give the long polling loop a chance to update the state; this could be any async call + await hass.async_block_till_done() + + updated_sync_status = dataclasses.replace( + player_mocks.player_data_secondary.sync_status_long_polling_mock.get(), + master=PairedPlayer("1.1.1.1", 11000), + ) + player_mocks.player_data_secondary.sync_status_long_polling_mock.set( + updated_sync_status + ) + + # give the long polling loop a chance to update the state; this could be any async call + await hass.async_block_till_done() + + attr_bluesound_group = hass.states.get( + "media_player.player_name2222" + ).attributes.get("bluesound_group") + + assert attr_bluesound_group == ["player-name1111", "player-name2222"] + + async def test_volume_up_from_6_to_7( hass: HomeAssistant, setup_config_entry: None, From 56b4733e4aebf53970e63308a5474b7e02c90e51 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 27 Nov 2024 10:24:06 +0100 Subject: [PATCH 0943/1070] Clean up early assignment in script response (#131691) --- homeassistant/helpers/script.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 2ddfd2fe3130a..a67ef60c79953 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -473,11 +473,13 @@ async def async_run(self) -> ScriptRunResult | None: script_execution_set("aborted") except _StopScript as err: script_execution_set("finished", err.response) - response = err.response # Let the _StopScript bubble up if this is a sub-script if not self._script.top_level: raise + + response = err.response + except Exception: script_execution_set("error") raise From 345c1fe0b2205814d646f54469f6733013ab3084 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 27 Nov 2024 11:12:45 +0000 Subject: [PATCH 0944/1070] Have Utility Meter monitor Timezone changes in configuration (#131112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * listen to config changes for possible DST changes * Add test * check tz actually changed * Update tests/components/utility_meter/test_sensor.py Co-authored-by: Abílio Costa * Update tests/components/utility_meter/test_sensor.py Co-authored-by: Abílio Costa * Clean up comment --------- Co-authored-by: Abílio Costa Co-authored-by: Martin Hjelmare --- .../components/utility_meter/sensor.py | 23 +++++++++++++++++++ tests/components/utility_meter/test_sensor.py | 17 ++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 19ef3c1f3a88d..9c13aa1984af8 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -27,6 +27,7 @@ ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_UNIQUE_ID, + EVENT_CORE_CONFIG_UPDATE, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -404,6 +405,10 @@ def __init__( self._tariff = tariff self._tariff_entity = tariff_entity self._next_reset = None + self._current_tz = None + self._config_scheduler() + + def _config_scheduler(self): self.scheduler = ( CronSim( self._cron_pattern, @@ -565,6 +570,7 @@ async def _program_reset(self): self._next_reset, ) ) + self.async_write_ha_state() async def _async_reset_meter(self, event): """Reset the utility meter status.""" @@ -601,6 +607,10 @@ async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() + # track current timezone in case it changes + # and we need to reconfigure the scheduler + self._current_tz = self.hass.config.time_zone + await self._program_reset() self.async_on_remove( @@ -655,6 +665,19 @@ def async_source_tracking(event): self.async_on_remove(async_at_started(self.hass, async_source_tracking)) + async def async_track_time_zone(event): + """Reconfigure Scheduler after time zone changes.""" + + if self._current_tz != self.hass.config.time_zone: + self._current_tz = self.hass.config.time_zone + + self._config_scheduler() + await self._program_reset() + + self.async_on_remove( + self.hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, async_track_time_zone) + ) + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" if self._collecting: diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 0ab78739f7f8a..348afac57f7a2 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1764,6 +1764,23 @@ async def test_self_reset_hourly_dst2(hass: HomeAssistant) -> None: assert state.attributes.get("next_reset") == next_reset +async def test_tz_changes(hass: HomeAssistant) -> None: + """Test that a timezone change changes the scheduler.""" + + await hass.config.async_update(time_zone="Europe/Prague") + + await _test_self_reset( + hass, gen_config("daily"), "2024-10-26T23:59:00.000000+02:00" + ) + state = hass.states.get("sensor.energy_bill") + assert state.attributes.get("next_reset") == "2024-10-28T00:00:00+01:00" + + await hass.config.async_update(time_zone="Pacific/Fiji") + + state = hass.states.get("sensor.energy_bill") + assert state.attributes.get("next_reset") != "2024-10-28T00:00:00+01:00" + + async def test_self_reset_daily(hass: HomeAssistant) -> None: """Test daily reset of meter.""" await _test_self_reset( From 284fe17b1c62a3fcc3eeae7cba436d15d26667bc Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Wed, 27 Nov 2024 13:22:28 +0100 Subject: [PATCH 0945/1070] Add time and offset config to Swiss public transport connections (#120357) * add time and offset config for connections * split the config flow * fix arrival config * add time_mode data description * use delta as dict instead of string * simplify the config_flow * improve descriptions of config_flow * improve config flow * remove obsolete string * switch priority of the config options * improvements --- .../swiss_public_transport/__init__.py | 37 +++- .../swiss_public_transport/config_flow.py | 159 ++++++++++++--- .../swiss_public_transport/const.py | 8 + .../swiss_public_transport/coordinator.py | 12 +- .../swiss_public_transport/helper.py | 54 ++++- .../swiss_public_transport/strings.json | 37 +++- .../test_config_flow.py | 184 +++++++++++++++--- .../swiss_public_transport/test_init.py | 20 +- 8 files changed, 437 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index bceac6007a261..628f6e95c2abd 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -19,12 +19,22 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .const import CONF_DESTINATION, CONF_START, CONF_VIA, DOMAIN, PLACEHOLDERS +from .const import ( + CONF_DESTINATION, + CONF_START, + CONF_TIME_FIXED, + CONF_TIME_OFFSET, + CONF_TIME_STATION, + CONF_VIA, + DEFAULT_TIME_STATION, + DOMAIN, + PLACEHOLDERS, +) from .coordinator import ( SwissPublicTransportConfigEntry, SwissPublicTransportDataUpdateCoordinator, ) -from .helper import unique_id_from_config +from .helper import offset_opendata, unique_id_from_config from .services import setup_services _LOGGER = logging.getLogger(__name__) @@ -50,8 +60,19 @@ async def async_setup_entry( start = config[CONF_START] destination = config[CONF_DESTINATION] + time_offset: dict[str, int] | None = config.get(CONF_TIME_OFFSET) + session = async_get_clientsession(hass) - opendata = OpendataTransport(start, destination, session, via=config.get(CONF_VIA)) + opendata = OpendataTransport( + start, + destination, + session, + via=config.get(CONF_VIA), + time=config.get(CONF_TIME_FIXED), + isArrivalTime=config.get(CONF_TIME_STATION, DEFAULT_TIME_STATION) == "arrival", + ) + if time_offset: + offset_opendata(opendata, time_offset) try: await opendata.async_get_data() @@ -75,7 +96,7 @@ async def async_setup_entry( }, ) from e - coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata) + coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata, time_offset) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -96,7 +117,7 @@ async def async_migrate_entry( """Migrate config entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) - if config_entry.version > 2: + if config_entry.version > 3: # This means the user has downgraded from a future version return False @@ -131,9 +152,9 @@ async def async_migrate_entry( config_entry, unique_id=new_unique_id, minor_version=2 ) - if config_entry.version < 2: - # Via stations now available, which are not backwards compatible if used, changes unique id - hass.config_entries.async_update_entry(config_entry, version=2, minor_version=1) + if config_entry.version < 3: + # Via stations and time/offset settings now available, which are not backwards compatible if used, changes unique id + hass.config_entries.async_update_entry(config_entry, version=3, minor_version=1) _LOGGER.debug( "Migration to version %s.%s successful", diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index 74c6223f1d99a..58d674f0c266e 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -14,15 +14,35 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( + DurationSelector, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, TextSelector, TextSelectorConfig, TextSelectorType, + TimeSelector, ) -from .const import CONF_DESTINATION, CONF_START, CONF_VIA, DOMAIN, MAX_VIA, PLACEHOLDERS -from .helper import unique_id_from_config +from .const import ( + CONF_DESTINATION, + CONF_START, + CONF_TIME_FIXED, + CONF_TIME_MODE, + CONF_TIME_OFFSET, + CONF_TIME_STATION, + CONF_VIA, + DEFAULT_TIME_MODE, + DEFAULT_TIME_STATION, + DOMAIN, + IS_ARRIVAL_OPTIONS, + MAX_VIA, + PLACEHOLDERS, + TIME_MODE_OPTIONS, +) +from .helper import offset_opendata, unique_id_from_config -DATA_SCHEMA = vol.Schema( +USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_START): cv.string, vol.Optional(CONF_VIA): TextSelector( @@ -32,8 +52,25 @@ ), ), vol.Required(CONF_DESTINATION): cv.string, + vol.Optional(CONF_TIME_MODE, default=DEFAULT_TIME_MODE): SelectSelector( + SelectSelectorConfig( + options=TIME_MODE_OPTIONS, + mode=SelectSelectorMode.DROPDOWN, + translation_key="time_mode", + ), + ), + vol.Optional(CONF_TIME_STATION, default=DEFAULT_TIME_STATION): SelectSelector( + SelectSelectorConfig( + options=IS_ARRIVAL_OPTIONS, + mode=SelectSelectorMode.DROPDOWN, + translation_key="time_station", + ), + ), } ) +ADVANCED_TIME_DATA_SCHEMA = {vol.Optional(CONF_TIME_FIXED): TimeSelector()} +ADVANCED_TIME_OFFSET_DATA_SCHEMA = {vol.Optional(CONF_TIME_OFFSET): DurationSelector()} + _LOGGER = logging.getLogger(__name__) @@ -41,39 +78,33 @@ class SwissPublicTransportConfigFlow(ConfigFlow, domain=DOMAIN): """Swiss public transport config flow.""" - VERSION = 2 + VERSION = 3 MINOR_VERSION = 1 + user_input: dict[str, Any] + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Async user step to set up the connection.""" errors: dict[str, str] = {} if user_input is not None: - unique_id = unique_id_from_config(user_input) - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - if CONF_VIA in user_input and len(user_input[CONF_VIA]) > MAX_VIA: errors["base"] = "too_many_via_stations" else: - session = async_get_clientsession(self.hass) - opendata = OpendataTransport( - user_input[CONF_START], - user_input[CONF_DESTINATION], - session, - via=user_input.get(CONF_VIA), - ) - try: - await opendata.async_get_data() - except OpendataTransportConnectionError: - errors["base"] = "cannot_connect" - except OpendataTransportError: - errors["base"] = "bad_config" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error") - errors["base"] = "unknown" + err = await self.fetch_connections(user_input) + if err: + errors["base"] = err else: + self.user_input = user_input + if user_input[CONF_TIME_MODE] == "fixed": + return await self.async_step_time_fixed() + if user_input[CONF_TIME_MODE] == "offset": + return await self.async_step_time_offset() + + unique_id = unique_id_from_config(user_input) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() return self.async_create_entry( title=unique_id, data=user_input, @@ -81,7 +112,85 @@ async def async_step_user( return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + data_schema=USER_DATA_SCHEMA, + suggested_values=user_input, + ), errors=errors, description_placeholders=PLACEHOLDERS, ) + + async def async_step_time_fixed( + self, time_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Async time step to set up the connection.""" + return await self._async_step_time_mode( + CONF_TIME_FIXED, vol.Schema(ADVANCED_TIME_DATA_SCHEMA), time_input + ) + + async def async_step_time_offset( + self, time_offset_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Async time offset step to set up the connection.""" + return await self._async_step_time_mode( + CONF_TIME_OFFSET, + vol.Schema(ADVANCED_TIME_OFFSET_DATA_SCHEMA), + time_offset_input, + ) + + async def _async_step_time_mode( + self, + step_id: str, + time_mode_schema: vol.Schema, + time_mode_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Async time mode step to set up the connection.""" + errors: dict[str, str] = {} + if time_mode_input is not None: + unique_id = unique_id_from_config({**self.user_input, **time_mode_input}) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + err = await self.fetch_connections( + {**self.user_input, **time_mode_input}, + time_mode_input.get(CONF_TIME_OFFSET), + ) + if err: + errors["base"] = err + else: + return self.async_create_entry( + title=unique_id, + data={**self.user_input, **time_mode_input}, + ) + + return self.async_show_form( + step_id=step_id, + data_schema=time_mode_schema, + errors=errors, + description_placeholders=PLACEHOLDERS, + ) + + async def fetch_connections( + self, input: dict[str, Any], time_offset: dict[str, int] | None = None + ) -> str | None: + """Fetch the connections and advancedly return an error.""" + try: + session = async_get_clientsession(self.hass) + opendata = OpendataTransport( + input[CONF_START], + input[CONF_DESTINATION], + session, + via=input.get(CONF_VIA), + time=input.get(CONF_TIME_FIXED), + ) + if time_offset: + offset_opendata(opendata, time_offset) + await opendata.async_get_data() + except OpendataTransportConnectionError: + return "cannot_connect" + except OpendataTransportError: + return "bad_config" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + return "unknown" + return None diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py index c02f36f2f2513..10bfc0d03555b 100644 --- a/homeassistant/components/swiss_public_transport/const.py +++ b/homeassistant/components/swiss_public_transport/const.py @@ -7,13 +7,21 @@ CONF_DESTINATION: Final = "to" CONF_START: Final = "from" CONF_VIA: Final = "via" +CONF_TIME_STATION: Final = "time_station" +CONF_TIME_MODE: Final = "time_mode" +CONF_TIME_FIXED: Final = "time_fixed" +CONF_TIME_OFFSET: Final = "time_offset" DEFAULT_NAME = "Next Destination" DEFAULT_UPDATE_TIME = 90 +DEFAULT_TIME_STATION = "departure" +DEFAULT_TIME_MODE = "now" MAX_VIA = 5 CONNECTIONS_COUNT = 3 CONNECTIONS_MAX = 15 +IS_ARRIVAL_OPTIONS = ["departure", "arrival"] +TIME_MODE_OPTIONS = ["now", "fixed", "offset"] PLACEHOLDERS = { diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py index e6413e6f77269..59602e7b982e4 100644 --- a/homeassistant/components/swiss_public_transport/coordinator.py +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -19,6 +19,7 @@ from homeassistant.util.json import JsonValueType from .const import CONNECTIONS_COUNT, DEFAULT_UPDATE_TIME, DOMAIN +from .helper import offset_opendata _LOGGER = logging.getLogger(__name__) @@ -57,7 +58,12 @@ class SwissPublicTransportDataUpdateCoordinator( config_entry: SwissPublicTransportConfigEntry - def __init__(self, hass: HomeAssistant, opendata: OpendataTransport) -> None: + def __init__( + self, + hass: HomeAssistant, + opendata: OpendataTransport, + time_offset: dict[str, int] | None, + ) -> None: """Initialize the SwissPublicTransport data coordinator.""" super().__init__( hass, @@ -66,6 +72,7 @@ def __init__(self, hass: HomeAssistant, opendata: OpendataTransport) -> None: update_interval=timedelta(seconds=DEFAULT_UPDATE_TIME), ) self._opendata = opendata + self._time_offset = time_offset def remaining_time(self, departure) -> timedelta | None: """Calculate the remaining time for the departure.""" @@ -81,6 +88,9 @@ async def _async_update_data(self) -> list[DataConnection]: async def fetch_connections(self, limit: int) -> list[DataConnection]: """Fetch connections using the opendata api.""" self._opendata.limit = limit + if self._time_offset: + offset_opendata(self._opendata, self._time_offset) + try: await self._opendata.async_get_data() except OpendataTransportConnectionError as e: diff --git a/homeassistant/components/swiss_public_transport/helper.py b/homeassistant/components/swiss_public_transport/helper.py index af03f7ad193a2..704479b77d6b7 100644 --- a/homeassistant/components/swiss_public_transport/helper.py +++ b/homeassistant/components/swiss_public_transport/helper.py @@ -1,15 +1,59 @@ """Helper functions for swiss_public_transport.""" +from datetime import timedelta from types import MappingProxyType from typing import Any -from .const import CONF_DESTINATION, CONF_START, CONF_VIA +from opendata_transport import OpendataTransport + +import homeassistant.util.dt as dt_util + +from .const import ( + CONF_DESTINATION, + CONF_START, + CONF_TIME_FIXED, + CONF_TIME_OFFSET, + CONF_TIME_STATION, + CONF_VIA, + DEFAULT_TIME_STATION, +) + + +def offset_opendata(opendata: OpendataTransport, offset: dict[str, int]) -> None: + """In place offset the opendata connector.""" + + duration = timedelta(**offset) + if duration: + now_offset = dt_util.as_local(dt_util.now() + duration) + opendata.date = now_offset.date() + opendata.time = now_offset.time() + + +def dict_duration_to_str_duration( + d: dict[str, int], +) -> str: + """Build a string from a dict duration.""" + return f"{d['hours']:02d}:{d['minutes']:02d}:{d['seconds']:02d}" def unique_id_from_config(config: MappingProxyType[str, Any] | dict[str, Any]) -> str: """Build a unique id from a config entry.""" - return f"{config[CONF_START]} {config[CONF_DESTINATION]}" + ( - " via " + ", ".join(config[CONF_VIA]) - if CONF_VIA in config and len(config[CONF_VIA]) > 0 - else "" + return ( + f"{config[CONF_START]} {config[CONF_DESTINATION]}" + + ( + " via " + ", ".join(config[CONF_VIA]) + if CONF_VIA in config and len(config[CONF_VIA]) > 0 + else "" + ) + + ( + " arrival" + if config.get(CONF_TIME_STATION, DEFAULT_TIME_STATION) == "arrival" + else "" + ) + + (" at " + config[CONF_TIME_FIXED] if CONF_TIME_FIXED in config else "") + + ( + " in " + dict_duration_to_str_duration(config[CONF_TIME_OFFSET]) + if CONF_TIME_OFFSET in config + else "" + ) ) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index b3bfd9aea8ff0..91645b2fee4a4 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -17,10 +17,30 @@ "data": { "from": "Start station", "to": "End station", - "via": "List of up to 5 via stations" + "via": "List of up to 5 via stations", + "time_station": "Select the relevant station", + "time_mode": "Select a time mode" + }, + "data_description": { + "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.", + "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)." }, "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", "title": "Swiss Public Transport" + }, + "time_fixed": { + "data": { + "time_fixed": "Time of day" + }, + "description": "Please select the relevant time for the connection (e.g. 7:12:00 AM every morning).", + "title": "Swiss Public Transport" + }, + "time_offset": { + "data": { + "time_offset": "Time offset" + }, + "description": "Please select the relevant offset to add to the earliest possible connection (e.g. add +00:05:00 offset, taking into account the time to walk to the station)", + "title": "Swiss Public Transport" } } }, @@ -84,5 +104,20 @@ "config_entry_not_found": { "message": "Swiss public transport integration instance \"{target}\" not found." } + }, + "selector": { + "time_station": { + "options": { + "departure": "Show departure time from start station", + "arrival": "Show arrival time at end station" + } + }, + "time_mode": { + "options": { + "now": "Now", + "fixed": "At a fixed time of day", + "offset": "At an offset from now" + } + } } } diff --git a/tests/components/swiss_public_transport/test_config_flow.py b/tests/components/swiss_public_transport/test_config_flow.py index 027336e28a675..7c17b0d4c3066 100644 --- a/tests/components/swiss_public_transport/test_config_flow.py +++ b/tests/components/swiss_public_transport/test_config_flow.py @@ -12,6 +12,10 @@ from homeassistant.components.swiss_public_transport.const import ( CONF_DESTINATION, CONF_START, + CONF_TIME_FIXED, + CONF_TIME_MODE, + CONF_TIME_OFFSET, + CONF_TIME_STATION, CONF_VIA, MAX_VIA, ) @@ -23,40 +27,86 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") -MOCK_DATA_STEP = { +MOCK_USER_DATA_STEP = { CONF_START: "test_start", CONF_DESTINATION: "test_destination", + CONF_TIME_STATION: "departure", + CONF_TIME_MODE: "now", } -MOCK_DATA_STEP_ONE_VIA = { - **MOCK_DATA_STEP, +MOCK_USER_DATA_STEP_ONE_VIA = { + **MOCK_USER_DATA_STEP, CONF_VIA: ["via_station"], } -MOCK_DATA_STEP_MANY_VIA = { - **MOCK_DATA_STEP, +MOCK_USER_DATA_STEP_MANY_VIA = { + **MOCK_USER_DATA_STEP, CONF_VIA: ["via_station_1", "via_station_2", "via_station_3"], } -MOCK_DATA_STEP_TOO_MANY_STATIONS = { - **MOCK_DATA_STEP, - CONF_VIA: MOCK_DATA_STEP_ONE_VIA[CONF_VIA] * (MAX_VIA + 1), +MOCK_USER_DATA_STEP_TOO_MANY_STATIONS = { + **MOCK_USER_DATA_STEP, + CONF_VIA: MOCK_USER_DATA_STEP_ONE_VIA[CONF_VIA] * (MAX_VIA + 1), +} + +MOCK_USER_DATA_STEP_ARRIVAL = { + **MOCK_USER_DATA_STEP, + CONF_TIME_STATION: "arrival", +} + +MOCK_USER_DATA_STEP_TIME_FIXED = { + **MOCK_USER_DATA_STEP, + CONF_TIME_MODE: "fixed", +} + +MOCK_USER_DATA_STEP_TIME_FIXED_OFFSET = { + **MOCK_USER_DATA_STEP, + CONF_TIME_MODE: "offset", +} + +MOCK_USER_DATA_STEP_BAD = { + **MOCK_USER_DATA_STEP, + CONF_TIME_MODE: "bad", +} + +MOCK_ADVANCED_DATA_STEP_TIME = { + CONF_TIME_FIXED: "18:03:00", +} + +MOCK_ADVANCED_DATA_STEP_TIME_OFFSET = { + CONF_TIME_OFFSET: {"hours": 0, "minutes": 10, "seconds": 0}, } @pytest.mark.parametrize( - ("user_input", "config_title"), + ("user_input", "time_mode_input", "config_title"), [ - (MOCK_DATA_STEP, "test_start test_destination"), - (MOCK_DATA_STEP_ONE_VIA, "test_start test_destination via via_station"), + (MOCK_USER_DATA_STEP, None, "test_start test_destination"), ( - MOCK_DATA_STEP_MANY_VIA, + MOCK_USER_DATA_STEP_ONE_VIA, + None, + "test_start test_destination via via_station", + ), + ( + MOCK_USER_DATA_STEP_MANY_VIA, + None, "test_start test_destination via via_station_1, via_station_2, via_station_3", ), + (MOCK_USER_DATA_STEP_ARRIVAL, None, "test_start test_destination arrival"), + ( + MOCK_USER_DATA_STEP_TIME_FIXED, + MOCK_ADVANCED_DATA_STEP_TIME, + "test_start test_destination at 18:03:00", + ), + ( + MOCK_USER_DATA_STEP_TIME_FIXED_OFFSET, + MOCK_ADVANCED_DATA_STEP_TIME_OFFSET, + "test_start test_destination in 00:10:00", + ), ], ) async def test_flow_user_init_data_success( - hass: HomeAssistant, user_input, config_title + hass: HomeAssistant, user_input, time_mode_input, config_title ) -> None: """Test success response.""" result = await hass.config_entries.flow.async_init( @@ -66,48 +116,56 @@ async def test_flow_user_init_data_success( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["handler"] == "swiss_public_transport" - assert result["data_schema"] == config_flow.DATA_SCHEMA + assert result["data_schema"] == config_flow.USER_DATA_SCHEMA with patch( "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", autospec=True, return_value=True, ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=user_input, ) + if time_mode_input: + assert result["type"] == FlowResultType.FORM + if CONF_TIME_FIXED in time_mode_input: + assert result["step_id"] == "time_fixed" + if CONF_TIME_OFFSET in time_mode_input: + assert result["step_id"] == "time_offset" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=time_mode_input, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["result"].title == config_title - assert result["data"] == user_input + assert result["data"] == {**user_input, **(time_mode_input or {})} @pytest.mark.parametrize( ("raise_error", "text_error", "user_input_error"), [ - (OpendataTransportConnectionError(), "cannot_connect", MOCK_DATA_STEP), - (OpendataTransportError(), "bad_config", MOCK_DATA_STEP), - (None, "too_many_via_stations", MOCK_DATA_STEP_TOO_MANY_STATIONS), - (IndexError(), "unknown", MOCK_DATA_STEP), + (OpendataTransportConnectionError(), "cannot_connect", MOCK_USER_DATA_STEP), + (OpendataTransportError(), "bad_config", MOCK_USER_DATA_STEP), + (None, "too_many_via_stations", MOCK_USER_DATA_STEP_TOO_MANY_STATIONS), + (IndexError(), "unknown", MOCK_USER_DATA_STEP), ], ) -async def test_flow_user_init_data_error_and_recover( +async def test_flow_user_init_data_error_and_recover_on_step_1( hass: HomeAssistant, raise_error, text_error, user_input_error ) -> None: - """Test unknown errors.""" + """Test errors in user step.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) with patch( "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", autospec=True, side_effect=raise_error, ) as mock_OpendataTransport: - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=user_input_error, @@ -121,13 +179,75 @@ async def test_flow_user_init_data_error_and_recover( mock_OpendataTransport.return_value = True result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=MOCK_DATA_STEP, + user_input=MOCK_USER_DATA_STEP, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["result"].title == "test_start test_destination" - assert result["data"] == MOCK_DATA_STEP + assert result["data"] == MOCK_USER_DATA_STEP + + +@pytest.mark.parametrize( + ("raise_error", "text_error", "user_input"), + [ + ( + OpendataTransportConnectionError(), + "cannot_connect", + MOCK_ADVANCED_DATA_STEP_TIME, + ), + (OpendataTransportError(), "bad_config", MOCK_ADVANCED_DATA_STEP_TIME), + (IndexError(), "unknown", MOCK_ADVANCED_DATA_STEP_TIME), + ], +) +async def test_flow_user_init_data_error_and_recover_on_step_2( + hass: HomeAssistant, raise_error, text_error, user_input +) -> None: + """Test errors in time mode step.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["handler"] == "swiss_public_transport" + assert result["data_schema"] == config_flow.USER_DATA_SCHEMA + + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_DATA_STEP_TIME_FIXED, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "time_fixed" + + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + side_effect=raise_error, + ) as mock_OpendataTransport: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == text_error + + # Recover + mock_OpendataTransport.side_effect = None + mock_OpendataTransport.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].title == "test_start test_destination at 18:03:00" async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> None: @@ -135,8 +255,8 @@ async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> No entry = MockConfigEntry( domain=config_flow.DOMAIN, - data=MOCK_DATA_STEP, - unique_id=unique_id_from_config(MOCK_DATA_STEP), + data=MOCK_USER_DATA_STEP, + unique_id=unique_id_from_config(MOCK_USER_DATA_STEP), ) entry.add_to_hass(hass) @@ -151,7 +271,7 @@ async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=MOCK_DATA_STEP, + user_input=MOCK_USER_DATA_STEP, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/swiss_public_transport/test_init.py b/tests/components/swiss_public_transport/test_init.py index 9ad4a8d50b083..963f5e6fa40a8 100644 --- a/tests/components/swiss_public_transport/test_init.py +++ b/tests/components/swiss_public_transport/test_init.py @@ -7,6 +7,9 @@ from homeassistant.components.swiss_public_transport.const import ( CONF_DESTINATION, CONF_START, + CONF_TIME_FIXED, + CONF_TIME_OFFSET, + CONF_TIME_STATION, CONF_VIA, DOMAIN, ) @@ -28,6 +31,17 @@ CONF_VIA: ["via_station"], } +MOCK_DATA_STEP_TIME_FIXED = { + **MOCK_DATA_STEP_VIA, + CONF_TIME_FIXED: "18:03:00", +} + +MOCK_DATA_STEP_TIME_OFFSET = { + **MOCK_DATA_STEP_VIA, + CONF_TIME_OFFSET: {"hours": 0, "minutes": 10, "seconds": 0}, + CONF_TIME_STATION: "arrival", +} + CONNECTIONS = [ { "departure": "2024-01-06T18:03:00+0100", @@ -70,6 +84,8 @@ (1, 1, MOCK_DATA_STEP_BASE, "None_departure"), (1, 2, MOCK_DATA_STEP_BASE, None), (2, 1, MOCK_DATA_STEP_VIA, None), + (3, 1, MOCK_DATA_STEP_TIME_FIXED, None), + (3, 1, MOCK_DATA_STEP_TIME_OFFSET, None), ], ) async def test_migration_from( @@ -113,7 +129,7 @@ async def test_migration_from( ) # Check change in config entry and verify most recent version - assert config_entry.version == 2 + assert config_entry.version == 3 assert config_entry.minor_version == 1 assert config_entry.unique_id == unique_id @@ -130,7 +146,7 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None: mock_entry = MockConfigEntry( domain=DOMAIN, - version=3, + version=4, minor_version=1, unique_id="some_crazy_future_unique_id", data=MOCK_DATA_STEP_BASE, From 3464ffc53eaaee6ace2ac9b6b970a4225e9fe6b9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 27 Nov 2024 13:26:57 +0100 Subject: [PATCH 0946/1070] Add open to Template lock (#129292) * Add open to Template lock * Update from review --- homeassistant/components/template/lock.py | 55 +++++++++++--- tests/components/template/test_lock.py | 89 +++++++++++++++++++++++ 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 6ea8aff4c1acf..d7bb30dbba0b8 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -2,13 +2,14 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.lock import ( PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, LockEntity, + LockEntityFeature, LockState, ) from homeassistant.const import ( @@ -36,6 +37,7 @@ CONF_CODE_FORMAT_TEMPLATE = "code_format_template" CONF_LOCK = "lock" CONF_UNLOCK = "unlock" +CONF_OPEN = "open" DEFAULT_NAME = "Template Lock" DEFAULT_OPTIMISTIC = False @@ -45,6 +47,7 @@ vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_CODE_FORMAT_TEMPLATE): cv.template, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, @@ -53,7 +56,9 @@ ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) -async def _async_create_entities(hass, config): +async def _async_create_entities( + hass: HomeAssistant, config: dict[str, Any] +) -> list[TemplateLock]: """Create the Template lock.""" config = rewrite_common_legacy_to_modern_conf(hass, config) return [TemplateLock(hass, config, config.get(CONF_UNIQUE_ID))] @@ -76,22 +81,26 @@ class TemplateLock(TemplateEntity, LockEntity): def __init__( self, - hass, - config, - unique_id, - ): + hass: HomeAssistant, + config: dict[str, Any], + unique_id: str | None, + ) -> None: """Initialize the lock.""" super().__init__( hass, config=config, fallback_name=DEFAULT_NAME, unique_id=unique_id ) - self._state = None + self._state: str | bool | LockState | None = None name = self._attr_name + assert name self._state_template = config.get(CONF_VALUE_TEMPLATE) self._command_lock = Script(hass, config[CONF_LOCK], name, DOMAIN) self._command_unlock = Script(hass, config[CONF_UNLOCK], name, DOMAIN) + if CONF_OPEN in config: + self._command_open = Script(hass, config[CONF_OPEN], name, DOMAIN) + self._attr_supported_features |= LockEntityFeature.OPEN self._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE) - self._code_format = None - self._code_format_template_error = None + self._code_format: str | None = None + self._code_format_template_error: TemplateError | None = None self._optimistic = config.get(CONF_OPTIMISTIC) self._attr_assumed_state = bool(self._optimistic) @@ -115,6 +124,11 @@ def is_locking(self) -> bool: """Return true if lock is locking.""" return self._state == LockState.LOCKING + @property + def is_open(self) -> bool: + """Return true if lock is open.""" + return self._state == LockState.OPEN + @callback def _update_state(self, result): """Update the state from the template.""" @@ -141,6 +155,8 @@ def code_format(self) -> str | None: @callback def _async_setup_templates(self) -> None: """Set up templates.""" + if TYPE_CHECKING: + assert self._state_template is not None self.add_template_attribute( "_state", self._state_template, None, self._update_state ) @@ -168,6 +184,8 @@ def _update_code_format(self, render: str | TemplateError | None): async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" + # Check if we need to raise for incorrect code format + # template before processing the action. self._raise_template_error_if_available() if self._optimistic: @@ -182,6 +200,8 @@ async def async_lock(self, **kwargs: Any) -> None: async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" + # Check if we need to raise for incorrect code format + # template before processing the action. self._raise_template_error_if_available() if self._optimistic: @@ -194,7 +214,24 @@ async def async_unlock(self, **kwargs: Any) -> None: self._command_unlock, run_variables=tpl_vars, context=self._context ) + async def async_open(self, **kwargs: Any) -> None: + """Open the device.""" + # Check if we need to raise for incorrect code format + # template before processing the action. + self._raise_template_error_if_available() + + if self._optimistic: + self._state = LockState.OPEN + self.async_write_ha_state() + + tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} + + await self.async_run_script( + self._command_open, run_variables=tpl_vars, context=self._context + ) + def _raise_template_error_if_available(self): + """Raise an error if the rendered code format is not valid.""" if self._code_format_template_error is not None: raise ServiceValidationError( translation_domain=DOMAIN, diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 186a84d536578..d9cb294c41f8d 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -10,6 +10,7 @@ ATTR_ENTITY_ID, STATE_OFF, STATE_ON, + STATE_OPEN, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant, ServiceCall @@ -30,6 +31,13 @@ "caller": "{{ this.entity_id }}", }, }, + "open": { + "service": "test.automation", + "data_template": { + "action": "open", + "caller": "{{ this.entity_id }}", + }, + }, } OPTIMISTIC_CODED_LOCK_CONFIG = { @@ -81,6 +89,53 @@ async def test_template_state(hass: HomeAssistant) -> None: state = hass.states.get("lock.test_template_lock") assert state.state == LockState.UNLOCKED + hass.states.async_set("switch.test_state", STATE_OPEN) + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.state == LockState.OPEN + + +@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + **OPTIMISTIC_LOCK_CONFIG, + "name": "Test lock", + "optimistic": True, + "value_template": "{{ states.switch.test_state.state }}", + } + }, + ], +) +@pytest.mark.usefixtures("start_ha") +async def test_open_lock_optimistic( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: + """Test optimistic open.""" + await setup.async_setup_component(hass, "switch", {}) + hass.states.async_set("switch.test_state", STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get("lock.test_lock") + assert state.state == LockState.LOCKED + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_OPEN, + {ATTR_ENTITY_ID: "lock.test_lock"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["action"] == "open" + assert calls[0].data["caller"] == "lock.test_lock" + + state = hass.states.get("lock.test_lock") + assert state.state == LockState.OPEN + @pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( @@ -282,6 +337,40 @@ async def test_unlock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> N assert calls[0].data["caller"] == "lock.template_lock" +@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + **OPTIMISTIC_LOCK_CONFIG, + "value_template": "{{ states.switch.test_state.state }}", + } + }, + ], +) +@pytest.mark.usefixtures("start_ha") +async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: + """Test open action.""" + await setup.async_setup_component(hass, "switch", {}) + hass.states.async_set("switch.test_state", STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get("lock.template_lock") + assert state.state == LockState.LOCKED + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_OPEN, + {ATTR_ENTITY_ID: "lock.template_lock"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["action"] == "open" + assert calls[0].data["caller"] == "lock.template_lock" + + @pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( "config", From 326f51a019555231ffa45eca8b2df7b881dfa3fd Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 27 Nov 2024 15:20:47 +0200 Subject: [PATCH 0947/1070] Bump aioshelly to 12.1.0 (#131714) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index c4a22a77739f5..3489a2d06d90f 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.0.1"], + "requirements": ["aioshelly==12.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index af34c0e797b25..f67df7eba5d1a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -369,7 +369,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.0.1 +aioshelly==12.1.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40d43ea825ebb..3249a1b148c3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -351,7 +351,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.0.1 +aioshelly==12.1.0 # homeassistant.components.skybell aioskybell==22.7.0 From 137db5ac7909aa5c87e6ef82f3ac7eaeb6d69d58 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 27 Nov 2024 14:45:37 +0100 Subject: [PATCH 0948/1070] Bump samsungtvws to 2.7.0 (#131690) --- homeassistant/components/samsungtv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index bc4ba90002804..d25501b356d82 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -37,7 +37,7 @@ "requirements": [ "getmac==0.9.4", "samsungctl[websocket]==0.7.1", - "samsungtvws[async,encrypted]==2.6.0", + "samsungtvws[async,encrypted]==2.7.0", "wakeonlan==2.1.0", "async-upnp-client==0.41.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index f67df7eba5d1a..912d33d3eddb0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2610,7 +2610,7 @@ rxv==0.7.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==2.6.0 +samsungtvws[async,encrypted]==2.7.0 # homeassistant.components.sanix sanix==1.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3249a1b148c3a..2741a351bde40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2086,7 +2086,7 @@ rxv==0.7.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==2.6.0 +samsungtvws[async,encrypted]==2.7.0 # homeassistant.components.sanix sanix==1.0.6 From d8dd6a99b331a03dd51234e9d689bedc368a8314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 27 Nov 2024 13:45:53 +0000 Subject: [PATCH 0949/1070] Use default translation on SensorEntity unit_of_measurement (#131633) * Use translations on SensorEntity unit_of_measurement property * Use default language for unit translation * Update brother integration snapshot * Update snapshots --- homeassistant/components/sensor/__init__.py | 20 ++++++++- homeassistant/helpers/entity_platform.py | 9 ++++ .../brother/snapshots/test_sensor.ambr | 42 +++++++++++------ .../mastodon/snapshots/test_sensor.ambr | 9 ++-- .../mealie/snapshots/test_sensor.ambr | 15 ++++--- .../nextdns/snapshots/test_sensor.ambr | 45 ++++++++++++------- tests/components/sensor/test_init.py | 34 ++++++++++++++ 7 files changed, 136 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 31626b0b76119..c01eead7e991e 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -504,6 +504,17 @@ def suggested_unit_of_measurement(self) -> str | None: return self.entity_description.suggested_unit_of_measurement return None + @cached_property + def _unit_of_measurement_translation_key(self) -> str | None: + """Return translation key for unit of measurement.""" + if self.translation_key is None: + return None + platform = self.platform + return ( + f"component.{platform.platform_name}.entity.{platform.domain}" + f".{self.translation_key}.unit_of_measurement" + ) + @final @property @override @@ -531,7 +542,14 @@ def unit_of_measurement(self) -> str | None: ): return self.hass.config.units.temperature_unit - # Fourth priority: Native unit + # Fourth priority: Unit translation + if (translation_key := self._unit_of_measurement_translation_key) and ( + unit_of_measurement + := self.platform.default_language_platform_translations.get(translation_key) + ): + return unit_of_measurement + + # Lowest priority: Native unit return native_unit_of_measurement @final diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 62eed213b2a82..0d7614c569c68 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -145,6 +145,7 @@ def __init__( self.platform_translations: dict[str, str] = {} self.object_id_component_translations: dict[str, str] = {} self.object_id_platform_translations: dict[str, str] = {} + self.default_language_platform_translations: dict[str, str] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -480,6 +481,14 @@ async def async_load_translations(self) -> None: self.object_id_platform_translations = await self._async_get_translations( object_id_language, "entity", self.platform_name ) + if config_language == languages.DEFAULT_LANGUAGE: + self.default_language_platform_translations = self.platform_translations + else: + self.default_language_platform_translations = ( + await self._async_get_translations( + languages.DEFAULT_LANGUAGE, "entity", self.platform_name + ) + ) def _schedule_add_entities( self, new_entities: Iterable[Entity], update_before_add: bool = False diff --git a/tests/components/brother/snapshots/test_sensor.ambr b/tests/components/brother/snapshots/test_sensor.ambr index a313e013f4b0a..4de85859461f6 100644 --- a/tests/components/brother/snapshots/test_sensor.ambr +++ b/tests/components/brother/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'supported_features': 0, 'translation_key': 'bw_pages', 'unique_id': '0123456789_bw_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_b_w_pages-state] @@ -39,6 +39,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW B/W pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_b_w_pages', @@ -130,7 +131,7 @@ 'supported_features': 0, 'translation_key': 'black_drum_page_counter', 'unique_id': '0123456789_black_drum_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_black_drum_page_counter-state] @@ -138,6 +139,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Black drum page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_black_drum_page_counter', @@ -229,7 +231,7 @@ 'supported_features': 0, 'translation_key': 'black_drum_remaining_pages', 'unique_id': '0123456789_black_drum_remaining_pages', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_black_drum_remaining_pages-state] @@ -237,6 +239,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Black drum remaining pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_black_drum_remaining_pages', @@ -328,7 +331,7 @@ 'supported_features': 0, 'translation_key': 'color_pages', 'unique_id': '0123456789_color_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_color_pages-state] @@ -336,6 +339,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Color pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_color_pages', @@ -377,7 +381,7 @@ 'supported_features': 0, 'translation_key': 'cyan_drum_page_counter', 'unique_id': '0123456789_cyan_drum_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_cyan_drum_page_counter-state] @@ -385,6 +389,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Cyan drum page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_cyan_drum_page_counter', @@ -476,7 +481,7 @@ 'supported_features': 0, 'translation_key': 'cyan_drum_remaining_pages', 'unique_id': '0123456789_cyan_drum_remaining_pages', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_cyan_drum_remaining_pages-state] @@ -484,6 +489,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Cyan drum remaining pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_cyan_drum_remaining_pages', @@ -575,7 +581,7 @@ 'supported_features': 0, 'translation_key': 'drum_page_counter', 'unique_id': '0123456789_drum_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_drum_page_counter-state] @@ -583,6 +589,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Drum page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_drum_page_counter', @@ -674,7 +681,7 @@ 'supported_features': 0, 'translation_key': 'drum_remaining_pages', 'unique_id': '0123456789_drum_remaining_pages', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_drum_remaining_pages-state] @@ -682,6 +689,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Drum remaining pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_drum_remaining_pages', @@ -723,7 +731,7 @@ 'supported_features': 0, 'translation_key': 'duplex_unit_page_counter', 'unique_id': '0123456789_duplex_unit_pages_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_duplex_unit_page_counter-state] @@ -731,6 +739,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Duplex unit page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_duplex_unit_page_counter', @@ -869,7 +878,7 @@ 'supported_features': 0, 'translation_key': 'magenta_drum_page_counter', 'unique_id': '0123456789_magenta_drum_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_magenta_drum_page_counter-state] @@ -877,6 +886,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Magenta drum page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_magenta_drum_page_counter', @@ -968,7 +978,7 @@ 'supported_features': 0, 'translation_key': 'magenta_drum_remaining_pages', 'unique_id': '0123456789_magenta_drum_remaining_pages', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_magenta_drum_remaining_pages-state] @@ -976,6 +986,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Magenta drum remaining pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_magenta_drum_remaining_pages', @@ -1067,7 +1078,7 @@ 'supported_features': 0, 'translation_key': 'page_counter', 'unique_id': '0123456789_page_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_page_counter-state] @@ -1075,6 +1086,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_page_counter', @@ -1212,7 +1224,7 @@ 'supported_features': 0, 'translation_key': 'yellow_drum_page_counter', 'unique_id': '0123456789_yellow_drum_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_yellow_drum_page_counter-state] @@ -1220,6 +1232,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Yellow drum page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_yellow_drum_page_counter', @@ -1311,7 +1324,7 @@ 'supported_features': 0, 'translation_key': 'yellow_drum_remaining_pages', 'unique_id': '0123456789_yellow_drum_remaining_pages', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_yellow_drum_remaining_pages-state] @@ -1319,6 +1332,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Yellow drum remaining pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_yellow_drum_remaining_pages', diff --git a/tests/components/mastodon/snapshots/test_sensor.ambr b/tests/components/mastodon/snapshots/test_sensor.ambr index 3e6e41796f6fa..c8df8cdab1977 100644 --- a/tests/components/mastodon/snapshots/test_sensor.ambr +++ b/tests/components/mastodon/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'supported_features': 0, 'translation_key': 'followers', 'unique_id': 'trwnh_mastodon_social_followers', - 'unit_of_measurement': None, + 'unit_of_measurement': 'accounts', }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_followers-state] @@ -39,6 +39,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mastodon @trwnh@mastodon.social Followers', 'state_class': , + 'unit_of_measurement': 'accounts', }), 'context': , 'entity_id': 'sensor.mastodon_trwnh_mastodon_social_followers', @@ -80,7 +81,7 @@ 'supported_features': 0, 'translation_key': 'following', 'unique_id': 'trwnh_mastodon_social_following', - 'unit_of_measurement': None, + 'unit_of_measurement': 'accounts', }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_following-state] @@ -88,6 +89,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mastodon @trwnh@mastodon.social Following', 'state_class': , + 'unit_of_measurement': 'accounts', }), 'context': , 'entity_id': 'sensor.mastodon_trwnh_mastodon_social_following', @@ -129,7 +131,7 @@ 'supported_features': 0, 'translation_key': 'posts', 'unique_id': 'trwnh_mastodon_social_posts', - 'unit_of_measurement': None, + 'unit_of_measurement': 'posts', }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_posts-state] @@ -137,6 +139,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mastodon @trwnh@mastodon.social Posts', 'state_class': , + 'unit_of_measurement': 'posts', }), 'context': , 'entity_id': 'sensor.mastodon_trwnh_mastodon_social_posts', diff --git a/tests/components/mealie/snapshots/test_sensor.ambr b/tests/components/mealie/snapshots/test_sensor.ambr index d52ffc9a79a4e..e645cf4c45f3e 100644 --- a/tests/components/mealie/snapshots/test_sensor.ambr +++ b/tests/components/mealie/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'supported_features': 0, 'translation_key': 'categories', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_categories', - 'unit_of_measurement': None, + 'unit_of_measurement': 'categories', }) # --- # name: test_entities[sensor.mealie_categories-state] @@ -39,6 +39,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Categories', 'state_class': , + 'unit_of_measurement': 'categories', }), 'context': , 'entity_id': 'sensor.mealie_categories', @@ -80,7 +81,7 @@ 'supported_features': 0, 'translation_key': 'recipes', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_recipes', - 'unit_of_measurement': None, + 'unit_of_measurement': 'recipes', }) # --- # name: test_entities[sensor.mealie_recipes-state] @@ -88,6 +89,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Recipes', 'state_class': , + 'unit_of_measurement': 'recipes', }), 'context': , 'entity_id': 'sensor.mealie_recipes', @@ -129,7 +131,7 @@ 'supported_features': 0, 'translation_key': 'tags', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_tags', - 'unit_of_measurement': None, + 'unit_of_measurement': 'tags', }) # --- # name: test_entities[sensor.mealie_tags-state] @@ -137,6 +139,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Tags', 'state_class': , + 'unit_of_measurement': 'tags', }), 'context': , 'entity_id': 'sensor.mealie_tags', @@ -178,7 +181,7 @@ 'supported_features': 0, 'translation_key': 'tools', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_tools', - 'unit_of_measurement': None, + 'unit_of_measurement': 'tools', }) # --- # name: test_entities[sensor.mealie_tools-state] @@ -186,6 +189,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Tools', 'state_class': , + 'unit_of_measurement': 'tools', }), 'context': , 'entity_id': 'sensor.mealie_tools', @@ -227,7 +231,7 @@ 'supported_features': 0, 'translation_key': 'users', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_users', - 'unit_of_measurement': None, + 'unit_of_measurement': 'users', }) # --- # name: test_entities[sensor.mealie_users-state] @@ -235,6 +239,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Users', 'state_class': , + 'unit_of_measurement': 'users', }), 'context': , 'entity_id': 'sensor.mealie_users', diff --git a/tests/components/nextdns/snapshots/test_sensor.ambr b/tests/components/nextdns/snapshots/test_sensor.ambr index a1ff0941e3fb1..14bebea53f866 100644 --- a/tests/components/nextdns/snapshots/test_sensor.ambr +++ b/tests/components/nextdns/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'supported_features': 0, 'translation_key': 'doh3_queries', 'unique_id': 'xyz12_doh3_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_over_http_3_queries-state] @@ -39,6 +39,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS-over-HTTP/3 queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_over_http_3_queries', @@ -130,7 +131,7 @@ 'supported_features': 0, 'translation_key': 'doh_queries', 'unique_id': 'xyz12_doh_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_over_https_queries-state] @@ -138,6 +139,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS-over-HTTPS queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_over_https_queries', @@ -229,7 +231,7 @@ 'supported_features': 0, 'translation_key': 'doq_queries', 'unique_id': 'xyz12_doq_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_over_quic_queries-state] @@ -237,6 +239,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS-over-QUIC queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_over_quic_queries', @@ -328,7 +331,7 @@ 'supported_features': 0, 'translation_key': 'dot_queries', 'unique_id': 'xyz12_dot_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_over_tls_queries-state] @@ -336,6 +339,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS-over-TLS queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_over_tls_queries', @@ -427,7 +431,7 @@ 'supported_features': 0, 'translation_key': 'all_queries', 'unique_id': 'xyz12_all_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_queries-state] @@ -435,6 +439,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_queries', @@ -476,7 +481,7 @@ 'supported_features': 0, 'translation_key': 'blocked_queries', 'unique_id': 'xyz12_blocked_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_queries_blocked-state] @@ -484,6 +489,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS queries blocked', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_queries_blocked', @@ -575,7 +581,7 @@ 'supported_features': 0, 'translation_key': 'relayed_queries', 'unique_id': 'xyz12_relayed_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_queries_relayed-state] @@ -583,6 +589,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS queries relayed', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_queries_relayed', @@ -624,7 +631,7 @@ 'supported_features': 0, 'translation_key': 'not_validated_queries', 'unique_id': 'xyz12_not_validated_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dnssec_not_validated_queries-state] @@ -632,6 +639,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNSSEC not validated queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dnssec_not_validated_queries', @@ -673,7 +681,7 @@ 'supported_features': 0, 'translation_key': 'validated_queries', 'unique_id': 'xyz12_validated_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dnssec_validated_queries-state] @@ -681,6 +689,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNSSEC validated queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dnssec_validated_queries', @@ -772,7 +781,7 @@ 'supported_features': 0, 'translation_key': 'encrypted_queries', 'unique_id': 'xyz12_encrypted_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_encrypted_queries-state] @@ -780,6 +789,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile Encrypted queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_encrypted_queries', @@ -871,7 +881,7 @@ 'supported_features': 0, 'translation_key': 'ipv4_queries', 'unique_id': 'xyz12_ipv4_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_ipv4_queries-state] @@ -879,6 +889,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile IPv4 queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_ipv4_queries', @@ -920,7 +931,7 @@ 'supported_features': 0, 'translation_key': 'ipv6_queries', 'unique_id': 'xyz12_ipv6_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_ipv6_queries-state] @@ -928,6 +939,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile IPv6 queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_ipv6_queries', @@ -1019,7 +1031,7 @@ 'supported_features': 0, 'translation_key': 'tcp_queries', 'unique_id': 'xyz12_tcp_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_tcp_queries-state] @@ -1027,6 +1039,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile TCP queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_tcp_queries', @@ -1118,7 +1131,7 @@ 'supported_features': 0, 'translation_key': 'udp_queries', 'unique_id': 'xyz12_udp_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_udp_queries-state] @@ -1126,6 +1139,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile UDP queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_udp_queries', @@ -1217,7 +1231,7 @@ 'supported_features': 0, 'translation_key': 'unencrypted_queries', 'unique_id': 'xyz12_unencrypted_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_unencrypted_queries-state] @@ -1225,6 +1239,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile Unencrypted queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_unencrypted_queries', diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 3893a089b8127..48c8465c7dee7 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -7,6 +7,7 @@ from decimal import Decimal from types import ModuleType from typing import Any +from unittest.mock import patch import pytest @@ -484,6 +485,39 @@ async def test_restore_sensor_restore_state( assert entity0.native_unit_of_measurement == uom +async def test_translated_unit( + hass: HomeAssistant, +) -> None: + """Test translated unit.""" + + with patch( + "homeassistant.helpers.service.translation.async_get_translations", + return_value={ + "component.test.entity.sensor.test_translation_key.unit_of_measurement": "Tests" + }, + ): + entity0 = MockSensor( + name="Test", + native_value="123", + unique_id="very_unique", + ) + entity0.entity_description = SensorEntityDescription( + "test", + translation_key="test_translation_key", + native_unit_of_measurement="ignored_unit", + ) + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + + assert await async_setup_component( + hass, "sensor", {"sensor": {"platform": "test"}} + ) + await hass.async_block_till_done() + + entity_id = entity0.entity_id + state = hass.states.get(entity_id) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "Tests" + + @pytest.mark.parametrize( ( "device_class", From 88feb8a7ad88ff080a887dad8ce0e993a100c534 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 27 Nov 2024 14:47:17 +0100 Subject: [PATCH 0950/1070] Fix ADS platform schema (#131701) --- homeassistant/components/ads/cover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index 541f8bfc82c2a..c7b0f4f2f8ac4 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -37,7 +37,7 @@ PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_ADS_VAR): cv.string, + vol.Required(CONF_ADS_VAR): cv.string, vol.Optional(CONF_ADS_VAR_POSITION): cv.string, vol.Optional(CONF_ADS_VAR_SET_POS): cv.string, vol.Optional(CONF_ADS_VAR_CLOSE): cv.string, From a7db35c76cbffbc42a2af65e6ec9c2fb57089fae Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 27 Nov 2024 15:06:46 +0100 Subject: [PATCH 0951/1070] Add horizontal swing support to ClimateEntity (#125578) * Add horizontal swing support to ClimateEntity * Fixes + tests * Fixes --- homeassistant/components/climate/__init__.py | 57 ++++++++++++++++++- homeassistant/components/climate/const.py | 8 +++ homeassistant/components/climate/icons.json | 10 ++++ .../components/climate/reproduce_state.py | 10 ++++ .../components/climate/services.yaml | 15 ++++- .../components/climate/significant_change.py | 3 + homeassistant/components/climate/strings.json | 23 ++++++++ homeassistant/components/demo/climate.py | 23 ++++++++ homeassistant/components/demo/icons.json | 7 +++ homeassistant/components/demo/strings.json | 7 +++ tests/components/climate/test_init.py | 47 ++++++++++++++- .../climate/test_reproduce_state.py | 4 ++ .../climate/test_significant_change.py | 13 +++++ 13 files changed, 224 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 08bad57b6d0c0..de9c90c81b85c 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -70,6 +70,8 @@ ATTR_MIN_TEMP, ATTR_PRESET_MODE, ATTR_PRESET_MODES, + ATTR_SWING_HORIZONTAL_MODE, + ATTR_SWING_HORIZONTAL_MODES, ATTR_SWING_MODE, ATTR_SWING_MODES, ATTR_TARGET_TEMP_HIGH, @@ -101,6 +103,7 @@ SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, SWING_BOTH, @@ -219,6 +222,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_handle_set_swing_mode_service", [ClimateEntityFeature.SWING_MODE], ) + component.async_register_entity_service( + SERVICE_SET_SWING_HORIZONTAL_MODE, + {vol.Required(ATTR_SWING_HORIZONTAL_MODE): cv.string}, + "async_handle_set_swing_horizontal_mode_service", + [ClimateEntityFeature.SWING_HORIZONTAL_MODE], + ) return True @@ -256,6 +265,8 @@ class ClimateEntityDescription(EntityDescription, frozen_or_thawed=True): "fan_modes", "swing_mode", "swing_modes", + "swing_horizontal_mode", + "swing_horizontal_modes", "supported_features", "min_temp", "max_temp", @@ -300,6 +311,8 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_supported_features: ClimateEntityFeature = ClimateEntityFeature(0) _attr_swing_mode: str | None _attr_swing_modes: list[str] | None + _attr_swing_horizontal_mode: str | None + _attr_swing_horizontal_modes: list[str] | None _attr_target_humidity: float | None = None _attr_target_temperature_high: float | None _attr_target_temperature_low: float | None @@ -513,6 +526,9 @@ def capability_attributes(self) -> dict[str, Any] | None: if ClimateEntityFeature.SWING_MODE in supported_features: data[ATTR_SWING_MODES] = self.swing_modes + if ClimateEntityFeature.SWING_HORIZONTAL_MODE in supported_features: + data[ATTR_SWING_HORIZONTAL_MODES] = self.swing_horizontal_modes + return data @final @@ -564,6 +580,9 @@ def state_attributes(self) -> dict[str, Any]: if ClimateEntityFeature.SWING_MODE in supported_features: data[ATTR_SWING_MODE] = self.swing_mode + if ClimateEntityFeature.SWING_HORIZONTAL_MODE in supported_features: + data[ATTR_SWING_HORIZONTAL_MODE] = self.swing_horizontal_mode + if ClimateEntityFeature.AUX_HEAT in supported_features: data[ATTR_AUX_HEAT] = STATE_ON if self.is_aux_heat else STATE_OFF if ( @@ -691,11 +710,27 @@ def swing_modes(self) -> list[str] | None: """ return self._attr_swing_modes + @cached_property + def swing_horizontal_mode(self) -> str | None: + """Return the horizontal swing setting. + + Requires ClimateEntityFeature.SWING_HORIZONTAL_MODE. + """ + return self._attr_swing_horizontal_mode + + @cached_property + def swing_horizontal_modes(self) -> list[str] | None: + """Return the list of available horizontal swing modes. + + Requires ClimateEntityFeature.SWING_HORIZONTAL_MODE. + """ + return self._attr_swing_horizontal_modes + @final @callback def _valid_mode_or_raise( self, - mode_type: Literal["preset", "swing", "fan", "hvac"], + mode_type: Literal["preset", "horizontal_swing", "swing", "fan", "hvac"], mode: str | HVACMode, modes: list[str] | list[HVACMode] | None, ) -> None: @@ -793,6 +828,26 @@ async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" await self.hass.async_add_executor_job(self.set_swing_mode, swing_mode) + @final + async def async_handle_set_swing_horizontal_mode_service( + self, swing_horizontal_mode: str + ) -> None: + """Validate and set new horizontal swing mode.""" + self._valid_mode_or_raise( + "horizontal_swing", swing_horizontal_mode, self.swing_horizontal_modes + ) + await self.async_set_swing_horizontal_mode(swing_horizontal_mode) + + def set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new target horizontal swing operation.""" + raise NotImplementedError + + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new target horizontal swing operation.""" + await self.hass.async_add_executor_job( + self.set_swing_horizontal_mode, swing_horizontal_mode + ) + @final async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None: """Validate and set new preset mode.""" diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index a84a2f3c62805..b22d5df93ba6c 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -92,6 +92,10 @@ class HVACMode(StrEnum): SWING_VERTICAL = "vertical" SWING_HORIZONTAL = "horizontal" +# Possible horizontal swing state +SWING_HORIZONTAL_ON = "on" +SWING_HORIZONTAL_OFF = "off" + class HVACAction(StrEnum): """HVAC action for climate devices.""" @@ -134,6 +138,8 @@ class HVACAction(StrEnum): ATTR_HVAC_MODE = "hvac_mode" ATTR_SWING_MODES = "swing_modes" ATTR_SWING_MODE = "swing_mode" +ATTR_SWING_HORIZONTAL_MODE = "swing_horizontal_mode" +ATTR_SWING_HORIZONTAL_MODES = "swing_horizontal_modes" ATTR_TARGET_TEMP_HIGH = "target_temp_high" ATTR_TARGET_TEMP_LOW = "target_temp_low" ATTR_TARGET_TEMP_STEP = "target_temp_step" @@ -153,6 +159,7 @@ class HVACAction(StrEnum): SERVICE_SET_HUMIDITY = "set_humidity" SERVICE_SET_HVAC_MODE = "set_hvac_mode" SERVICE_SET_SWING_MODE = "set_swing_mode" +SERVICE_SET_SWING_HORIZONTAL_MODE = "set_swing_horizontal_mode" SERVICE_SET_TEMPERATURE = "set_temperature" @@ -168,6 +175,7 @@ class ClimateEntityFeature(IntFlag): AUX_HEAT = 64 TURN_OFF = 128 TURN_ON = 256 + SWING_HORIZONTAL_MODE = 512 # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index c9a8d12d01be5..8f4ffa6b19f25 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -51,6 +51,13 @@ "on": "mdi:arrow-oscillating", "vertical": "mdi:arrow-up-down" } + }, + "swing_horizontal_mode": { + "default": "mdi:circle-medium", + "state": { + "off": "mdi:arrow-oscillating-off", + "on": "mdi:arrow-expand-horizontal" + } } } } @@ -65,6 +72,9 @@ "set_swing_mode": { "service": "mdi:arrow-oscillating" }, + "set_swing_horizontal_mode": { + "service": "mdi:arrow-expand-horizontal" + }, "set_temperature": { "service": "mdi:thermometer" }, diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py index 99357777fba07..d38e243cb62d8 100644 --- a/homeassistant/components/climate/reproduce_state.py +++ b/homeassistant/components/climate/reproduce_state.py @@ -14,6 +14,7 @@ ATTR_HUMIDITY, ATTR_HVAC_MODE, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -23,6 +24,7 @@ SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, ) @@ -76,6 +78,14 @@ async def call_service( ): await call_service(SERVICE_SET_SWING_MODE, [ATTR_SWING_MODE]) + if ( + ATTR_SWING_HORIZONTAL_MODE in state.attributes + and state.attributes[ATTR_SWING_HORIZONTAL_MODE] is not None + ): + await call_service( + SERVICE_SET_SWING_HORIZONTAL_MODE, [ATTR_SWING_HORIZONTAL_MODE] + ) + if ( ATTR_FAN_MODE in state.attributes and state.attributes[ATTR_FAN_MODE] is not None diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 12a8e6f001fae..68421bf23866d 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -131,7 +131,20 @@ set_swing_mode: fields: swing_mode: required: true - example: "horizontal" + example: "on" + selector: + text: + +set_swing_horizontal_mode: + target: + entity: + domain: climate + supported_features: + - climate.ClimateEntityFeature.SWING_HORIZONTAL_MODE + fields: + swing_horizontal_mode: + required: true + example: "on" selector: text: diff --git a/homeassistant/components/climate/significant_change.py b/homeassistant/components/climate/significant_change.py index 0c4cdd4ac6aae..2b7e2c5d8b1a2 100644 --- a/homeassistant/components/climate/significant_change.py +++ b/homeassistant/components/climate/significant_change.py @@ -19,6 +19,7 @@ ATTR_HUMIDITY, ATTR_HVAC_ACTION, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -34,6 +35,7 @@ ATTR_HVAC_ACTION, ATTR_PRESET_MODE, ATTR_SWING_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ATTR_TEMPERATURE, @@ -70,6 +72,7 @@ def async_check_significant_change( ATTR_HVAC_ACTION, ATTR_PRESET_MODE, ATTR_SWING_MODE, + ATTR_SWING_HORIZONTAL_MODE, ]: return True diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index f607419195e18..6d8b2c5449dde 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -123,6 +123,16 @@ "swing_modes": { "name": "Swing modes" }, + "swing_horizontal_mode": { + "name": "Horizontal swing mode", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "swing_horizontal_modes": { + "name": "Horizontal swing modes" + }, "target_temp_high": { "name": "Upper target temperature" }, @@ -221,6 +231,16 @@ } } }, + "set_swing_horizontal_mode": { + "name": "Set horizontal swing mode", + "description": "Sets horizontal swing operation mode.", + "fields": { + "swing_horizontal_mode": { + "name": "Horizontal swing mode", + "description": "Horizontal swing operation mode." + } + } + }, "turn_on": { "name": "[%key:common::action::turn_on%]", "description": "Turns climate device on." @@ -264,6 +284,9 @@ "not_valid_swing_mode": { "message": "Swing mode {mode} is not valid. Valid swing modes are: {modes}." }, + "not_valid_horizontal_swing_mode": { + "message": "Horizontal swing mode {mode} is not valid. Valid horizontal swing modes are: {modes}." + }, "not_valid_fan_mode": { "message": "Fan mode {mode} is not valid. Valid fan modes are: {modes}." }, diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index ff0ed5746caaa..5424591f0215a 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -43,6 +43,7 @@ async def async_setup_entry( target_humidity=None, current_humidity=None, swing_mode=None, + swing_horizontal_mode=None, hvac_mode=HVACMode.HEAT, hvac_action=HVACAction.HEATING, target_temp_high=None, @@ -60,6 +61,7 @@ async def async_setup_entry( target_humidity=67.4, current_humidity=54.2, swing_mode="off", + swing_horizontal_mode="auto", hvac_mode=HVACMode.COOL, hvac_action=HVACAction.COOLING, target_temp_high=None, @@ -78,6 +80,7 @@ async def async_setup_entry( target_humidity=None, current_humidity=None, swing_mode="auto", + swing_horizontal_mode=None, hvac_mode=HVACMode.HEAT_COOL, hvac_action=None, target_temp_high=24, @@ -109,6 +112,7 @@ def __init__( target_humidity: float | None, current_humidity: float | None, swing_mode: str | None, + swing_horizontal_mode: str | None, hvac_mode: HVACMode, hvac_action: HVACAction | None, target_temp_high: float | None, @@ -129,6 +133,8 @@ def __init__( self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY if swing_mode is not None: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + if swing_horizontal_mode is not None: + self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE if HVACMode.HEAT_COOL in hvac_modes or HVACMode.AUTO in hvac_modes: self._attr_supported_features |= ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -147,9 +153,11 @@ def __init__( self._hvac_action = hvac_action self._hvac_mode = hvac_mode self._current_swing_mode = swing_mode + self._current_swing_horizontal_mode = swing_horizontal_mode self._fan_modes = ["on_low", "on_high", "auto_low", "auto_high", "off"] self._hvac_modes = hvac_modes self._swing_modes = ["auto", "1", "2", "3", "off"] + self._swing_horizontal_modes = ["auto", "rangefull", "off"] self._target_temperature_high = target_temp_high self._target_temperature_low = target_temp_low self._attr_device_info = DeviceInfo( @@ -242,6 +250,16 @@ def swing_modes(self) -> list[str]: """List of available swing modes.""" return self._swing_modes + @property + def swing_horizontal_mode(self) -> str | None: + """Return the swing setting.""" + return self._current_swing_horizontal_mode + + @property + def swing_horizontal_modes(self) -> list[str]: + """List of available swing modes.""" + return self._swing_horizontal_modes + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if kwargs.get(ATTR_TEMPERATURE) is not None: @@ -266,6 +284,11 @@ async def async_set_swing_mode(self, swing_mode: str) -> None: self._current_swing_mode = swing_mode self.async_write_ha_state() + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new swing mode.""" + self._current_swing_horizontal_mode = swing_horizontal_mode + self.async_write_ha_state() + async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" self._current_fan_mode = fan_mode diff --git a/homeassistant/components/demo/icons.json b/homeassistant/components/demo/icons.json index 17425a6d11911..eafcbb9161ad7 100644 --- a/homeassistant/components/demo/icons.json +++ b/homeassistant/components/demo/icons.json @@ -19,6 +19,13 @@ "auto": "mdi:arrow-oscillating", "off": "mdi:arrow-oscillating-off" } + }, + "swing_horizontal_mode": { + "state": { + "rangefull": "mdi:pan-horizontal", + "auto": "mdi:compare-horizontal", + "off": "mdi:arrow-oscillating-off" + } } } } diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index aa5554e9fcc7c..da72b33d3ca30 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -42,6 +42,13 @@ "auto": "Auto", "off": "[%key:common::state::off%]" } + }, + "swing_horizontal_mode": { + "state": { + "rangefull": "Full range", + "auto": "Auto", + "off": "[%key:common::state::off%]" + } } } } diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index aa162e0b68388..254fb26a47116 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -24,6 +24,7 @@ ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -31,8 +32,11 @@ SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, + SWING_HORIZONTAL_OFF, + SWING_HORIZONTAL_ON, ClimateEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -104,6 +108,7 @@ class MockClimateEntity(MockEntity, ClimateEntity): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE ) _attr_preset_mode = "home" _attr_preset_modes = ["home", "away"] @@ -111,6 +116,8 @@ class MockClimateEntity(MockEntity, ClimateEntity): _attr_fan_modes = ["auto", "off"] _attr_swing_mode = "auto" _attr_swing_modes = ["auto", "off"] + _attr_swing_horizontal_mode = "on" + _attr_swing_horizontal_modes = [SWING_HORIZONTAL_ON, SWING_HORIZONTAL_OFF] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature = 20 _attr_target_temperature_high = 25 @@ -144,6 +151,10 @@ def set_swing_mode(self, swing_mode: str) -> None: """Set swing mode.""" self._attr_swing_mode = swing_mode + def set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set horizontal swing mode.""" + self._attr_swing_horizontal_mode = swing_horizontal_mode + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" self._attr_hvac_mode = hvac_mode @@ -194,7 +205,11 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s (enum_field, constant_prefix) for enum_field in enum if enum_field - not in [ClimateEntityFeature.TURN_ON, ClimateEntityFeature.TURN_OFF] + not in [ + ClimateEntityFeature.TURN_ON, + ClimateEntityFeature.TURN_OFF, + ClimateEntityFeature.SWING_HORIZONTAL_MODE, + ] ] @@ -339,6 +354,7 @@ async def test_mode_validation( assert state.attributes.get(ATTR_PRESET_MODE) == "home" assert state.attributes.get(ATTR_FAN_MODE) == "auto" assert state.attributes.get(ATTR_SWING_MODE) == "auto" + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "on" await hass.services.async_call( DOMAIN, @@ -358,6 +374,15 @@ async def test_mode_validation( }, blocking=True, ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + { + "entity_id": "climate.test", + "swing_horizontal_mode": "off", + }, + blocking=True, + ) await hass.services.async_call( DOMAIN, SERVICE_SET_FAN_MODE, @@ -371,6 +396,7 @@ async def test_mode_validation( assert state.attributes.get(ATTR_PRESET_MODE) == "away" assert state.attributes.get(ATTR_FAN_MODE) == "off" assert state.attributes.get(ATTR_SWING_MODE) == "off" + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "off" await hass.services.async_call( DOMAIN, @@ -427,6 +453,25 @@ async def test_mode_validation( ) assert exc.value.translation_key == "not_valid_swing_mode" + with pytest.raises( + ServiceValidationError, + match="Horizontal swing mode invalid is not valid. Valid horizontal swing modes are: on, off", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + { + "entity_id": "climate.test", + "swing_horizontal_mode": "invalid", + }, + blocking=True, + ) + assert ( + str(exc.value) + == "Horizontal swing mode invalid is not valid. Valid horizontal swing modes are: on, off" + ) + assert exc.value.translation_key == "not_valid_horizontal_swing_mode" + with pytest.raises( ServiceValidationError, match="Fan mode invalid is not valid. Valid fan modes are: auto, off", diff --git a/tests/components/climate/test_reproduce_state.py b/tests/components/climate/test_reproduce_state.py index 0632ebcc9e43b..3bc91467f14fd 100644 --- a/tests/components/climate/test_reproduce_state.py +++ b/tests/components/climate/test_reproduce_state.py @@ -6,6 +6,7 @@ ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -14,6 +15,7 @@ SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, HVACMode, @@ -96,6 +98,7 @@ async def test_state_with_context(hass: HomeAssistant) -> None: [ (SERVICE_SET_PRESET_MODE, ATTR_PRESET_MODE), (SERVICE_SET_SWING_MODE, ATTR_SWING_MODE), + (SERVICE_SET_SWING_HORIZONTAL_MODE, ATTR_SWING_HORIZONTAL_MODE), (SERVICE_SET_FAN_MODE, ATTR_FAN_MODE), (SERVICE_SET_HUMIDITY, ATTR_HUMIDITY), (SERVICE_SET_TEMPERATURE, ATTR_TEMPERATURE), @@ -122,6 +125,7 @@ async def test_attribute(hass: HomeAssistant, service, attribute) -> None: [ (SERVICE_SET_PRESET_MODE, ATTR_PRESET_MODE), (SERVICE_SET_SWING_MODE, ATTR_SWING_MODE), + (SERVICE_SET_SWING_HORIZONTAL_MODE, ATTR_SWING_HORIZONTAL_MODE), (SERVICE_SET_FAN_MODE, ATTR_FAN_MODE), ], ) diff --git a/tests/components/climate/test_significant_change.py b/tests/components/climate/test_significant_change.py index f060344722ad8..7d70909035799 100644 --- a/tests/components/climate/test_significant_change.py +++ b/tests/components/climate/test_significant_change.py @@ -10,6 +10,7 @@ ATTR_HUMIDITY, ATTR_HVAC_ACTION, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -66,6 +67,18 @@ async def test_significant_state_change(hass: HomeAssistant) -> None: ), (METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "old_value"}, False), (METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "new_value"}, True), + ( + METRIC, + {ATTR_SWING_HORIZONTAL_MODE: "old_value"}, + {ATTR_SWING_HORIZONTAL_MODE: "old_value"}, + False, + ), + ( + METRIC, + {ATTR_SWING_HORIZONTAL_MODE: "old_value"}, + {ATTR_SWING_HORIZONTAL_MODE: "new_value"}, + True, + ), # multiple attributes ( METRIC, From d6f4a79b468d06e1c2d41ffd8561f4f2dabfea2b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 27 Nov 2024 15:37:36 +0100 Subject: [PATCH 0952/1070] Remove workaround for flaky translation tests (#131628) --- tests/common.py | 17 +++++++++++++++++ tests/components/conftest.py | 5 +---- tests/components/tts/test_init.py | 4 ++++ tests/conftest.py | 7 ++++++- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/tests/common.py b/tests/common.py index 8bd45e4d7f8f6..3ec3f6d844c62 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1815,3 +1815,20 @@ async def snapshot_platform( state = hass.states.get(entity_entry.entity_id) assert state, f"State not found for {entity_entry.entity_id}" assert state == snapshot(name=f"{entity_entry.entity_id}-state") + + +def reset_translation_cache(hass: HomeAssistant, components: list[str]) -> None: + """Reset translation cache for specified components. + + Use this if you are mocking a core component (for example via + mock_integration), to ensure that the mocked translations are not + persisted in the shared session cache. + """ + translations_cache = translation._async_get_translations_cache(hass) + for loaded_components in translations_cache.cache_data.loaded.values(): + for component_to_unload in components: + loaded_components.discard(component_to_unload) + for loaded_categories in translations_cache.cache_data.cache.values(): + for loaded_components in loaded_categories.values(): + for component_to_unload in components: + loaded_components.pop(component_to_unload, None) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 4294c0c2912ab..5628a2b1aafaa 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -827,9 +827,6 @@ async def _service_registry_async_call( f"Unused ignore translations: {', '.join(unused_ignore)}. " "Please remove them from the ignore_translations fixture." ) - for key, description in translation_errors.items(): - if key.startswith("component.cloud.issues."): - # cloud tests are flaky - continue + for description in translation_errors.values(): if description not in {"used", "unused"}: pytest.fail(description) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 80dff87eb9bf4..9d8dbf3ef94ed 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -45,6 +45,7 @@ mock_integration, mock_platform, mock_restore_cache, + reset_translation_cache, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -1988,3 +1989,6 @@ async def test_default_engine_prefer_cloud_entity( provider_engine = tts.async_resolve_engine(hass, "test") assert provider_engine == "test" assert tts.async_default_engine(hass) == "tts.cloud_tts_entity" + + # Reset the `cloud` translations cache + reset_translation_cache(hass, ["cloud"]) diff --git a/tests/conftest.py b/tests/conftest.py index b858073a5e4e6..c46ed0407e54a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1192,7 +1192,12 @@ def mock_get_source_ip() -> Generator[_patch]: @pytest.fixture(autouse=True, scope="session") def translations_once() -> Generator[_patch]: - """Only load translations once per session.""" + """Only load translations once per session. + + Warning: having this as a session fixture can cause issues with tests that + create mock integrations, overriding the real integration translations + with empty ones. Translations should be reset after such tests (see #131628) + """ cache = _TranslationsCacheData({}, {}) patcher = patch( "homeassistant.helpers.translation._TranslationsCacheData", From c21e221f65c0848168f0e212995c78901d56544b Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 27 Nov 2024 16:20:38 +0100 Subject: [PATCH 0953/1070] Add data description to Iron OS integration (#131719) --- homeassistant/components/iron_os/strings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index 75584fe191c37..92441b39fc3f4 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -5,10 +5,13 @@ "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { "address": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "address": "Ensure your device is powered on and within Bluetooth range before continuing" } }, "bluetooth_confirm": { - "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + "description": "Do you want to set up {name}?\n\n*Ensure your device is powered on and within Bluetooth range before continuing*" } }, "abort": { From f4b57617fbbda8327a38be729c0b4bb01e35299f Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 27 Nov 2024 16:23:59 +0100 Subject: [PATCH 0954/1070] Unifiprotect fix missing domain check (#131724) --- homeassistant/components/unifiprotect/data.py | 1 + tests/components/unifiprotect/test_init.py | 66 ++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 4ad8892ca0199..baecc7f8323ac 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -349,6 +349,7 @@ def async_ufp_instance_for_config_entry_ids( entry.runtime_data.api for entry_id in config_entry_ids if (entry := hass.config_entries.async_get_entry(entry_id)) + and entry.domain == DOMAIN and hasattr(entry, "runtime_data") ), None, diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 46e57c62101ee..457fd9d3b975b 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -2,8 +2,9 @@ from __future__ import annotations -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch +import pytest from uiprotect import NotAuthorized, NvrError, ProtectApiClient from uiprotect.api import DEVICE_UPDATE_INTERVAL from uiprotect.data import NVR, Bootstrap, CloudAccount, Light @@ -13,6 +14,9 @@ CONF_DISABLE_RTSP, DOMAIN, ) +from homeassistant.components.unifiprotect.data import ( + async_ufp_instance_for_config_entry_ids, +) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -286,3 +290,63 @@ async def test_device_remove_devices_nvr( client = await hass_ws_client(hass) response = await client.remove_device(live_device_entry.id, entry_id) assert not response["success"] + + +@pytest.mark.parametrize( + ("mock_entries", "expected_result"), + [ + pytest.param( + [ + Mock( + spec=ConfigEntry, + domain=DOMAIN, + runtime_data=Mock(api="mock_api_instance_1"), + ), + Mock( + spec=ConfigEntry, + domain="other_domain", + runtime_data=Mock(api="mock_api_instance_2"), + ), + ], + "mock_api_instance_1", + id="one_matching_domain", + ), + pytest.param( + [ + Mock( + spec=ConfigEntry, + domain="other_domain", + runtime_data=Mock(api="mock_api_instance_1"), + ), + Mock( + spec=ConfigEntry, + domain="other_domain", + runtime_data=Mock(api="mock_api_instance_2"), + ), + ], + None, + id="no_matching_domain", + ), + ], +) +async def test_async_ufp_instance_for_config_entry_ids( + mock_entries, expected_result +) -> None: + """Test async_ufp_instance_for_config_entry_ids with various entry configurations.""" + + hass = Mock(spec=HomeAssistant) + + mock_entry_mapping = { + str(index): entry for index, entry in enumerate(mock_entries, start=1) + } + + def mock_async_get_entry(entry_id): + return mock_entry_mapping.get(entry_id) + + hass.config_entries.async_get_entry = Mock(side_effect=mock_async_get_entry) + + entry_ids = set(mock_entry_mapping.keys()) + + result = async_ufp_instance_for_config_entry_ids(hass, entry_ids) + + assert result == expected_result From e05401a9229f216a8aafa57f1b82c9468a957a39 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 27 Nov 2024 16:28:36 +0100 Subject: [PATCH 0955/1070] Update snapshot to fix CI (#131725) --- .../garages_amsterdam/snapshots/test_sensor.ambr | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/components/garages_amsterdam/snapshots/test_sensor.ambr b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr index 55922de2f0b4d..2c579631baeb8 100644 --- a/tests/components/garages_amsterdam/snapshots/test_sensor.ambr +++ b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr @@ -29,7 +29,7 @@ 'supported_features': 0, 'translation_key': 'long_capacity', 'unique_id': 'IJDok-long_capacity', - 'unit_of_measurement': None, + 'unit_of_measurement': 'cars', }) # --- # name: test_all_sensors[sensor.ijdok_long_parking_capacity-state] @@ -37,6 +37,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by municipality of Amsterdam', 'friendly_name': 'IJDok Long parking capacity', + 'unit_of_measurement': 'cars', }), 'context': , 'entity_id': 'sensor.ijdok_long_parking_capacity', @@ -78,7 +79,7 @@ 'supported_features': 0, 'translation_key': 'free_space_long', 'unique_id': 'IJDok-free_space_long', - 'unit_of_measurement': None, + 'unit_of_measurement': 'cars', }) # --- # name: test_all_sensors[sensor.ijdok_long_parking_free_space-state] @@ -87,6 +88,7 @@ 'attribution': 'Data provided by municipality of Amsterdam', 'friendly_name': 'IJDok Long parking free space', 'state_class': , + 'unit_of_measurement': 'cars', }), 'context': , 'entity_id': 'sensor.ijdok_long_parking_free_space', @@ -126,7 +128,7 @@ 'supported_features': 0, 'translation_key': 'short_capacity', 'unique_id': 'IJDok-short_capacity', - 'unit_of_measurement': None, + 'unit_of_measurement': 'cars', }) # --- # name: test_all_sensors[sensor.ijdok_short_parking_capacity-state] @@ -134,6 +136,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by municipality of Amsterdam', 'friendly_name': 'IJDok Short parking capacity', + 'unit_of_measurement': 'cars', }), 'context': , 'entity_id': 'sensor.ijdok_short_parking_capacity', @@ -175,7 +178,7 @@ 'supported_features': 0, 'translation_key': 'free_space_short', 'unique_id': 'IJDok-free_space_short', - 'unit_of_measurement': None, + 'unit_of_measurement': 'cars', }) # --- # name: test_all_sensors[sensor.ijdok_short_parking_free_space-state] @@ -184,6 +187,7 @@ 'attribution': 'Data provided by municipality of Amsterdam', 'friendly_name': 'IJDok Short parking free space', 'state_class': , + 'unit_of_measurement': 'cars', }), 'context': , 'entity_id': 'sensor.ijdok_short_parking_free_space', From b2537a45e0afec8be6092fb7dac03b5872ed07b6 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 27 Nov 2024 16:33:05 +0100 Subject: [PATCH 0956/1070] Update frontend to 20241127.0 (#131722) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 4dc5a2b0ae47c..3063d3d844056 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241106.2"] + "requirements": ["home-assistant-frontend==20241127.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 67590af6185e5..fc53d791c7c9a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ habluetooth==3.6.0 hass-nabucasa==0.85.0 hassil==2.0.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241106.2 +home-assistant-frontend==20241127.0 home-assistant-intents==2024.11.13 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 912d33d3eddb0..2eaa7120b382f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1130,7 +1130,7 @@ hole==0.8.0 holidays==0.61 # homeassistant.components.frontend -home-assistant-frontend==20241106.2 +home-assistant-frontend==20241127.0 # homeassistant.components.conversation home-assistant-intents==2024.11.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2741a351bde40..3c264004d926c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -956,7 +956,7 @@ hole==0.8.0 holidays==0.61 # homeassistant.components.frontend -home-assistant-frontend==20241106.2 +home-assistant-frontend==20241127.0 # homeassistant.components.conversation home-assistant-intents==2024.11.13 From c2d6599736e7ae5482b9308b8eb9b6f3ef88ad95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 27 Nov 2024 16:34:41 +0100 Subject: [PATCH 0957/1070] Home connect program select entities (#126157) * Home connect selector for programs * Mark program switches as deprecated * Simplified translation keys * Improvements for program select entity * Revert mark program switches as deprecated * Return `None` if program is `None` or empty string * Fix program format * Use `is` instead of `==` * Program selector entity selects program instead of start the selected program * Fix typo * Active and selected program * Added ServiceValidationError * Delete unnecessary `service` param at tests * Use full program keys * Fix again typos in programs states * Use map for translations * Add error handling for when the selected program is not registered on the program map * Reverse map for programs and translation keys * Remove stale string * Log only once that the program is not part of the official Home Connect API specification * pop programs * Move `RE_CAMEL_CASE` to a better place * Added warning if updated program is not valid * Stale test function name * Improve log about unknown program at update * Add underscore before numbers in translation keys * Added suggested changes Co-authored-by: Martin Hjelmare * Use target for adding an executor job * Apply suggestions from code review * Clean whitespace --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 15 + .../components/home_connect/const.py | 13 + .../components/home_connect/select.py | 300 ++++++++++++++++ .../components/home_connect/strings.json | 323 ++++++++++++++++++ .../components/home_connect/switch.py | 13 +- tests/components/home_connect/conftest.py | 1 + tests/components/home_connect/test_init.py | 13 +- tests/components/home_connect/test_select.py | 161 +++++++++ 8 files changed, 826 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/home_connect/select.py create mode 100644 tests/components/home_connect/test_select.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 2c351f4dfa1b9..6e89fd2c9f717 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging +import re from typing import Any, cast from requests import HTTPError @@ -44,6 +45,8 @@ _LOGGER = logging.getLogger(__name__) +RE_CAMEL_CASE = re.compile(r"(? dict[str, Any if len(err.args) > 0 and isinstance(err.args[0], str) else "?", } + + +def bsh_key_to_translation_key(bsh_key: str) -> str: + """Convert a BSH key to a translation key format. + + This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`, + and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`. + """ + return "_".join( + RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".") + ).lower() diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index e49a56b9b97f8..e9f32b0e7728f 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -5,10 +5,23 @@ OAUTH2_AUTHORIZE = "https://api.home-connect.com/security/oauth/authorize" OAUTH2_TOKEN = "https://api.home-connect.com/security/oauth/token" +APPLIANCES_WITH_PROGRAMS = ( + "CleaningRobot", + "CoffeeMaker", + "Dishwasher", + "Dryer", + "Hood", + "Oven", + "WarmingDrawer", + "Washer", + "WasherDryer", +) + BSH_POWER_STATE = "BSH.Common.Setting.PowerState" BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" +BSH_SELECTED_PROGRAM = "BSH.Common.Root.SelectedProgram" BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram" BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive" BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed" diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py new file mode 100644 index 0000000000000..172b959b145dd --- /dev/null +++ b/homeassistant/components/home_connect/select.py @@ -0,0 +1,300 @@ +"""Provides a select platform for Home Connect.""" + +import contextlib +import logging + +from homeconnect.api import HomeConnectError + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ( + HomeConnectConfigEntry, + bsh_key_to_translation_key, + get_dict_from_home_connect_error, +) +from .api import HomeConnectDevice +from .const import ( + APPLIANCES_WITH_PROGRAMS, + ATTR_VALUE, + BSH_ACTIVE_PROGRAM, + BSH_SELECTED_PROGRAM, + DOMAIN, +) +from .entity import HomeConnectEntity + +_LOGGER = logging.getLogger(__name__) + +TRANSLATION_KEYS_PROGRAMS_MAP = { + bsh_key_to_translation_key(program): program + for program in ( + "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanAll", + "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanMap", + "ConsumerProducts.CleaningRobot.Program.Basic.GoHome", + "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto", + "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso", + "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio", + "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee", + "ConsumerProducts.CoffeeMaker.Program.Beverage.XLCoffee", + "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeGrande", + "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato", + "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino", + "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato", + "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte", + "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth", + "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KleinerBrauner", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.GrosserBrauner", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Verlaengerter", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.VerlaengerterBraun", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.WienerMelange", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeCortado", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeConLeche", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeAuLait", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Doppio", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Kaapi", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KoffieVerkeerd", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Garoto", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.RedEye", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.BlackEye", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.DeadEye", + "ConsumerProducts.CoffeeMaker.Program.Beverage.HotWater", + "Dishcare.Dishwasher.Program.PreRinse", + "Dishcare.Dishwasher.Program.Auto1", + "Dishcare.Dishwasher.Program.Auto2", + "Dishcare.Dishwasher.Program.Auto3", + "Dishcare.Dishwasher.Program.Eco50", + "Dishcare.Dishwasher.Program.Quick45", + "Dishcare.Dishwasher.Program.Intensiv70", + "Dishcare.Dishwasher.Program.Normal65", + "Dishcare.Dishwasher.Program.Glas40", + "Dishcare.Dishwasher.Program.GlassCare", + "Dishcare.Dishwasher.Program.NightWash", + "Dishcare.Dishwasher.Program.Quick65", + "Dishcare.Dishwasher.Program.Normal45", + "Dishcare.Dishwasher.Program.Intensiv45", + "Dishcare.Dishwasher.Program.AutoHalfLoad", + "Dishcare.Dishwasher.Program.IntensivPower", + "Dishcare.Dishwasher.Program.MagicDaily", + "Dishcare.Dishwasher.Program.Super60", + "Dishcare.Dishwasher.Program.Kurz60", + "Dishcare.Dishwasher.Program.ExpressSparkle65", + "Dishcare.Dishwasher.Program.MachineCare", + "Dishcare.Dishwasher.Program.SteamFresh", + "Dishcare.Dishwasher.Program.MaximumCleaning", + "Dishcare.Dishwasher.Program.MixedLoad", + "LaundryCare.Dryer.Program.Cotton", + "LaundryCare.Dryer.Program.Synthetic", + "LaundryCare.Dryer.Program.Mix", + "LaundryCare.Dryer.Program.Blankets", + "LaundryCare.Dryer.Program.BusinessShirts", + "LaundryCare.Dryer.Program.DownFeathers", + "LaundryCare.Dryer.Program.Hygiene", + "LaundryCare.Dryer.Program.Jeans", + "LaundryCare.Dryer.Program.Outdoor", + "LaundryCare.Dryer.Program.SyntheticRefresh", + "LaundryCare.Dryer.Program.Towels", + "LaundryCare.Dryer.Program.Delicates", + "LaundryCare.Dryer.Program.Super40", + "LaundryCare.Dryer.Program.Shirts15", + "LaundryCare.Dryer.Program.Pillow", + "LaundryCare.Dryer.Program.AntiShrink", + "LaundryCare.Dryer.Program.MyTime.MyDryingTime", + "LaundryCare.Dryer.Program.TimeCold", + "LaundryCare.Dryer.Program.TimeWarm", + "LaundryCare.Dryer.Program.InBasket", + "LaundryCare.Dryer.Program.TimeColdFix.TimeCold20", + "LaundryCare.Dryer.Program.TimeColdFix.TimeCold30", + "LaundryCare.Dryer.Program.TimeColdFix.TimeCold60", + "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm30", + "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm40", + "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm60", + "LaundryCare.Dryer.Program.Dessous", + "Cooking.Common.Program.Hood.Automatic", + "Cooking.Common.Program.Hood.Venting", + "Cooking.Common.Program.Hood.DelayedShutOff", + "Cooking.Oven.Program.HeatingMode.PreHeating", + "Cooking.Oven.Program.HeatingMode.HotAir", + "Cooking.Oven.Program.HeatingMode.HotAirEco", + "Cooking.Oven.Program.HeatingMode.HotAirGrilling", + "Cooking.Oven.Program.HeatingMode.TopBottomHeating", + "Cooking.Oven.Program.HeatingMode.TopBottomHeatingEco", + "Cooking.Oven.Program.HeatingMode.BottomHeating", + "Cooking.Oven.Program.HeatingMode.PizzaSetting", + "Cooking.Oven.Program.HeatingMode.SlowCook", + "Cooking.Oven.Program.HeatingMode.IntensiveHeat", + "Cooking.Oven.Program.HeatingMode.KeepWarm", + "Cooking.Oven.Program.HeatingMode.PreheatOvenware", + "Cooking.Oven.Program.HeatingMode.FrozenHeatupSpecial", + "Cooking.Oven.Program.HeatingMode.Desiccation", + "Cooking.Oven.Program.HeatingMode.Defrost", + "Cooking.Oven.Program.HeatingMode.Proof", + "Cooking.Oven.Program.HeatingMode.HotAir30Steam", + "Cooking.Oven.Program.HeatingMode.HotAir60Steam", + "Cooking.Oven.Program.HeatingMode.HotAir80Steam", + "Cooking.Oven.Program.HeatingMode.HotAir100Steam", + "Cooking.Oven.Program.HeatingMode.SabbathProgramme", + "Cooking.Oven.Program.Microwave90Watt", + "Cooking.Oven.Program.Microwave180Watt", + "Cooking.Oven.Program.Microwave360Watt", + "Cooking.Oven.Program.Microwave600Watt", + "Cooking.Oven.Program.Microwave900Watt", + "Cooking.Oven.Program.Microwave1000Watt", + "Cooking.Oven.Program.Microwave.Max", + "Cooking.Oven.Program.HeatingMode.WarmingDrawer", + "LaundryCare.Washer.Program.Cotton", + "LaundryCare.Washer.Program.Cotton.CottonEco", + "LaundryCare.Washer.Program.Cotton.Eco4060", + "LaundryCare.Washer.Program.Cotton.Colour", + "LaundryCare.Washer.Program.EasyCare", + "LaundryCare.Washer.Program.Mix", + "LaundryCare.Washer.Program.Mix.NightWash", + "LaundryCare.Washer.Program.DelicatesSilk", + "LaundryCare.Washer.Program.Wool", + "LaundryCare.Washer.Program.Sensitive", + "LaundryCare.Washer.Program.Auto30", + "LaundryCare.Washer.Program.Auto40", + "LaundryCare.Washer.Program.Auto60", + "LaundryCare.Washer.Program.Chiffon", + "LaundryCare.Washer.Program.Curtains", + "LaundryCare.Washer.Program.DarkWash", + "LaundryCare.Washer.Program.Dessous", + "LaundryCare.Washer.Program.Monsoon", + "LaundryCare.Washer.Program.Outdoor", + "LaundryCare.Washer.Program.PlushToy", + "LaundryCare.Washer.Program.ShirtsBlouses", + "LaundryCare.Washer.Program.SportFitness", + "LaundryCare.Washer.Program.Towels", + "LaundryCare.Washer.Program.WaterProof", + "LaundryCare.Washer.Program.PowerSpeed59", + "LaundryCare.Washer.Program.Super153045.Super15", + "LaundryCare.Washer.Program.Super153045.Super1530", + "LaundryCare.Washer.Program.DownDuvet.Duvet", + "LaundryCare.Washer.Program.Rinse.RinseSpinDrain", + "LaundryCare.Washer.Program.DrumClean", + "LaundryCare.WasherDryer.Program.Cotton", + "LaundryCare.WasherDryer.Program.Cotton.Eco4060", + "LaundryCare.WasherDryer.Program.Mix", + "LaundryCare.WasherDryer.Program.EasyCare", + "LaundryCare.WasherDryer.Program.WashAndDry60", + "LaundryCare.WasherDryer.Program.WashAndDry90", + ) +} + +PROGRAMS_TRANSLATION_KEYS_MAP = { + value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() +} + +PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( + SelectEntityDescription( + key=BSH_ACTIVE_PROGRAM, + translation_key="active_program", + ), + SelectEntityDescription( + key=BSH_SELECTED_PROGRAM, + translation_key="selected_program", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomeConnectConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Home Connect select entities.""" + + def get_entities() -> list[HomeConnectProgramSelectEntity]: + """Get a list of entities.""" + entities: list[HomeConnectProgramSelectEntity] = [] + programs_not_found = set() + for device in entry.runtime_data.devices: + if device.appliance.type in APPLIANCES_WITH_PROGRAMS: + with contextlib.suppress(HomeConnectError): + programs = device.appliance.get_programs_available() + if programs: + for program in programs: + if program not in PROGRAMS_TRANSLATION_KEYS_MAP: + programs.remove(program) + if program not in programs_not_found: + _LOGGER.info( + 'The program "%s" is not part of the official Home Connect API specification', + program, + ) + programs_not_found.add(program) + entities.extend( + HomeConnectProgramSelectEntity(device, programs, desc) + for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS + ) + return entities + + async_add_entities(await hass.async_add_executor_job(get_entities), True) + + +class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): + """Select class for Home Connect programs.""" + + def __init__( + self, + device: HomeConnectDevice, + programs: list[str], + desc: SelectEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__( + device, + desc, + ) + self._attr_options = [ + PROGRAMS_TRANSLATION_KEYS_MAP[program] for program in programs + ] + self.start_on_select = desc.key == BSH_ACTIVE_PROGRAM + + async def async_update(self) -> None: + """Update the program selection status.""" + program = self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) + if not program: + program_translation_key = None + elif not ( + program_translation_key := PROGRAMS_TRANSLATION_KEYS_MAP.get(program) + ): + _LOGGER.debug( + 'The program "%s" is not part of the official Home Connect API specification', + program, + ) + self._attr_current_option = program_translation_key + _LOGGER.debug("Updated, new program: %s", self._attr_current_option) + + async def async_select_option(self, option: str) -> None: + """Select new program.""" + bsh_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] + _LOGGER.debug( + "Starting program: %s" if self.start_on_select else "Selecting program: %s", + bsh_key, + ) + if self.start_on_select: + target = self.device.appliance.start_program + else: + target = self.device.appliance.select_program + try: + await self.hass.async_add_executor_job(target, bsh_key) + except HomeConnectError as err: + if self.start_on_select: + translation_key = "start_program" + else: + translation_key = "select_program" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "program": bsh_key, + }, + ) from err + self.async_entity_update() diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 934aed5b7d5f5..f952476302024 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -46,6 +46,9 @@ "turn_off": { "message": "Error while trying to turn off {entity_id} ({setting_key}): {description}" }, + "select_program": { + "message": "Error while trying to select program {program}: {description}" + }, "start_program": { "message": "Error while trying to start program {program}: {description}" }, @@ -267,6 +270,326 @@ "name": "Wine compartment 3 temperature" } }, + "select": { + "selected_program": { + "name": "Selected program", + "state": { + "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all", + "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map", + "consumer_products_cleaning_robot_program_basic_go_home": "Go home", + "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto", + "consumer_products_coffee_maker_program_beverage_espresso": "Espresso", + "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio", + "consumer_products_coffee_maker_program_beverage_coffee": "Coffee", + "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee", + "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande", + "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato", + "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino", + "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato", + "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte", + "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth", + "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange", + "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white", + "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado", + "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado", + "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche", + "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait", + "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio", + "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi", + "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd", + "consumer_products_coffee_maker_program_coffee_world_galao": "Galao", + "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto", + "consumer_products_coffee_maker_program_coffee_world_americano": "Americano", + "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye", + "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye", + "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye", + "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water", + "dishcare_dishwasher_program_pre_rinse": "Pre_rinse", + "dishcare_dishwasher_program_auto_1": "Auto 1", + "dishcare_dishwasher_program_auto_2": "Auto 2", + "dishcare_dishwasher_program_auto_3": "Auto 3", + "dishcare_dishwasher_program_eco_50": "Eco 50ºC", + "dishcare_dishwasher_program_quick_45": "Quick 45ºC", + "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC", + "dishcare_dishwasher_program_normal_65": "Normal 65ºC", + "dishcare_dishwasher_program_glas_40": "Glass 40ºC", + "dishcare_dishwasher_program_glass_care": "Glass care", + "dishcare_dishwasher_program_night_wash": "Night wash", + "dishcare_dishwasher_program_quick_65": "Quick 65ºC", + "dishcare_dishwasher_program_normal_45": "Normal 45ºC", + "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC", + "dishcare_dishwasher_program_auto_half_load": "Auto half load", + "dishcare_dishwasher_program_intensiv_power": "Intensive power", + "dishcare_dishwasher_program_magic_daily": "Magic daily", + "dishcare_dishwasher_program_super_60": "Super 60ºC", + "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC", + "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC", + "dishcare_dishwasher_program_machine_care": "Machine care", + "dishcare_dishwasher_program_steam_fresh": "Steam fresh", + "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning", + "dishcare_dishwasher_program_mixed_load": "Mixed load", + "laundry_care_dryer_program_cotton": "Cotton", + "laundry_care_dryer_program_synthetic": "Synthetic", + "laundry_care_dryer_program_mix": "Mix", + "laundry_care_dryer_program_blankets": "Blankets", + "laundry_care_dryer_program_business_shirts": "Business shirts", + "laundry_care_dryer_program_down_feathers": "Down feathers", + "laundry_care_dryer_program_hygiene": "Hygiene", + "laundry_care_dryer_program_jeans": "Jeans", + "laundry_care_dryer_program_outdoor": "Outdoor", + "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh", + "laundry_care_dryer_program_towels": "Towels", + "laundry_care_dryer_program_delicates": "Delicates", + "laundry_care_dryer_program_super_40": "Super 40ºC", + "laundry_care_dryer_program_shirts_15": "Shirts 15ºC", + "laundry_care_dryer_program_pillow": "Pillow", + "laundry_care_dryer_program_anti_shrink": "Anti shrink", + "laundry_care_dryer_program_my_time_my_drying_time": "My drying time", + "laundry_care_dryer_program_time_cold": "Cold (variable time)", + "laundry_care_dryer_program_time_warm": "Warm (variable time)", + "laundry_care_dryer_program_in_basket": "In basket", + "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)", + "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)", + "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)", + "laundry_care_dryer_program_dessous": "Dessous", + "cooking_common_program_hood_automatic": "Automatic", + "cooking_common_program_hood_venting": "Venting", + "cooking_common_program_hood_delayed_shut_off": "Delayed shut off", + "cooking_oven_program_heating_mode_pre_heating": "Pre-heating", + "cooking_oven_program_heating_mode_hot_air": "Hot air", + "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco", + "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling", + "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating", + "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco", + "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating", + "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting", + "cooking_oven_program_heating_mode_slow_cook": "Slow cook", + "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat", + "cooking_oven_program_heating_mode_keep_warm": "Keep warm", + "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware", + "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products", + "cooking_oven_program_heating_mode_desiccation": "Desiccation", + "cooking_oven_program_heating_mode_defrost": "Defrost", + "cooking_oven_program_heating_mode_proof": "Proof", + "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH", + "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH", + "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH", + "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH", + "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme", + "cooking_oven_program_microwave_90_watt": "90 Watt", + "cooking_oven_program_microwave_180_watt": "180 Watt", + "cooking_oven_program_microwave_360_watt": "360 Watt", + "cooking_oven_program_microwave_600_watt": "600 Watt", + "cooking_oven_program_microwave_900_watt": "900 Watt", + "cooking_oven_program_microwave_1000_watt": "1000 Watt", + "cooking_oven_program_microwave_max": "Max", + "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer", + "laundry_care_washer_program_cotton": "Cotton", + "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco", + "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC", + "laundry_care_washer_program_cotton_colour": "Cotton color", + "laundry_care_washer_program_easy_care": "Easy care", + "laundry_care_washer_program_mix": "Mix", + "laundry_care_washer_program_mix_night_wash": "Mix night wash", + "laundry_care_washer_program_delicates_silk": "Delicates silk", + "laundry_care_washer_program_wool": "Wool", + "laundry_care_washer_program_sensitive": "Sensitive", + "laundry_care_washer_program_auto_30": "Auto 30ºC", + "laundry_care_washer_program_auto_40": "Auto 40ºC", + "laundry_care_washer_program_auto_60": "Auto 60ºC", + "laundry_care_washer_program_chiffon": "Chiffon", + "laundry_care_washer_program_curtains": "Curtains", + "laundry_care_washer_program_dark_wash": "Dark wash", + "laundry_care_washer_program_dessous": "Dessous", + "laundry_care_washer_program_monsoon": "Monsoon", + "laundry_care_washer_program_outdoor": "Outdoor", + "laundry_care_washer_program_plush_toy": "Plush toy", + "laundry_care_washer_program_shirts_blouses": "Shirts blouses", + "laundry_care_washer_program_sport_fitness": "Sport fitness", + "laundry_care_washer_program_towels": "Towels", + "laundry_care_washer_program_water_proof": "Water proof", + "laundry_care_washer_program_power_speed_59": "Power speed <60 min", + "laundry_care_washer_program_super_153045_super_15": "Super 15 min", + "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min", + "laundry_care_washer_program_down_duvet_duvet": "Down duvet", + "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain", + "laundry_care_washer_program_drum_clean": "Drum clean", + "laundry_care_washer_dryer_program_cotton": "Cotton", + "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60 ºC", + "laundry_care_washer_dryer_program_mix": "Mix", + "laundry_care_washer_dryer_program_easy_care": "Easy care", + "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)", + "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)" + } + }, + "active_program": { + "name": "Active program", + "state": { + "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_all%]", + "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_map%]", + "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_basic_go_home%]", + "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_ristretto%]", + "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso%]", + "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_doppio%]", + "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_coffee%]", + "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_x_l_coffee%]", + "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_grande%]", + "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]", + "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_cappuccino%]", + "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_latte_macchiato%]", + "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_latte%]", + "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_milk_froth%]", + "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_warm_milk%]", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]", + "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_flat_white%]", + "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]", + "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_doppio%]", + "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kaapi%]", + "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]", + "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_galao%]", + "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_garoto%]", + "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_americano%]", + "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_red_eye%]", + "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_black_eye%]", + "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_dead_eye%]", + "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_hot_water%]", + "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_pre_rinse%]", + "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_1%]", + "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_2%]", + "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_3%]", + "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_eco_50%]", + "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_45%]", + "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_70%]", + "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_65%]", + "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glas_40%]", + "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glass_care%]", + "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_night_wash%]", + "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_65%]", + "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_45%]", + "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_45%]", + "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_half_load%]", + "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_power%]", + "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_magic_daily%]", + "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_super_60%]", + "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_kurz_60%]", + "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_express_sparkle_65%]", + "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_machine_care%]", + "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_steam_fresh%]", + "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_maximum_cleaning%]", + "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_mixed_load%]", + "laundry_care_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_cotton%]", + "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic%]", + "laundry_care_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_mix%]", + "laundry_care_dryer_program_blankets": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_blankets%]", + "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_business_shirts%]", + "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_down_feathers%]", + "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_hygiene%]", + "laundry_care_dryer_program_jeans": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_jeans%]", + "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_outdoor%]", + "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic_refresh%]", + "laundry_care_dryer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_towels%]", + "laundry_care_dryer_program_delicates": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_delicates%]", + "laundry_care_dryer_program_super_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_super_40%]", + "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_shirts_15%]", + "laundry_care_dryer_program_pillow": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_pillow%]", + "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_anti_shrink%]", + "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_my_time_my_drying_time%]", + "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold%]", + "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm%]", + "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_in_basket%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_20%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_30%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_60%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_30%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_40%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_60%]", + "laundry_care_dryer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_dessous%]", + "cooking_common_program_hood_automatic": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_automatic%]", + "cooking_common_program_hood_venting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_venting%]", + "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_delayed_shut_off%]", + "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pre_heating%]", + "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air%]", + "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_eco%]", + "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_grilling%]", + "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating%]", + "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating_eco%]", + "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_bottom_heating%]", + "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pizza_setting%]", + "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_slow_cook%]", + "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_intensive_heat%]", + "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_keep_warm%]", + "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_preheat_ovenware%]", + "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_frozen_heatup_special%]", + "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_desiccation%]", + "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_defrost%]", + "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_proof%]", + "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_30_steam%]", + "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_60_steam%]", + "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_80_steam%]", + "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_100_steam%]", + "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_sabbath_programme%]", + "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_90_watt%]", + "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_180_watt%]", + "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_360_watt%]", + "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_600_watt%]", + "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_900_watt%]", + "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_1000_watt%]", + "cooking_oven_program_microwave_max": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_max%]", + "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_warming_drawer%]", + "laundry_care_washer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton%]", + "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_cotton_eco%]", + "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_eco_4060%]", + "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_colour%]", + "laundry_care_washer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_easy_care%]", + "laundry_care_washer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix%]", + "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix_night_wash%]", + "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_delicates_silk%]", + "laundry_care_washer_program_wool": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_wool%]", + "laundry_care_washer_program_sensitive": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sensitive%]", + "laundry_care_washer_program_auto_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_30%]", + "laundry_care_washer_program_auto_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_40%]", + "laundry_care_washer_program_auto_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_60%]", + "laundry_care_washer_program_chiffon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_chiffon%]", + "laundry_care_washer_program_curtains": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_curtains%]", + "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dark_wash%]", + "laundry_care_washer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dessous%]", + "laundry_care_washer_program_monsoon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_monsoon%]", + "laundry_care_washer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_outdoor%]", + "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_plush_toy%]", + "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_shirts_blouses%]", + "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sport_fitness%]", + "laundry_care_washer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_towels%]", + "laundry_care_washer_program_water_proof": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_water_proof%]", + "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_power_speed_59%]", + "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_15%]", + "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_1530%]", + "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_down_duvet_duvet%]", + "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_rinse_rinse_spin_drain%]", + "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_drum_clean%]", + "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton%]", + "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton_eco_4060%]", + "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_mix%]", + "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_easy_care%]", + "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_60%]", + "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_90%]" + } + } + }, "sensor": { "program_progress": { "name": "Program progress" diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index cad6e81081602..2fe3ff0a01049 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -13,6 +13,7 @@ from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( + APPLIANCES_WITH_PROGRAMS, ATTR_ALLOWED_VALUES, ATTR_CONSTRAINTS, ATTR_VALUE, @@ -36,18 +37,6 @@ _LOGGER = logging.getLogger(__name__) -APPLIANCES_WITH_PROGRAMS = ( - "CleaningRobot", - "CoffeeMaker", - "Dishwasher", - "Dryer", - "Hood", - "Oven", - "WarmingDrawer", - "Washer", - "WasherDryer", -) - SWITCHES = ( SwitchEntityDescription( diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 4e790074700c5..d2eff43e071bd 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -182,6 +182,7 @@ def mock_problematic_appliance(request: pytest.FixtureRequest) -> Mock: mock.get_programs_active.side_effect = HomeConnectError mock.get_programs_available.side_effect = HomeConnectError mock.start_program.side_effect = HomeConnectError + mock.select_program.side_effect = HomeConnectError mock.stop_program.side_effect = HomeConnectError mock.get_status.side_effect = HomeConnectError mock.get_settings.side_effect = HomeConnectError diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 849e93e33d234..7c4f73b6f0aef 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -10,7 +10,10 @@ import requests_mock from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.home_connect import SCAN_INTERVAL +from homeassistant.components.home_connect import ( + SCAN_INTERVAL, + bsh_key_to_translation_key, +) from homeassistant.components.home_connect.const import ( BSH_CHILD_LOCK_STATE, BSH_OPERATION_STATE, @@ -27,6 +30,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from script.hassfest.translations import RE_TRANSLATION_KEY from .conftest import ( CLIENT_ID, @@ -372,3 +376,10 @@ async def test_entity_migration( domain, DOMAIN, f"{appliance.haId}-{expected_unique_id_suffix}" ) assert config_entry_v1_1.minor_version == 2 + + +async def test_bsh_key_transformations() -> None: + """Test that the key transformations are compatible valid translations keys and can be reversed.""" + program = "Dishcare.Dishwasher.Program.Eco50" + translation_key = bsh_key_to_translation_key(program) + assert RE_TRANSLATION_KEY.match(translation_key) diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py new file mode 100644 index 0000000000000..5939d256e0a8e --- /dev/null +++ b/tests/components/home_connect/test_select.py @@ -0,0 +1,161 @@ +"""Tests for home_connect select entities.""" + +from collections.abc import Awaitable, Callable, Generator +from unittest.mock import MagicMock, Mock + +from homeconnect.api import HomeConnectError +import pytest + +from homeassistant.components.home_connect.const import ( + BSH_ACTIVE_PROGRAM, + BSH_SELECTED_PROGRAM, +) +from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .conftest import get_all_appliances + +from tests.common import MockConfigEntry, load_json_object_fixture + +SETTINGS_STATUS = { + setting.pop("key"): setting + for setting in load_json_object_fixture("home_connect/settings.json") + .get("Washer") + .get("data") + .get("settings") +} + +PROGRAM = "Dishcare.Dishwasher.Program.Eco50" + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SELECT] + + +async def test_select( + bypass_throttle: Generator[None], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: Mock, +) -> None: + """Test select entity.""" + get_appliances.side_effect = get_all_appliances + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + ("entity_id", "status", "program_to_set"), + [ + ( + "select.washer_selected_program", + {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "dishcare_dishwasher_program_eco_50", + ), + ( + "select.washer_active_program", + {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "dishcare_dishwasher_program_eco_50", + ), + ], +) +async def test_select_functionality( + entity_id: str, + status: dict, + program_to_set: str, + bypass_throttle: Generator[None], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + appliance: Mock, + get_appliances: MagicMock, +) -> None: + """Test select functionality.""" + appliance.status.update(SETTINGS_STATUS) + appliance.get_programs_available.return_value = [PROGRAM] + get_appliances.return_value = [appliance] + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state is ConfigEntryState.LOADED + + appliance.status.update(status) + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set}, + blocking=True, + ) + assert hass.states.is_state(entity_id, program_to_set) + + +@pytest.mark.parametrize( + ( + "entity_id", + "status", + "program_to_set", + "mock_attr", + "exception_match", + ), + [ + ( + "select.washer_selected_program", + {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "dishcare_dishwasher_program_eco_50", + "select_program", + r"Error.*select.*program.*", + ), + ( + "select.washer_active_program", + {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "dishcare_dishwasher_program_eco_50", + "start_program", + r"Error.*start.*program.*", + ), + ], +) +async def test_select_exception_handling( + entity_id: str, + status: dict, + program_to_set: str, + mock_attr: str, + exception_match: str, + bypass_throttle: Generator[None], + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, + problematic_appliance: Mock, + get_appliances: MagicMock, +) -> None: + """Test exception handling.""" + problematic_appliance.get_programs_available.side_effect = None + problematic_appliance.get_programs_available.return_value = [PROGRAM] + get_appliances.return_value = [problematic_appliance] + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state is ConfigEntryState.LOADED + + # Assert that an exception is called. + with pytest.raises(HomeConnectError): + getattr(problematic_appliance, mock_attr)() + + problematic_appliance.status.update(status) + with pytest.raises(ServiceValidationError, match=exception_match): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {"entity_id": entity_id, "option": program_to_set}, + blocking=True, + ) + assert getattr(problematic_appliance, mock_attr).call_count == 2 From 3eb483c1b076aaccc1fa9899e059af2b28faccc1 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 27 Nov 2024 10:42:59 -0600 Subject: [PATCH 0958/1070] Bump intents to 2024.11.27 (#131727) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- tests/components/conversation/snapshots/test_http.ambr | 6 ------ 6 files changed, 5 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index b45a545682545..26265a37cce6e 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.0.4", "home-assistant-intents==2024.11.13"] + "requirements": ["hassil==2.0.4", "home-assistant-intents==2024.11.27"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fc53d791c7c9a..a4beb141911f6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ hass-nabucasa==0.85.0 hassil==2.0.4 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241127.0 -home-assistant-intents==2024.11.13 +home-assistant-intents==2024.11.27 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 2eaa7120b382f..5decd2975fd92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,7 +1133,7 @@ holidays==0.61 home-assistant-frontend==20241127.0 # homeassistant.components.conversation -home-assistant-intents==2024.11.13 +home-assistant-intents==2024.11.27 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c264004d926c..3f824a1f21299 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -959,7 +959,7 @@ holidays==0.61 home-assistant-frontend==20241127.0 # homeassistant.components.conversation -home-assistant-intents==2024.11.13 +home-assistant-intents==2024.11.27 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index e6ab27de9b08c..e11ffca025d56 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.4,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.0 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.4 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.4 home-assistant-intents==2024.11.27 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index d9d859113f824..966abd63d781e 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -6,7 +6,6 @@ 'id': 'conversation.home_assistant', 'name': 'Home Assistant', 'supported_languages': list([ - 'af', 'ar', 'bg', 'bn', @@ -24,22 +23,18 @@ 'fi', 'fr', 'gl', - 'gu', 'he', - 'hi', 'hr', 'hu', 'id', 'is', 'it', 'ka', - 'kn', 'ko', 'lb', 'lt', 'lv', 'ml', - 'mn', 'ms', 'nb', 'nl', @@ -52,7 +47,6 @@ 'sl', 'sr', 'sv', - 'sw', 'te', 'th', 'tr', From 3485ce9c71c05d1c902d5cd40f0a789657280ea8 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 27 Nov 2024 17:43:48 +0100 Subject: [PATCH 0959/1070] Add actions to Music Assistant integration (#129515) Co-authored-by: Franck Nijhof Co-authored-by: Martin Hjelmare --- .../components/music_assistant/__init__.py | 7 +- .../components/music_assistant/icons.json | 7 + .../music_assistant/media_player.py | 80 +++++- .../components/music_assistant/services.yaml | 90 ++++++ .../components/music_assistant/strings.json | 73 +++++ .../music_assistant/test_media_player.py | 258 +++++++++++++++++- 6 files changed, 509 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/music_assistant/icons.json create mode 100644 homeassistant/components/music_assistant/services.yaml diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 9f0fc1aad2760..22de510ebe344 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -28,13 +28,13 @@ if TYPE_CHECKING: from music_assistant_models.event import MassEvent -type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData] - PLATFORMS = [Platform.MEDIA_PLAYER] CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 +type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData] + @dataclass class MusicAssistantEntryData: @@ -47,7 +47,7 @@ class MusicAssistantEntryData: async def async_setup_entry( hass: HomeAssistant, entry: MusicAssistantConfigEntry ) -> bool: - """Set up from a config entry.""" + """Set up Music Assistant from a config entry.""" http_session = async_get_clientsession(hass, verify_ssl=False) mass_url = entry.data[CONF_URL] mass = MusicAssistantClient(mass_url, http_session) @@ -97,6 +97,7 @@ async def on_hass_stop(event: Event) -> None: listen_task.cancel() raise ConfigEntryNotReady("Music Assistant client not ready") from err + # store the listen task and mass client in the entry data entry.runtime_data = MusicAssistantEntryData(mass, listen_task) # If the listen task is already failed, we need to raise ConfigEntryNotReady diff --git a/homeassistant/components/music_assistant/icons.json b/homeassistant/components/music_assistant/icons.json new file mode 100644 index 0000000000000..7533dbb6dad32 --- /dev/null +++ b/homeassistant/components/music_assistant/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "play_media": { "service": "mdi:play" }, + "play_announcement": { "service": "mdi:bullhorn" }, + "transfer_queue": { "service": "mdi:transfer" } + } +} diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 8789bb36d33a8..07d6ddeee0314 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -13,15 +13,18 @@ EventType, MediaType, PlayerFeature, + PlayerState as MassPlayerState, QueueOption, RepeatMode as MassRepeatMode, ) from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError from music_assistant_models.event import MassEvent from music_assistant_models.media_items import ItemMapping, MediaItemType, Track +import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( + ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_EXTRA, BrowseMedia, MediaPlayerDeviceClass, @@ -37,7 +40,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.util.dt import utc_from_timestamp from . import MusicAssistantConfigEntry @@ -79,6 +86,9 @@ MediaPlayerEnqueue.REPLACE: QueueOption.REPLACE, } +SERVICE_PLAY_MEDIA_ADVANCED = "play_media" +SERVICE_PLAY_ANNOUNCEMENT = "play_announcement" +SERVICE_TRANSFER_QUEUE = "transfer_queue" ATTR_RADIO_MODE = "radio_mode" ATTR_MEDIA_ID = "media_id" ATTR_MEDIA_TYPE = "media_type" @@ -138,6 +148,38 @@ async def handle_player_added(event: MassEvent) -> None: async_add_entities(mass_players) + # add platform service for play_media with advanced options + platform = async_get_current_platform() + platform.async_register_entity_service( + SERVICE_PLAY_MEDIA_ADVANCED, + { + vol.Required(ATTR_MEDIA_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_MEDIA_TYPE): vol.Coerce(MediaType), + vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Coerce(QueueOption), + vol.Optional(ATTR_ARTIST): cv.string, + vol.Optional(ATTR_ALBUM): cv.string, + vol.Optional(ATTR_RADIO_MODE): vol.Coerce(bool), + }, + "_async_handle_play_media", + ) + platform.async_register_entity_service( + SERVICE_PLAY_ANNOUNCEMENT, + { + vol.Required(ATTR_URL): cv.string, + vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool), + vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int), + }, + "_async_handle_play_announcement", + ) + platform.async_register_entity_service( + SERVICE_TRANSFER_QUEUE, + { + vol.Optional(ATTR_SOURCE_PLAYER): cv.entity_id, + vol.Optional(ATTR_AUTO_PLAY): vol.Coerce(bool), + }, + "_async_handle_transfer_queue", + ) + class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): """Representation of MediaPlayerEntity from Music Assistant Player.""" @@ -376,6 +418,8 @@ async def async_unjoin_player(self) -> None: async def _async_handle_play_media( self, media_id: list[str], + artist: str | None = None, + album: str | None = None, enqueue: MediaPlayerEnqueue | QueueOption | None = None, radio_mode: bool | None = None, media_type: str | None = None, @@ -402,6 +446,14 @@ async def _async_handle_play_media( elif await asyncio.to_thread(os.path.isfile, media_id_str): media_uris.append(media_id_str) continue + # last resort: search for media item by name/search + if item := await self.mass.music.get_item_by_name( + name=media_id_str, + artist=artist, + album=album, + media_type=MediaType(media_type) if media_type else None, + ): + media_uris.append(item.uri) if not media_uris: raise HomeAssistantError( @@ -435,6 +487,32 @@ async def _async_handle_play_announcement( self.player_id, url, use_pre_announce, announce_volume ) + @catch_musicassistant_error + async def _async_handle_transfer_queue( + self, source_player: str | None = None, auto_play: bool | None = None + ) -> None: + """Transfer the current queue to another player.""" + if not source_player: + # no source player given; try to find a playing player(queue) + for queue in self.mass.player_queues: + if queue.state == MassPlayerState.PLAYING: + source_queue_id = queue.queue_id + break + else: + raise HomeAssistantError( + "Source player not specified and no playing player found." + ) + else: + # resolve HA entity_id to MA player_id + entity_registry = er.async_get(self.hass) + if (entity := entity_registry.async_get(source_player)) is None: + raise HomeAssistantError("Source player not available.") + source_queue_id = entity.unique_id # unique_id is the MA player_id + target_queue_id = self.player_id + await self.mass.player_queues.transfer_queue( + source_queue_id, target_queue_id, auto_play + ) + async def async_browse_media( self, media_content_type: MediaType | str | None = None, diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml new file mode 100644 index 0000000000000..00f895c4ef639 --- /dev/null +++ b/homeassistant/components/music_assistant/services.yaml @@ -0,0 +1,90 @@ +# Descriptions for Music Assistant custom services + +play_media: + target: + entity: + domain: media_player + integration: music_assistant + supported_features: + - media_player.MediaPlayerEntityFeature.PLAY_MEDIA + fields: + media_id: + required: true + example: "spotify://playlist/aabbccddeeff" + selector: + object: + media_type: + example: "playlist" + selector: + select: + translation_key: media_type + options: + - artist + - album + - playlist + - track + - radio + artist: + example: "Queen" + selector: + text: + album: + example: "News of the world" + selector: + text: + enqueue: + selector: + select: + options: + - "play" + - "replace" + - "next" + - "replace_next" + - "add" + translation_key: enqueue + radio_mode: + advanced: true + selector: + boolean: + +play_announcement: + target: + entity: + domain: media_player + integration: music_assistant + supported_features: + - media_player.MediaPlayerEntityFeature.PLAY_MEDIA + - media_player.MediaPlayerEntityFeature.MEDIA_ANNOUNCE + fields: + url: + required: true + example: "http://someremotesite.com/doorbell.mp3" + selector: + text: + use_pre_announce: + example: "true" + selector: + boolean: + announce_volume: + example: 75 + selector: + number: + min: 1 + max: 100 + step: 1 + +transfer_queue: + target: + entity: + domain: media_player + integration: music_assistant + fields: + source_player: + selector: + entity: + domain: media_player + integration: music_assistant + auto_play: + example: "true" + selector: + boolean: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index f15b0b1b3065f..cce7f9607c26b 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -37,6 +37,70 @@ "description": "Check if there are updates available for the Music Assistant Server and/or integration." } }, + "services": { + "play_media": { + "name": "Play media", + "description": "Play media on a Music Assistant player with more fine-grained control options.", + "fields": { + "media_id": { + "name": "Media ID(s)", + "description": "URI or name of the item you want to play. Specify a list if you want to play/enqueue multiple items." + }, + "media_type": { + "name": "Media type", + "description": "The type of the content to play. Such as artist, album, track or playlist. Will be auto-determined if omitted." + }, + "enqueue": { + "name": "Enqueue", + "description": "If the content should be played now or added to the queue." + }, + "artist": { + "name": "Artist name", + "description": "When specifying a track or album by name in the Media ID field, you can optionally restrict results by this artist name." + }, + "album": { + "name": "Album name", + "description": "When specifying a track by name in the Media ID field, you can optionally restrict results by this album name." + }, + "radio_mode": { + "name": "Enable radio mode", + "description": "Enable radio mode to auto-generate a playlist based on the selection." + } + } + }, + "play_announcement": { + "name": "Play announcement", + "description": "Play announcement on a Music Assistant player with more fine-grained control options.", + "fields": { + "url": { + "name": "URL", + "description": "URL to the notification sound." + }, + "use_pre_announce": { + "name": "Use pre-announce", + "description": "Use pre-announcement sound for the announcement. Omit to use the player default." + }, + "announce_volume": { + "name": "Announce volume", + "description": "Use a forced volume level for the announcement. Omit to use player default." + } + } + }, + "transfer_queue": { + "name": "Transfer queue", + "description": "Transfer the player's queue to another player.", + "fields": { + "source_player": { + "name": "Source media player", + "description": "The source media player which has the queue you want to transfer. When omitted, the first playing player will be used." + }, + "auto_play": { + "name": "Auto play", + "description": "Start playing the queue on the target player. Omit to use the default behavior." + } + } + } + }, "selector": { "enqueue": { "options": { @@ -46,6 +110,15 @@ "replace": "Play now and clear queue", "replace_next": "Play next and clear queue" } + }, + "media_type": { + "options": { + "artist": "Artist", + "album": "Album", + "track": "Track", + "playlist": "Playlist", + "radio": "Radio" + } } } } diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 2054ce1e6aa73..26ed5d1e53820 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -2,9 +2,13 @@ from unittest.mock import MagicMock, call +from music_assistant_models.enums import MediaType, QueueOption +from music_assistant_models.media_items import Track +import pytest from syrupy import SnapshotAssertion from homeassistant.components.media_player import ( + ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_REPEAT, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SHUFFLE, @@ -13,6 +17,23 @@ DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, ) +from homeassistant.components.music_assistant.const import DOMAIN as MASS_DOMAIN +from homeassistant.components.music_assistant.media_player import ( + ATTR_ALBUM, + ATTR_ANNOUNCE_VOLUME, + ATTR_ARTIST, + ATTR_AUTO_PLAY, + ATTR_MEDIA_ID, + ATTR_MEDIA_TYPE, + ATTR_RADIO_MODE, + ATTR_SOURCE_PLAYER, + ATTR_URL, + ATTR_USE_PRE_ANNOUNCE, + SERVICE_PLAY_ANNOUNCEMENT, + SERVICE_PLAY_MEDIA_ADVANCED, + SERVICE_TRANSFER_QUEUE, +) +from homeassistant.config_entries import HomeAssistantError from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_MEDIA_NEXT_TRACK, @@ -35,6 +56,15 @@ from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities +from tests.common import AsyncMock + +MOCK_TRACK = Track( + item_id="1", + provider="library", + name="Test Track", + provider_mappings={}, +) + async def test_media_player( hass: HomeAssistant, @@ -110,11 +140,11 @@ async def test_media_player_seek_action( ) -async def test_media_player_volume_action( +async def test_media_player_volume_set_action( hass: HomeAssistant, music_assistant_client: MagicMock, ) -> None: - """Test media_player entity volume action.""" + """Test media_player entity volume_set action.""" await setup_integration_from_fixtures(hass, music_assistant_client) entity_id = "media_player.test_player_1" mass_player_id = "00:00:00:00:00:01" @@ -261,3 +291,227 @@ async def test_media_player_clear_playlist_action( assert music_assistant_client.send_command.call_args == call( "player_queues/clear", queue_id=mass_player_id ) + + +async def test_media_player_play_media_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player (advanced) play_media action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + + # test simple play_media call with URI as media_id and no media type + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=False, + start_item=None, + ) + + # test simple play_media call with URI and enqueue specified + music_assistant_client.send_command.reset_mock() + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + ATTR_MEDIA_ENQUEUE: "add", + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=QueueOption.ADD, + radio_mode=False, + start_item=None, + ) + + # test basic play_media call with URL and radio mode specified + music_assistant_client.send_command.reset_mock() + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + ATTR_RADIO_MODE: True, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=True, + start_item=None, + ) + + # test play_media call with media id and media type specified + music_assistant_client.send_command.reset_mock() + music_assistant_client.music.get_item = AsyncMock(return_value=MOCK_TRACK) + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "1", + ATTR_MEDIA_TYPE: "track", + }, + blocking=True, + ) + assert music_assistant_client.music.get_item.call_count == 1 + assert music_assistant_client.music.get_item.call_args == call( + MediaType.TRACK, "1", "library" + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=[MOCK_TRACK.uri], + option=None, + radio_mode=False, + start_item=None, + ) + + # test play_media call by name + music_assistant_client.send_command.reset_mock() + music_assistant_client.music.get_item_by_name = AsyncMock(return_value=MOCK_TRACK) + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "test", + ATTR_ARTIST: "artist", + ATTR_ALBUM: "album", + }, + blocking=True, + ) + assert music_assistant_client.music.get_item_by_name.call_count == 1 + assert music_assistant_client.music.get_item_by_name.call_args == call( + name="test", + artist="artist", + album="album", + media_type=None, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=[MOCK_TRACK.uri], + option=None, + radio_mode=False, + start_item=None, + ) + + +async def test_media_player_play_announcement_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player play_announcement action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_ANNOUNCEMENT, + { + ATTR_ENTITY_ID: entity_id, + ATTR_URL: "http://blah.com/announcement.mp3", + ATTR_USE_PRE_ANNOUNCE: True, + ATTR_ANNOUNCE_VOLUME: 50, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/play_announcement", + player_id=mass_player_id, + url="http://blah.com/announcement.mp3", + use_pre_announce=True, + volume_level=50, + ) + + +async def test_media_player_transfer_queue_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player transfer_queu action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_TRANSFER_QUEUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SOURCE_PLAYER: "media_player.my_super_test_player_2", + ATTR_AUTO_PLAY: True, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/transfer", + source_queue_id="00:00:00:00:00:02", + target_queue_id="00:00:00:00:00:01", + auto_play=True, + require_schema=25, + ) + # test again with invalid source player + music_assistant_client.send_command.reset_mock() + with pytest.raises(HomeAssistantError, match="Source player not available."): + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_TRANSFER_QUEUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SOURCE_PLAYER: "media_player.blah_blah", + }, + blocking=True, + ) + # test again with no source player specified (which picks first playing playerqueue) + music_assistant_client.send_command.reset_mock() + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_TRANSFER_QUEUE, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/transfer", + source_queue_id="test_group_player_1", + target_queue_id="00:00:00:00:00:01", + auto_play=None, + require_schema=25, + ) From e4e9d76b452a44be284b4ca552c4c836fac925de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 27 Nov 2024 16:45:53 +0000 Subject: [PATCH 0960/1070] Raise error if sensor has translated and hardcoded unit (#131657) --- homeassistant/components/sensor/__init__.py | 6 ++++ tests/components/sensor/test_init.py | 32 ++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index c01eead7e991e..f1864458ce8a8 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -547,6 +547,12 @@ def unit_of_measurement(self) -> str | None: unit_of_measurement := self.platform.default_language_platform_translations.get(translation_key) ): + if native_unit_of_measurement is not None: + raise ValueError( + f"Sensor {type(self)} from integration '{self.platform.platform_name}' " + f"has a translation key for unit_of_measurement '{unit_of_measurement}', " + f"but also has a native_unit_of_measurement '{native_unit_of_measurement}'" + ) return unit_of_measurement # Lowest priority: Native unit diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 48c8465c7dee7..19c25d819b671 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -504,7 +504,6 @@ async def test_translated_unit( entity0.entity_description = SensorEntityDescription( "test", translation_key="test_translation_key", - native_unit_of_measurement="ignored_unit", ) setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) @@ -518,6 +517,37 @@ async def test_translated_unit( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "Tests" +async def test_translated_unit_with_native_unit_raises( + hass: HomeAssistant, +) -> None: + """Test that translated unit.""" + + with patch( + "homeassistant.helpers.service.translation.async_get_translations", + return_value={ + "component.test.entity.sensor.test_translation_key.unit_of_measurement": "Tests" + }, + ): + entity0 = MockSensor( + name="Test", + native_value="123", + unique_id="very_unique", + ) + entity0.entity_description = SensorEntityDescription( + "test", + translation_key="test_translation_key", + native_unit_of_measurement="bad_unit", + ) + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + + assert await async_setup_component( + hass, "sensor", {"sensor": {"platform": "test"}} + ) + await hass.async_block_till_done() + # Setup fails so entity_id is None + assert entity0.entity_id is None + + @pytest.mark.parametrize( ( "device_class", From 1450fe0880fb5247115e46ba8a5184e86bd15765 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 27 Nov 2024 17:49:02 +0100 Subject: [PATCH 0961/1070] Improve test quality in alarm_control_panel (#130541) --- .../alarm_control_panel/__init__.py | 26 +++ .../alarm_control_panel/test_init.py | 164 ++++++------------ 2 files changed, 75 insertions(+), 115 deletions(-) diff --git a/tests/components/alarm_control_panel/__init__.py b/tests/components/alarm_control_panel/__init__.py index 1ef1161edd0f1..1f43c567844bb 100644 --- a/tests/components/alarm_control_panel/__init__.py +++ b/tests/components/alarm_control_panel/__init__.py @@ -1 +1,27 @@ """The tests for Alarm control panel platforms.""" + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + + +async def help_async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [ALARM_CONTROL_PANEL_DOMAIN] + ) + return True + + +async def help_async_unload_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Unload test config emntry.""" + return await hass.config_entries.async_unload_platforms( + config_entry, [Platform.ALARM_CONTROL_PANEL] + ) diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 1523a884b889e..58f585b40eab6 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -12,7 +12,6 @@ AlarmControlPanelEntityFeature, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, SERVICE_ALARM_ARM_AWAY, @@ -26,19 +25,18 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er, frame -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from .conftest import TEST_DOMAIN, MockAlarmControlPanel +from . import help_async_setup_entry_init, help_async_unload_entry +from .conftest import MockAlarmControlPanel from tests.common import ( MockConfigEntry, MockModule, - MockPlatform, help_test_all, import_and_test_deprecated_constant_enum, mock_integration, - mock_platform, + setup_test_component_platform, ) @@ -323,24 +321,6 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop ) -> None: """Test incorrectly using state property does log issue and raise repair.""" - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups( - config_entry, [ALARM_CONTROL_PANEL_DOMAIN] - ) - return True - - mock_integration( - hass, - MockModule( - TEST_DOMAIN, - async_setup_entry=async_setup_entry_init, - ), - built_in=False, - ) - class MockLegacyAlarmControlPanel(MockAlarmControlPanel): """Mocked alarm control entity.""" @@ -365,36 +345,33 @@ def state(self) -> str: code_format=code_format, code_arm_required=code_arm_required, ) - - async def async_setup_entry_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test alarm control panel platform via config entry.""" - async_add_entities([entity]) - - mock_platform( + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + mock_integration( hass, - f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}", - MockPlatform(async_setup_entry=async_setup_entry_platform), + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + built_in=False, + ) + setup_test_component_platform( + hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True ) - - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() state = hass.states.get(entity.entity_id) assert state is not None assert ( - "Detected that custom integration 'alarm_control_panel' is setting state directly." - " Entity None (.MockLegacyAlarmControlPanel'>)" - " should implement the 'alarm_state' property and return its state using the AlarmControlPanelState enum" - " at test_init.py, line 123: yield. This will stop working in Home Assistant 2025.11, please create a bug report at" - in caplog.text + "Detected that custom integration 'alarm_control_panel' is setting state" + " directly. Entity None (.MockLegacyAlarmControlPanel'>) should implement" + " the 'alarm_state' property and return its state using the AlarmControlPanelState" + " enum at test_init.py, line 123: yield. This will stop working in Home Assistant" + " 2025.11, please create a bug report at" in caplog.text ) @@ -409,23 +386,6 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state ) -> None: """Test incorrectly using _attr_state attribute does log issue and raise repair.""" - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups( - config_entry, [ALARM_CONTROL_PANEL_DOMAIN] - ) - return True - - mock_integration( - hass, - MockModule( - TEST_DOMAIN, - async_setup_entry=async_setup_entry_init, - ), - ) - class MockLegacyAlarmControlPanel(MockAlarmControlPanel): """Mocked alarm control entity.""" @@ -449,25 +409,20 @@ def alarm_disarm(self, code: str | None = None) -> None: code_format=code_format, code_arm_required=code_arm_required, ) - - async def async_setup_entry_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test alarm control panel platform via config entry.""" - async_add_entities([entity]) - - mock_platform( + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + mock_integration( hass, - f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}", - MockPlatform(async_setup_entry=async_setup_entry_platform), + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform( + hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True ) - - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() state = hass.states.get(entity.entity_id) assert state is not None @@ -485,10 +440,11 @@ async def async_setup_entry_platform( "Detected that custom integration 'alarm_control_panel' is setting state directly." " Entity alarm_control_panel.test_alarm_control_panel" " (.MockLegacyAlarmControlPanel'>)" - " should implement the 'alarm_state' property and return its state using the AlarmControlPanelState enum" - " at test_init.py, line 123: yield. This will stop working in Home Assistant 2025.11, please create a bug report at" - in caplog.text + "test_alarm_control_panel_log_deprecated_state_warning_using_attr_state_attr." + ".MockLegacyAlarmControlPanel'>) should implement the 'alarm_state' property" + " and return its state using the AlarmControlPanelState enum at test_init.py, line 123:" + " yield. This will stop working in Home Assistant 2025.11," + " please create a bug report at" in caplog.text ) caplog.clear() await help_test_async_alarm_control_panel_service( @@ -512,23 +468,6 @@ async def test_alarm_control_panel_deprecated_state_does_not_break_state( ) -> None: """Test using _attr_state attribute does not break state.""" - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups( - config_entry, [ALARM_CONTROL_PANEL_DOMAIN] - ) - return True - - mock_integration( - hass, - MockModule( - TEST_DOMAIN, - async_setup_entry=async_setup_entry_init, - ), - ) - class MockLegacyAlarmControlPanel(MockAlarmControlPanel): """Mocked alarm control entity.""" @@ -553,25 +492,20 @@ def alarm_disarm(self, code: str | None = None) -> None: code_format=code_format, code_arm_required=code_arm_required, ) - - async def async_setup_entry_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test alarm control panel platform via config entry.""" - async_add_entities([entity]) - - mock_platform( + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + mock_integration( hass, - f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}", - MockPlatform(async_setup_entry=async_setup_entry_platform), + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform( + hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True ) - - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() state = hass.states.get(entity.entity_id) assert state is not None From fda178da23b533243df2f4e6a72ce4be36dd661f Mon Sep 17 00:00:00 2001 From: Lutz Date: Wed, 27 Nov 2024 18:03:21 +0100 Subject: [PATCH 0962/1070] Add video event proxy endpoint for unifiprotect (#129980) Co-authored-by: J. Nick Koston --- .../components/unifiprotect/__init__.py | 3 +- .../components/unifiprotect/views.py | 118 ++++++++-- tests/components/unifiprotect/test_views.py | 217 ++++++++++++++++++ 3 files changed, 313 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 394a7f4332976..ed409a6eea045 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -45,7 +45,7 @@ async_create_api_client, async_get_devices, ) -from .views import ThumbnailProxyView, VideoProxyView +from .views import ThumbnailProxyView, VideoEventProxyView, VideoProxyView _LOGGER = logging.getLogger(__name__) @@ -174,6 +174,7 @@ async def _async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) hass.http.register_view(ThumbnailProxyView(hass)) hass.http.register_view(VideoProxyView(hass)) + hass.http.register_view(VideoEventProxyView(hass)) async def _async_options_updated(hass: HomeAssistant, entry: UFPConfigEntry) -> None: diff --git a/homeassistant/components/unifiprotect/views.py b/homeassistant/components/unifiprotect/views.py index 00128492c677b..9bf6ed024f5d7 100644 --- a/homeassistant/components/unifiprotect/views.py +++ b/homeassistant/components/unifiprotect/views.py @@ -5,7 +5,7 @@ from datetime import datetime from http import HTTPStatus import logging -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlencode from aiohttp import web @@ -30,7 +30,9 @@ def async_generate_thumbnail_url( ) -> str: """Generate URL for event thumbnail.""" - url_format = ThumbnailProxyView.url or "{nvr_id}/{event_id}" + url_format = ThumbnailProxyView.url + if TYPE_CHECKING: + assert url_format is not None url = url_format.format(nvr_id=nvr_id, event_id=event_id) params = {} @@ -50,7 +52,9 @@ def async_generate_event_video_url(event: Event) -> str: if event.start is None or event.end is None: raise ValueError("Event is ongoing") - url_format = VideoProxyView.url or "{nvr_id}/{camera_id}/{start}/{end}" + url_format = VideoProxyView.url + if TYPE_CHECKING: + assert url_format is not None return url_format.format( nvr_id=event.api.bootstrap.nvr.id, camera_id=event.camera_id, @@ -59,6 +63,19 @@ def async_generate_event_video_url(event: Event) -> str: ) +@callback +def async_generate_proxy_event_video_url( + nvr_id: str, + event_id: str, +) -> str: + """Generate proxy URL for event video.""" + + url_format = VideoEventProxyView.url + if TYPE_CHECKING: + assert url_format is not None + return url_format.format(nvr_id=nvr_id, event_id=event_id) + + @callback def _client_error(message: Any, code: HTTPStatus) -> web.Response: _LOGGER.warning("Client error (%s): %s", code.value, message) @@ -107,6 +124,27 @@ def _get_data_or_404(self, nvr_id_or_entry_id: str) -> ProtectData | web.Respons return data return _404("Invalid NVR ID") + @callback + def _async_get_camera(self, data: ProtectData, camera_id: str) -> Camera | None: + if (camera := data.api.bootstrap.cameras.get(camera_id)) is not None: + return camera + + entity_registry = er.async_get(self.hass) + device_registry = dr.async_get(self.hass) + + if (entity := entity_registry.async_get(camera_id)) is None or ( + device := device_registry.async_get(entity.device_id or "") + ) is None: + return None + + macs = [c[1] for c in device.connections if c[0] == dr.CONNECTION_NETWORK_MAC] + for mac in macs: + if (ufp_device := data.api.bootstrap.get_device_from_mac(mac)) is not None: + if isinstance(ufp_device, Camera): + camera = ufp_device + break + return camera + class ThumbnailProxyView(ProtectProxyView): """View to proxy event thumbnails from UniFi Protect.""" @@ -156,27 +194,6 @@ class VideoProxyView(ProtectProxyView): url = "/api/unifiprotect/video/{nvr_id}/{camera_id}/{start}/{end}" name = "api:unifiprotect_thumbnail" - @callback - def _async_get_camera(self, data: ProtectData, camera_id: str) -> Camera | None: - if (camera := data.api.bootstrap.cameras.get(camera_id)) is not None: - return camera - - entity_registry = er.async_get(self.hass) - device_registry = dr.async_get(self.hass) - - if (entity := entity_registry.async_get(camera_id)) is None or ( - device := device_registry.async_get(entity.device_id or "") - ) is None: - return None - - macs = [c[1] for c in device.connections if c[0] == dr.CONNECTION_NETWORK_MAC] - for mac in macs: - if (ufp_device := data.api.bootstrap.get_device_from_mac(mac)) is not None: - if isinstance(ufp_device, Camera): - camera = ufp_device - break - return camera - async def get( self, request: web.Request, nvr_id: str, camera_id: str, start: str, end: str ) -> web.StreamResponse: @@ -226,3 +243,56 @@ async def iterator(total: int, chunk: bytes | None) -> None: if response.prepared: await response.write_eof() return response + + +class VideoEventProxyView(ProtectProxyView): + """View to proxy video clips for events from UniFi Protect.""" + + url = "/api/unifiprotect/video/{nvr_id}/{event_id}" + name = "api:unifiprotect_videoEventView" + + async def get( + self, request: web.Request, nvr_id: str, event_id: str + ) -> web.StreamResponse: + """Get Camera Video clip for an event.""" + + data = self._get_data_or_404(nvr_id) + if isinstance(data, web.Response): + return data + + try: + event = await data.api.get_event(event_id) + except ClientError: + return _404(f"Invalid event ID: {event_id}") + if event.start is None or event.end is None: + return _400("Event is still ongoing") + camera = self._async_get_camera(data, str(event.camera_id)) + if camera is None: + return _404(f"Invalid camera ID: {event.camera_id}") + if not camera.can_read_media(data.api.bootstrap.auth_user): + return _403(f"User cannot read media from camera: {camera.id}") + + response = web.StreamResponse( + status=200, + reason="OK", + headers={ + "Content-Type": "video/mp4", + }, + ) + + async def iterator(total: int, chunk: bytes | None) -> None: + if not response.prepared: + response.content_length = total + await response.prepare(request) + + if chunk is not None: + await response.write(chunk) + + try: + await camera.get_video(event.start, event.end, iterator_callback=iterator) + except ClientError as err: + return _404(err) + + if response.prepared: + await response.write_eof() + return response diff --git a/tests/components/unifiprotect/test_views.py b/tests/components/unifiprotect/test_views.py index fed0a98552d7e..0f1b779168045 100644 --- a/tests/components/unifiprotect/test_views.py +++ b/tests/components/unifiprotect/test_views.py @@ -11,6 +11,7 @@ from homeassistant.components.unifiprotect.views import ( async_generate_event_video_url, + async_generate_proxy_event_video_url, async_generate_thumbnail_url, ) from homeassistant.core import HomeAssistant @@ -520,3 +521,219 @@ async def test_video_entity_id( assert response.status == 200 ufp.api.request.assert_called_once() + + +async def test_video_event_bad_nvr_id( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + camera: Camera, + ufp: MockUFPFixture, +) -> None: + """Test video proxy URL with bad NVR id.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_proxy_event_video_url("bad_id", "test_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_video_event_bad_event( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, +) -> None: + """Test generating event with bad event ID.""" + + ufp.api.get_event = AsyncMock(side_effect=ClientError()) + + await init_entry(hass, ufp, [camera]) + url = async_generate_proxy_event_video_url(ufp.api.bootstrap.nvr.id, "bad_event_id") + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_video_event_bad_camera( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, +) -> None: + """Test generating event with bad camera ID.""" + + ufp.api.get_event = AsyncMock(side_effect=ClientError()) + + await init_entry(hass, ufp, [camera]) + url = async_generate_proxy_event_video_url(ufp.api.bootstrap.nvr.id, "bad_event_id") + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_video_event_bad_camera_perms( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test video URL with bad camera perms.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + event_start = fixed_now - timedelta(seconds=30) + event = Event( + model=ModelType.EVENT, + api=ufp.api, + start=event_start, + end=fixed_now, + id="test_id", + type=EventType.MOTION, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id="bad_id", + camera=camera, + ) + + ufp.api.get_event = AsyncMock(return_value=event) + + url = async_generate_proxy_event_video_url(ufp.api.bootstrap.nvr.id, "test_id") + + ufp.api.bootstrap.auth_user.all_permissions = [] + ufp.api.bootstrap.auth_user._perm_cache = {} + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_video_event_ongoing( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test video URL with ongoing event.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + event_start = fixed_now - timedelta(seconds=30) + event = Event( + model=ModelType.EVENT, + api=ufp.api, + start=event_start, + id="test_id", + type=EventType.MOTION, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=camera.id, + camera=camera, + ) + + ufp.api.get_event = AsyncMock(return_value=event) + + url = async_generate_proxy_event_video_url(ufp.api.bootstrap.nvr.id, "test_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 400 + ufp.api.request.assert_not_called() + + +async def test_event_video_no_data( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test invalid no event video returned.""" + + await init_entry(hass, ufp, [camera]) + event_start = fixed_now - timedelta(seconds=30) + event = Event( + model=ModelType.EVENT, + api=ufp.api, + start=event_start, + end=fixed_now, + id="test_id", + type=EventType.MOTION, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=camera.id, + camera=camera, + ) + + ufp.api.request = AsyncMock(side_effect=ClientError) + ufp.api.get_event = AsyncMock(return_value=event) + + url = async_generate_proxy_event_video_url(ufp.api.bootstrap.nvr.id, "test_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + + +async def test_event_video( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test event video URL with no video.""" + + content = Mock() + content.__anext__ = AsyncMock(side_effect=[b"test", b"test", StopAsyncIteration()]) + content.__aiter__ = Mock(return_value=content) + + mock_response = Mock() + mock_response.content_length = 8 + mock_response.content.iter_chunked = Mock(return_value=content) + + ufp.api.request = AsyncMock(return_value=mock_response) + await init_entry(hass, ufp, [camera]) + event_start = fixed_now - timedelta(seconds=30) + event = Event( + model=ModelType.EVENT, + api=ufp.api, + start=event_start, + end=fixed_now, + id="test_id", + type=EventType.MOTION, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=camera.id, + camera=camera, + ) + + ufp.api.get_event = AsyncMock(return_value=event) + + url = async_generate_proxy_event_video_url(ufp.api.bootstrap.nvr.id, "test_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + assert await response.content.read() == b"testtest" + + assert response.status == 200 + ufp.api.request.assert_called_once() From 1f1fdf80db756a0a50a95a28caa964ac8c423bcd Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 27 Nov 2024 18:03:34 +0100 Subject: [PATCH 0963/1070] Unifiprotect replace direct mocks with MockConfigEntry for test_async_ufp_instance_for_config_entry_ids (#131736) Co-authored-by: J. Nick Koston --- tests/components/unifiprotect/test_init.py | 43 ++++++++++------------ 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 457fd9d3b975b..0d88754a11091 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -297,15 +297,15 @@ async def test_device_remove_devices_nvr( [ pytest.param( [ - Mock( - spec=ConfigEntry, + MockConfigEntry( domain=DOMAIN, - runtime_data=Mock(api="mock_api_instance_1"), + entry_id="1", + data={}, ), - Mock( - spec=ConfigEntry, + MockConfigEntry( domain="other_domain", - runtime_data=Mock(api="mock_api_instance_2"), + entry_id="2", + data={}, ), ], "mock_api_instance_1", @@ -313,15 +313,15 @@ async def test_device_remove_devices_nvr( ), pytest.param( [ - Mock( - spec=ConfigEntry, + MockConfigEntry( domain="other_domain", - runtime_data=Mock(api="mock_api_instance_1"), + entry_id="1", + data={}, ), - Mock( - spec=ConfigEntry, + MockConfigEntry( domain="other_domain", - runtime_data=Mock(api="mock_api_instance_2"), + entry_id="2", + data={}, ), ], None, @@ -330,22 +330,17 @@ async def test_device_remove_devices_nvr( ], ) async def test_async_ufp_instance_for_config_entry_ids( - mock_entries, expected_result + hass: HomeAssistant, + mock_entries: list[MockConfigEntry], + expected_result: str | None, ) -> None: """Test async_ufp_instance_for_config_entry_ids with various entry configurations.""" - hass = Mock(spec=HomeAssistant) - - mock_entry_mapping = { - str(index): entry for index, entry in enumerate(mock_entries, start=1) - } - - def mock_async_get_entry(entry_id): - return mock_entry_mapping.get(entry_id) - - hass.config_entries.async_get_entry = Mock(side_effect=mock_async_get_entry) + for index, entry in enumerate(mock_entries): + entry.add_to_hass(hass) + entry.runtime_data = Mock(api=f"mock_api_instance_{index + 1}") - entry_ids = set(mock_entry_mapping.keys()) + entry_ids = {entry.entry_id for entry in mock_entries} result = async_ufp_instance_for_config_entry_ids(hass, entry_ids) From ae34a6b375e2266333892c61e33b58c3e0828739 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 27 Nov 2024 12:04:08 -0500 Subject: [PATCH 0964/1070] Do not double expose scripts in LLM tools (#131726) --- homeassistant/helpers/llm.py | 12 ++++-------- tests/helpers/test_llm.py | 4 ---- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 49ae1455006ff..38d80d5649d00 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -445,17 +445,13 @@ def _get_exposed_entities( entities = {} for state in hass.states.async_all(): - if not async_should_expose(hass, assistant, state.entity_id): + if ( + not async_should_expose(hass, assistant, state.entity_id) + or state.domain == SCRIPT_DOMAIN + ): continue description: str | None = None - if state.domain == SCRIPT_DOMAIN: - description, parameters = _get_cached_script_parameters( - hass, state.entity_id - ) - if parameters.schema: # Only list scripts without input fields here - continue - entity_entry = entity_registry.async_get(state.entity_id) names = [state.name] area_names = [] diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 4b2fc9e5fc1b9..3787526c433fc 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -517,10 +517,6 @@ def create_entity( ) ) exposed_entities_prompt = """An overview of the areas and the devices in this smart home: -- names: script_with_no_fields - domain: script - state: 'off' - description: This is another test script - names: Kitchen domain: light state: 'on' From e8975cffe6d559c4fa222a4ab4e74afdf49d0b92 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 27 Nov 2024 18:07:26 +0100 Subject: [PATCH 0965/1070] Update hash regex for frontend file in tests (#131742) --- tests/components/frontend/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 5006adedd77ff..5a6822771764e 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -166,7 +166,7 @@ async def test_frontend_and_static(mock_http_client: TestClient) -> None: text = await resp.text() # Test we can retrieve frontend.js - frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9_-]{11}.js)", text) + frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9_-]{16}.js)", text) assert frontendjs is not None, text resp = await mock_http_client.get(frontendjs.groups(0)[0]) @@ -689,7 +689,7 @@ async def test_auth_authorize(mock_http_client: TestClient) -> None: # Test we can retrieve authorize.js authorizejs = re.search( - r"(?P\/frontend_latest\/authorize.[A-Za-z0-9_-]{11}.js)", text + r"(?P\/frontend_latest\/authorize.[A-Za-z0-9_-]{16}.js)", text ) assert authorizejs is not None, text From a6cb6fd2395fb1cf6045a29dddf150be9626cbc4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 27 Nov 2024 18:12:46 +0100 Subject: [PATCH 0966/1070] Create MQTT device referenced by via device (#131588) --- homeassistant/components/mqtt/entity.py | 31 ++++++ tests/components/mqtt/test_discovery.py | 136 ++++++++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 46b2c9e1d42b5..c73e1975a68c1 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -1185,6 +1185,33 @@ def device_info_from_specifications( return info +@callback +def ensure_via_device_exists( + hass: HomeAssistant, device_info: DeviceInfo | None, config_entry: ConfigEntry +) -> None: + """Ensure the via device is in the device registry.""" + if ( + device_info is None + or CONF_VIA_DEVICE not in device_info + or (device_registry := dr.async_get(hass)).async_get_device( + identifiers={device_info["via_device"]} + ) + ): + return + + # Ensure the via device exists in the device registry + _LOGGER.debug( + "Device identifier %s via_device reference from device_info %s " + "not found in the Device Registry, creating new entry", + device_info["via_device"], + device_info, + ) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={device_info["via_device"]}, + ) + + class MqttEntityDeviceInfo(Entity): """Mixin used for mqtt platforms that support the device registry.""" @@ -1203,6 +1230,7 @@ def device_info_discovery_update(self, config: DiscoveryInfoType) -> None: device_info = self.device_info if device_info is not None: + ensure_via_device_exists(self.hass, device_info, self._config_entry) device_registry.async_get_or_create( config_entry_id=config_entry_id, **device_info ) @@ -1256,6 +1284,7 @@ def __init__( self, hass, discovery_data, self.discovery_update ) MqttEntityDeviceInfo.__init__(self, config.get(CONF_DEVICE), config_entry) + ensure_via_device_exists(self.hass, self.device_info, self._config_entry) def _init_entity_id(self) -> None: """Set entity_id from object_id if defined in config.""" @@ -1490,6 +1519,8 @@ def update_device( config_entry_id = config_entry.entry_id device_info = device_info_from_specifications(config[CONF_DEVICE]) + ensure_via_device_exists(hass, device_info, config_entry) + if config_entry_id is not None and device_info is not None: update_device_info = cast(dict[str, Any], device_info) update_device_info["config_entry_id"] = config_entry_id diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index e49e7a27c8d46..8a674a4e1cdbf 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -2987,3 +2987,139 @@ async def test_shared_state_topic( state = hass.states.get(entity_id) assert state is not None assert state.state == "New state3" + + +@pytest.mark.parametrize("single_configs", [copy.deepcopy(TEST_SINGLE_CONFIGS)]) +async def test_discovery_with_late_via_device_discovery( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, + tag_mock: AsyncMock, + single_configs: list[tuple[str, dict[str, Any]]], +) -> None: + """Test a via device is available and the discovery of the via device is late.""" + await mqtt_mock_entry() + + await hass.async_block_till_done() + await hass.async_block_till_done() + + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + assert via_device_entry is None + # Discovery single config schema + for discovery_topic, config in single_configs: + config["device"]["via_device"] = "id_via_very_unique" + payload = json.dumps(config) + async_fire_mqtt_message( + hass, + discovery_topic, + payload, + ) + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + assert via_device_entry is not None + assert via_device_entry.name is None + + await hass.async_block_till_done() + + # Now discover the via device (a switch) + via_device_config = { + "name": None, + "command_topic": "test-switch-topic", + "unique_id": "very_unique_switch", + "device": {"identifiers": ["id_via_very_unique"], "name": "My Switch"}, + } + payload = json.dumps(via_device_config) + via_device_discovery_topic = "homeassistant/switch/very_unique/config" + async_fire_mqtt_message( + hass, + via_device_discovery_topic, + payload, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + assert via_device_entry is not None + assert via_device_entry.name == "My Switch" + + await help_check_discovered_items(hass, device_registry, tag_mock) + + +@pytest.mark.parametrize("single_configs", [copy.deepcopy(TEST_SINGLE_CONFIGS)]) +async def test_discovery_with_late_via_device_update( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, + tag_mock: AsyncMock, + single_configs: list[tuple[str, dict[str, Any]]], +) -> None: + """Test a via device is available and the discovery of the via device is is set via an update.""" + await mqtt_mock_entry() + + await hass.async_block_till_done() + await hass.async_block_till_done() + + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + assert via_device_entry is None + # Discovery single config schema without via device + for discovery_topic, config in single_configs: + payload = json.dumps(config) + async_fire_mqtt_message( + hass, + discovery_topic, + payload, + ) + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert via_device_entry is None + + # Resend the discovery update to set the via device + for discovery_topic, config in single_configs: + config["device"]["via_device"] = "id_via_very_unique" + payload = json.dumps(config) + async_fire_mqtt_message( + hass, + discovery_topic, + payload, + ) + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + assert via_device_entry is not None + assert via_device_entry.name is None + + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Now discover the via device (a switch) + via_device_config = { + "name": None, + "command_topic": "test-switch-topic", + "unique_id": "very_unique_switch", + "device": {"identifiers": ["id_via_very_unique"], "name": "My Switch"}, + } + payload = json.dumps(via_device_config) + via_device_discovery_topic = "homeassistant/switch/very_unique/config" + async_fire_mqtt_message( + hass, + via_device_discovery_topic, + payload, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + assert via_device_entry is not None + assert via_device_entry.name == "My Switch" + + await help_check_discovered_items(hass, device_registry, tag_mock) From e04b6f0cd86c0490ff5a9c74b79b653de9a41a60 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 27 Nov 2024 18:17:53 +0100 Subject: [PATCH 0967/1070] Add quality scale hassfest check for config-entry-unload (#131720) * Add dataclass to hassfest quality_scale * Add basic check for config-entry-unloading * Future-proof with a list of errors --- script/hassfest/quality_scale.py | 167 ++++++++++-------- .../quality_scale_validation/__init__.py | 15 ++ .../config_entry_unloading.py | 26 +++ 3 files changed, 137 insertions(+), 71 deletions(-) create mode 100644 script/hassfest/quality_scale_validation/__init__.py create mode 100644 script/hassfest/quality_scale_validation/config_entry_unloading.py diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index d842b17f98d8b..980d659b03ee9 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2,6 +2,8 @@ from __future__ import annotations +from dataclasses import dataclass + import voluptuous as vol from voluptuous.humanize import humanize_error @@ -10,72 +12,88 @@ from homeassistant.util.yaml import load_yaml_dict from .model import Config, Integration, ScaledQualityScaleTiers +from .quality_scale_validation import RuleValidationProtocol, config_entry_unloading QUALITY_SCALE_TIERS = {value.name.lower(): value for value in ScaledQualityScaleTiers} -RULES = { - ScaledQualityScaleTiers.BRONZE: [ - "action-setup", - "appropriate-polling", - "brands", - "common-modules", - "config-flow", - "config-flow-test-coverage", - "dependency-transparency", - "docs-actions", - "docs-high-level-description", - "docs-installation-instructions", - "docs-removal-instructions", - "entity-event-setup", - "entity-unique-id", - "has-entity-name", - "runtime-data", - "test-before-configure", - "test-before-setup", - "unique-config-entry", - ], - ScaledQualityScaleTiers.SILVER: [ - "action-exceptions", - "config-entry-unloading", - "docs-configuration-parameters", - "docs-installation-parameters", - "entity-unavailable", - "integration-owner", - "log-when-unavailable", - "parallel-updates", - "reauthentication-flow", - "test-coverage", - ], - ScaledQualityScaleTiers.GOLD: [ - "devices", - "diagnostics", - "discovery", - "discovery-update-info", - "docs-data-update", - "docs-examples", - "docs-known-limitations", - "docs-supported-devices", - "docs-supported-functions", - "docs-troubleshooting", - "docs-use-cases", - "dynamic-devices", - "entity-category", - "entity-device-class", - "entity-disabled-by-default", - "entity-translations", - "exception-translations", - "icon-translations", - "reconfiguration-flow", - "repair-issues", - "stale-devices", - ], - ScaledQualityScaleTiers.PLATINUM: [ - "async-dependency", - "inject-websession", - "strict-typing", - ], + +@dataclass +class Rule: + """Quality scale rules.""" + + name: str + tier: ScaledQualityScaleTiers + validator: RuleValidationProtocol | None = None + + +ALL_RULES = [ + # BRONZE + Rule("action-setup", ScaledQualityScaleTiers.BRONZE), + Rule("appropriate-polling", ScaledQualityScaleTiers.BRONZE), + Rule("brands", ScaledQualityScaleTiers.BRONZE), + Rule("common-modules", ScaledQualityScaleTiers.BRONZE), + Rule("config-flow", ScaledQualityScaleTiers.BRONZE), + Rule("config-flow-test-coverage", ScaledQualityScaleTiers.BRONZE), + Rule("dependency-transparency", ScaledQualityScaleTiers.BRONZE), + Rule("docs-actions", ScaledQualityScaleTiers.BRONZE), + Rule("docs-high-level-description", ScaledQualityScaleTiers.BRONZE), + Rule("docs-installation-instructions", ScaledQualityScaleTiers.BRONZE), + Rule("docs-removal-instructions", ScaledQualityScaleTiers.BRONZE), + Rule("entity-event-setup", ScaledQualityScaleTiers.BRONZE), + Rule("entity-unique-id", ScaledQualityScaleTiers.BRONZE), + Rule("has-entity-name", ScaledQualityScaleTiers.BRONZE), + Rule("runtime-data", ScaledQualityScaleTiers.BRONZE), + Rule("test-before-configure", ScaledQualityScaleTiers.BRONZE), + Rule("test-before-setup", ScaledQualityScaleTiers.BRONZE), + Rule("unique-config-entry", ScaledQualityScaleTiers.BRONZE), + # SILVER + Rule("action-exceptions", ScaledQualityScaleTiers.SILVER), + Rule( + "config-entry-unloading", ScaledQualityScaleTiers.SILVER, config_entry_unloading + ), + Rule("docs-configuration-parameters", ScaledQualityScaleTiers.SILVER), + Rule("docs-installation-parameters", ScaledQualityScaleTiers.SILVER), + Rule("entity-unavailable", ScaledQualityScaleTiers.SILVER), + Rule("integration-owner", ScaledQualityScaleTiers.SILVER), + Rule("log-when-unavailable", ScaledQualityScaleTiers.SILVER), + Rule("parallel-updates", ScaledQualityScaleTiers.SILVER), + Rule("reauthentication-flow", ScaledQualityScaleTiers.SILVER), + Rule("test-coverage", ScaledQualityScaleTiers.SILVER), + # GOLD: [ + Rule("devices", ScaledQualityScaleTiers.GOLD), + Rule("diagnostics", ScaledQualityScaleTiers.GOLD), + Rule("discovery", ScaledQualityScaleTiers.GOLD), + Rule("discovery-update-info", ScaledQualityScaleTiers.GOLD), + Rule("docs-data-update", ScaledQualityScaleTiers.GOLD), + Rule("docs-examples", ScaledQualityScaleTiers.GOLD), + Rule("docs-known-limitations", ScaledQualityScaleTiers.GOLD), + Rule("docs-supported-devices", ScaledQualityScaleTiers.GOLD), + Rule("docs-supported-functions", ScaledQualityScaleTiers.GOLD), + Rule("docs-troubleshooting", ScaledQualityScaleTiers.GOLD), + Rule("docs-use-cases", ScaledQualityScaleTiers.GOLD), + Rule("dynamic-devices", ScaledQualityScaleTiers.GOLD), + Rule("entity-category", ScaledQualityScaleTiers.GOLD), + Rule("entity-device-class", ScaledQualityScaleTiers.GOLD), + Rule("entity-disabled-by-default", ScaledQualityScaleTiers.GOLD), + Rule("entity-translations", ScaledQualityScaleTiers.GOLD), + Rule("exception-translations", ScaledQualityScaleTiers.GOLD), + Rule("icon-translations", ScaledQualityScaleTiers.GOLD), + Rule("reconfiguration-flow", ScaledQualityScaleTiers.GOLD), + Rule("repair-issues", ScaledQualityScaleTiers.GOLD), + Rule("stale-devices", ScaledQualityScaleTiers.GOLD), + # PLATINUM + Rule("async-dependency", ScaledQualityScaleTiers.PLATINUM), + Rule("inject-websession", ScaledQualityScaleTiers.PLATINUM), + Rule("strict-typing", ScaledQualityScaleTiers.PLATINUM), +] + +SCALE_RULES = { + tier: [rule.name for rule in ALL_RULES if rule.tier == tier] + for tier in ScaledQualityScaleTiers } +VALIDATORS = {rule.name: rule.validator for rule in ALL_RULES if rule.validator} + INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "abode", "accuweather", @@ -1244,7 +1262,7 @@ { vol.Required("rules"): vol.Schema( { - vol.Optional(rule): vol.Any( + vol.Optional(rule.name): vol.Any( vol.In(["todo", "done"]), vol.Schema( { @@ -1259,8 +1277,7 @@ } ), ) - for tier_list in RULES.values() - for rule in tier_list + for rule in ALL_RULES } ) } @@ -1327,21 +1344,29 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: "quality_scale", f"Invalid {name}: {humanize_error(data, err)}" ) - if declared_quality_scale is None: - return - - rules_met = set() + rules_met = set[str]() for rule_name, rule_value in data.get("rules", {}).items(): status = rule_value["status"] if isinstance(rule_value, dict) else rule_value - if status in {"done", "exempt"}: - rules_met.add(rule_name) + if status not in {"done", "exempt"}: + continue + rules_met.add(rule_name) + if ( + status == "done" + and (validator := VALIDATORS.get(rule_name)) + and (errors := validator.validate(integration)) + ): + for error in errors: + integration.add_error("quality_scale", f"[{rule_name}] {error}") # An integration must have all the necessary rules for the declared # quality scale, and all the rules below. + if declared_quality_scale is None: + return + for scale in ScaledQualityScaleTiers: if scale > declared_quality_scale: break - required_rules = set(RULES[scale]) + required_rules = set(SCALE_RULES[scale]) if missing_rules := (required_rules - rules_met): friendly_rule_str = "\n".join( f" {rule}: todo" for rule in sorted(missing_rules) diff --git a/script/hassfest/quality_scale_validation/__init__.py b/script/hassfest/quality_scale_validation/__init__.py new file mode 100644 index 0000000000000..836c108276396 --- /dev/null +++ b/script/hassfest/quality_scale_validation/__init__.py @@ -0,0 +1,15 @@ +"""Integration quality scale rules.""" + +from typing import Protocol + +from script.hassfest.model import Integration + + +class RuleValidationProtocol(Protocol): + """Protocol for rule validation.""" + + def validate(self, integration: Integration) -> list[str] | None: + """Validate a quality scale rule. + + Returns error (if any). + """ diff --git a/script/hassfest/quality_scale_validation/config_entry_unloading.py b/script/hassfest/quality_scale_validation/config_entry_unloading.py new file mode 100644 index 0000000000000..42134e0391e1c --- /dev/null +++ b/script/hassfest/quality_scale_validation/config_entry_unloading.py @@ -0,0 +1,26 @@ +"""Enforce that the integration implements entry unloading.""" + +import ast + +from script.hassfest.model import Integration + + +def _has_async_function(module: ast.Module, name: str) -> bool: + """Test if the module defines a function.""" + return any( + type(item) is ast.AsyncFunctionDef and item.name == name for item in module.body + ) + + +def validate(integration: Integration) -> list[str] | None: + """Validate that the integration has a config flow.""" + + init_file = integration.path / "__init__.py" + init = ast.parse(init_file.read_text()) + + if not _has_async_function(init, "async_unload_entry"): + return [ + "Integration does not support config entry unloading " + "(is missing `async_unload_entry` in __init__.py)" + ] + return None From db5c93f96dc661b4c87dc198275967232f7edf19 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 27 Nov 2024 18:36:24 +0100 Subject: [PATCH 0968/1070] Bump version to 2024.12.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 514c215461185..93a909f6aadda 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index e281a2429d05d..a9597458d6692 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.12.0.dev0" +version = "2024.12.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 3fff3003f24869ca1e12f8df430d8f201b53bb51 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 27 Nov 2024 22:37:15 +0100 Subject: [PATCH 0969/1070] Add missing data_description for lamarzocco OptionsFlow (#131708) --- homeassistant/components/lamarzocco/strings.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index f98d5c2a700ed..666eb7f4a8487 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -67,8 +67,10 @@ "step": { "init": { "data": { - "title": "Update Configuration", "use_bluetooth": "Use Bluetooth" + }, + "data_description": { + "use_bluetooth": "Should the integration try to use Bluetooth to control the machine?" } } } From 897abc114e453127fd8bb81b043ce24e30f8e828 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 28 Nov 2024 01:34:36 +0100 Subject: [PATCH 0970/1070] Bump music assistant client 1.0.8 (#131739) --- homeassistant/components/music_assistant/manifest.json | 2 +- homeassistant/components/music_assistant/media_player.py | 6 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/music_assistant/fixtures/players.json | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 65e6652407f76..f5cdcf50673d9 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.0.5"], + "requirements": ["music-assistant-client==1.0.8"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 07d6ddeee0314..d1d707c92e127 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -193,7 +193,7 @@ def __init__(self, mass: MusicAssistantClient, player_id: str) -> None: super().__init__(mass, player_id) self._attr_icon = self.player.icon.replace("mdi-", "mdi:") self._attr_supported_features = SUPPORTED_FEATURES - if PlayerFeature.SYNC in self.player.supported_features: + if PlayerFeature.SET_MEMBERS in self.player.supported_features: self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING if PlayerFeature.VOLUME_MUTE in self.player.supported_features: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE @@ -407,12 +407,12 @@ async def async_join_players(self, group_members: list[str]) -> None: if (mass_player_id := hass_state.attributes.get("mass_player_id")) is None: continue player_ids.append(mass_player_id) - await self.mass.players.player_command_sync_many(self.player_id, player_ids) + await self.mass.players.player_command_group_many(self.player_id, player_ids) @catch_musicassistant_error async def async_unjoin_player(self) -> None: """Remove this player from any group.""" - await self.mass.players.player_command_unsync(self.player_id) + await self.mass.players.player_command_ungroup(self.player_id) @catch_musicassistant_error async def _async_handle_play_media( diff --git a/requirements_all.txt b/requirements_all.txt index 5decd2975fd92..6151da007c4cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1409,7 +1409,7 @@ mozart-api==4.1.1.116.3 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.5 +music-assistant-client==1.0.8 # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f824a1f21299..f88bda8215ada 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1178,7 +1178,7 @@ mozart-api==4.1.1.116.3 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.5 +music-assistant-client==1.0.8 # homeassistant.components.tts mutagen==1.47.0 diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index b7ff304a7ee1d..2d8b88d0e8e22 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -16,7 +16,7 @@ "volume_set", "volume_mute", "pause", - "sync", + "set_members", "power", "enqueue" ], @@ -57,7 +57,7 @@ "volume_set", "volume_mute", "pause", - "sync", + "set_members", "power", "enqueue" ], @@ -109,7 +109,7 @@ "volume_set", "volume_mute", "pause", - "sync", + "set_members", "power", "enqueue" ], From 74a3d11aeae67e08e8621e971f7810f0a42db9c1 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 27 Nov 2024 13:36:17 -0800 Subject: [PATCH 0971/1070] Add a missing rainbird data description (#131740) --- homeassistant/components/rainbird/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 25d3a962b36ed..6f92b1bdb97da 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -40,6 +40,9 @@ "title": "[%key:component::rainbird::config::step::user::title%]", "data": { "duration": "Default irrigation time in minutes" + }, + "data_description": { + "duration": "The default duration the sprinkler will run when turned on." } } } From c9d3ba900ec05cf1ba9fbf08fe33580fba167856 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 Nov 2024 12:15:44 -0800 Subject: [PATCH 0972/1070] Bump aiohttp to 3.11.8 (#131744) --- homeassistant/components/http/__init__.py | 3 ++- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 3b18b44862a99..95cdee9ab9e46 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -326,7 +326,8 @@ def _make_request( protocol, writer, task, - loop=self._loop, + # loop will never be None when called from aiohttp + loop=self._loop, # type: ignore[arg-type] client_max_size=self._client_max_size, ) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a4beb141911f6..0819990cffcd0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.2.0 -aiohttp==3.11.7 +aiohttp==3.11.8 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index a9597458d6692..5f4c874702313 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.7", + "aiohttp==3.11.8", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index 5ca035921078d..28034d80394e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.7 +aiohttp==3.11.8 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 aiozoneinfo==0.2.1 From 47e7c4f1c140d8b4b0d2fe14928d617b265f4261 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 Nov 2024 16:35:49 -0800 Subject: [PATCH 0973/1070] Bump orjson to 3.10.12 (#131752) changelog: https://github.com/ijl/orjson/compare/3.10.11...3.10.12 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0819990cffcd0..691d80f31bfbc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -41,7 +41,7 @@ ifaddr==0.2.0 Jinja2==3.1.4 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.11 +orjson==3.10.12 packaging>=23.1 paho-mqtt==1.6.1 Pillow==11.0.0 diff --git a/pyproject.toml b/pyproject.toml index 5f4c874702313..68b0a7e5a59be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ dependencies = [ "Pillow==11.0.0", "propcache==0.2.0", "pyOpenSSL==24.2.1", - "orjson==3.10.11", + "orjson==3.10.12", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index 28034d80394e0..2cbdeb14b9804 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ cryptography==43.0.1 Pillow==11.0.0 propcache==0.2.0 pyOpenSSL==24.2.1 -orjson==3.10.11 +orjson==3.10.12 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From 509311ac193980b9d0c1c4261d1d0bc6c16fee55 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Nov 2024 08:07:19 +0100 Subject: [PATCH 0974/1070] Remove Spotify audio feature sensors (#131754) --- homeassistant/components/spotify/__init__.py | 2 +- .../components/spotify/coordinator.py | 22 - homeassistant/components/spotify/icons.json | 35 -- homeassistant/components/spotify/sensor.py | 179 ------ homeassistant/components/spotify/strings.json | 41 -- tests/components/spotify/conftest.py | 2 - .../spotify/fixtures/audio_features.json | 20 - .../spotify/snapshots/test_diagnostics.ambr | 14 - .../spotify/snapshots/test_sensor.ambr | 595 ------------------ tests/components/spotify/test_sensor.py | 66 -- 10 files changed, 1 insertion(+), 975 deletions(-) delete mode 100644 homeassistant/components/spotify/sensor.py delete mode 100644 tests/components/spotify/fixtures/audio_features.json delete mode 100644 tests/components/spotify/snapshots/test_sensor.ambr delete mode 100644 tests/components/spotify/test_sensor.py diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index cfcc9011b3796..37580ac432dca 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -29,7 +29,7 @@ spotify_uri_from_media_browser_url, ) -PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR] +PLATFORMS = [Platform.MEDIA_PLAYER] __all__ = [ "async_browse_media", diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index 9e62d5f137e41..a7c95e3124573 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -7,14 +7,12 @@ from spotifyaio import ( ContextType, - ItemType, PlaybackState, Playlist, SpotifyClient, SpotifyConnectionError, UserProfile, ) -from spotifyaio.models import AudioFeatures from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -39,7 +37,6 @@ class SpotifyCoordinatorData: current_playback: PlaybackState | None position_updated_at: datetime | None playlist: Playlist | None - audio_features: AudioFeatures | None dj_playlist: bool = False @@ -65,7 +62,6 @@ def __init__(self, hass: HomeAssistant, client: SpotifyClient) -> None: ) self.client = client self._playlist: Playlist | None = None - self._currently_loaded_track: str | None = None async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -84,28 +80,11 @@ async def _async_update_data(self) -> SpotifyCoordinatorData: current_playback=None, position_updated_at=None, playlist=None, - audio_features=None, ) # Record the last updated time, because Spotify's timestamp property is unreliable # and doesn't actually return the fetch time as is mentioned in the API description position_updated_at = dt_util.utcnow() - audio_features: AudioFeatures | None = None - if (item := current.item) is not None and item.type == ItemType.TRACK: - if item.uri != self._currently_loaded_track: - try: - audio_features = await self.client.get_audio_features(item.uri) - except SpotifyConnectionError: - _LOGGER.debug( - "Unable to load audio features for track '%s'. " - "Continuing without audio features", - item.uri, - ) - audio_features = None - else: - self._currently_loaded_track = item.uri - else: - audio_features = self.data.audio_features dj_playlist = False if (context := current.context) is not None: if self._playlist is None or self._playlist.uri != context.uri: @@ -128,6 +107,5 @@ async def _async_update_data(self) -> SpotifyCoordinatorData: current_playback=current, position_updated_at=position_updated_at, playlist=self._playlist, - audio_features=audio_features, dj_playlist=dj_playlist, ) diff --git a/homeassistant/components/spotify/icons.json b/homeassistant/components/spotify/icons.json index e1b08127e43f0..00c63141eae99 100644 --- a/homeassistant/components/spotify/icons.json +++ b/homeassistant/components/spotify/icons.json @@ -4,41 +4,6 @@ "spotify": { "default": "mdi:spotify" } - }, - "sensor": { - "song_tempo": { - "default": "mdi:metronome" - }, - "danceability": { - "default": "mdi:dance-ballroom" - }, - "energy": { - "default": "mdi:lightning-bolt" - }, - "mode": { - "default": "mdi:music" - }, - "speechiness": { - "default": "mdi:speaker-message" - }, - "acousticness": { - "default": "mdi:guitar-acoustic" - }, - "instrumentalness": { - "default": "mdi:guitar-electric" - }, - "valence": { - "default": "mdi:emoticon-happy" - }, - "liveness": { - "default": "mdi:music-note" - }, - "time_signature": { - "default": "mdi:music-clef-treble" - }, - "key": { - "default": "mdi:music-clef-treble" - } } } } diff --git a/homeassistant/components/spotify/sensor.py b/homeassistant/components/spotify/sensor.py deleted file mode 100644 index 3486a911b0d0d..0000000000000 --- a/homeassistant/components/spotify/sensor.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Sensor platform for Spotify.""" - -from collections.abc import Callable -from dataclasses import dataclass - -from spotifyaio.models import AudioFeatures, Key - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.const import PERCENTAGE -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .coordinator import SpotifyConfigEntry, SpotifyCoordinator -from .entity import SpotifyEntity - - -@dataclass(frozen=True, kw_only=True) -class SpotifyAudioFeaturesSensorEntityDescription(SensorEntityDescription): - """Describes Spotify sensor entity.""" - - value_fn: Callable[[AudioFeatures], float | str | None] - - -KEYS: dict[Key, str] = { - Key.C: "C", - Key.C_SHARP_D_FLAT: "C♯/D♭", - Key.D: "D", - Key.D_SHARP_E_FLAT: "D♯/E♭", - Key.E: "E", - Key.F: "F", - Key.F_SHARP_G_FLAT: "F♯/G♭", - Key.G: "G", - Key.G_SHARP_A_FLAT: "G♯/A♭", - Key.A: "A", - Key.A_SHARP_B_FLAT: "A♯/B♭", - Key.B: "B", -} - -KEY_OPTIONS = list(KEYS.values()) - - -def _get_key(audio_features: AudioFeatures) -> str | None: - if audio_features.key is None: - return None - return KEYS[audio_features.key] - - -AUDIO_FEATURE_SENSORS: tuple[SpotifyAudioFeaturesSensorEntityDescription, ...] = ( - SpotifyAudioFeaturesSensorEntityDescription( - key="bpm", - translation_key="song_tempo", - native_unit_of_measurement="bpm", - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.tempo, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="danceability", - translation_key="danceability", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.danceability * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="energy", - translation_key="energy", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.energy * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="mode", - translation_key="mode", - device_class=SensorDeviceClass.ENUM, - options=["major", "minor"], - value_fn=lambda audio_features: audio_features.mode.name.lower(), - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="speechiness", - translation_key="speechiness", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.speechiness * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="acousticness", - translation_key="acousticness", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.acousticness * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="instrumentalness", - translation_key="instrumentalness", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.instrumentalness * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="liveness", - translation_key="liveness", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.liveness * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="valence", - translation_key="valence", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.valence * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="time_signature", - translation_key="time_signature", - device_class=SensorDeviceClass.ENUM, - options=["3/4", "4/4", "5/4", "6/4", "7/4"], - value_fn=lambda audio_features: f"{audio_features.time_signature}/4", - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="key", - translation_key="key", - device_class=SensorDeviceClass.ENUM, - options=KEY_OPTIONS, - value_fn=_get_key, - entity_registry_enabled_default=False, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: SpotifyConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Spotify sensor based on a config entry.""" - coordinator = entry.runtime_data.coordinator - - async_add_entities( - SpotifyAudioFeatureSensor(coordinator, description) - for description in AUDIO_FEATURE_SENSORS - ) - - -class SpotifyAudioFeatureSensor(SpotifyEntity, SensorEntity): - """Representation of a Spotify sensor.""" - - entity_description: SpotifyAudioFeaturesSensorEntityDescription - - def __init__( - self, - coordinator: SpotifyCoordinator, - entity_description: SpotifyAudioFeaturesSensorEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attr_unique_id = ( - f"{coordinator.current_user.user_id}_{entity_description.key}" - ) - self.entity_description = entity_description - - @property - def native_value(self) -> float | str | None: - """Return the state of the sensor.""" - if (audio_features := self.coordinator.data.audio_features) is None: - return None - return self.entity_description.value_fn(audio_features) diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index faf20d740d933..90e573a170608 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -30,46 +30,5 @@ "info": { "api_endpoint_reachable": "Spotify API endpoint reachable" } - }, - "entity": { - "sensor": { - "song_tempo": { - "name": "Song tempo" - }, - "danceability": { - "name": "Song danceability" - }, - "energy": { - "name": "Song energy" - }, - "mode": { - "name": "Song mode", - "state": { - "minor": "Minor", - "major": "Major" - } - }, - "speechiness": { - "name": "Song speechiness" - }, - "acousticness": { - "name": "Song acousticness" - }, - "instrumentalness": { - "name": "Song instrumentalness" - }, - "valence": { - "name": "Song valence" - }, - "liveness": { - "name": "Song liveness" - }, - "time_signature": { - "name": "Song time signature" - }, - "key": { - "name": "Song key" - } - } } } diff --git a/tests/components/spotify/conftest.py b/tests/components/spotify/conftest.py index d3fc418f1cd5d..cc1f423246c15 100644 --- a/tests/components/spotify/conftest.py +++ b/tests/components/spotify/conftest.py @@ -9,7 +9,6 @@ Album, Artist, ArtistResponse, - AudioFeatures, CategoriesResponse, Category, CategoryPlaylistResponse, @@ -140,7 +139,6 @@ def mock_spotify() -> Generator[AsyncMock]: ("album.json", "get_album", Album), ("artist.json", "get_artist", Artist), ("show.json", "get_show", Show), - ("audio_features.json", "get_audio_features", AudioFeatures), ): getattr(client, method).return_value = obj.from_json( load_fixture(fixture, DOMAIN) diff --git a/tests/components/spotify/fixtures/audio_features.json b/tests/components/spotify/fixtures/audio_features.json deleted file mode 100644 index 52dfee060f72f..0000000000000 --- a/tests/components/spotify/fixtures/audio_features.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "danceability": 0.696, - "energy": 0.905, - "key": 3, - "loudness": -2.743, - "mode": 1, - "speechiness": 0.103, - "acousticness": 0.011, - "instrumentalness": 0.000905, - "liveness": 0.302, - "valence": 0.625, - "tempo": 114.944, - "type": "audio_features", - "id": "11dFghVXANMlKmJXsNCbNl", - "uri": "spotify:track:11dFghVXANMlKmJXsNCbNl", - "track_href": "https://api.spotify.com/v1/tracks/11dFghVXANMlKmJXsNCbNl", - "analysis_url": "https://api.spotify.com/v1/audio-analysis/11dFghVXANMlKmJXsNCbNl", - "duration_ms": 207960, - "time_signature": 4 -} diff --git a/tests/components/spotify/snapshots/test_diagnostics.ambr b/tests/components/spotify/snapshots/test_diagnostics.ambr index 161b6025ff377..40502562da36f 100644 --- a/tests/components/spotify/snapshots/test_diagnostics.ambr +++ b/tests/components/spotify/snapshots/test_diagnostics.ambr @@ -14,20 +14,6 @@ }), ]), 'playback': dict({ - 'audio_features': dict({ - 'acousticness': 0.011, - 'danceability': 0.696, - 'energy': 0.905, - 'instrumentalness': 0.000905, - 'key': 3, - 'liveness': 0.302, - 'loudness': -2.743, - 'mode': 1, - 'speechiness': 0.103, - 'tempo': 114.944, - 'time_signature': 4, - 'valence': 0.625, - }), 'current_playback': dict({ 'context': dict({ 'context_type': 'playlist', diff --git a/tests/components/spotify/snapshots/test_sensor.ambr b/tests/components/spotify/snapshots/test_sensor.ambr deleted file mode 100644 index ce77dda479f2c..0000000000000 --- a/tests/components/spotify/snapshots/test_sensor.ambr +++ /dev/null @@ -1,595 +0,0 @@ -# serializer version: 1 -# name: test_entities[sensor.spotify_spotify_1_song_acousticness-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_acousticness', - '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': None, - 'original_icon': None, - 'original_name': 'Song acousticness', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'acousticness', - 'unique_id': '1112264111_acousticness', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_acousticness-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song acousticness', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_acousticness', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1.1', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_danceability-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_danceability', - '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': None, - 'original_icon': None, - 'original_name': 'Song danceability', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'danceability', - 'unique_id': '1112264111_danceability', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_danceability-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song danceability', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_danceability', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '69.6', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_energy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_energy', - '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': None, - 'original_icon': None, - 'original_name': 'Song energy', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'energy', - 'unique_id': '1112264111_energy', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song energy', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '90.5', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_instrumentalness-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_instrumentalness', - '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': None, - 'original_icon': None, - 'original_name': 'Song instrumentalness', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'instrumentalness', - 'unique_id': '1112264111_instrumentalness', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_instrumentalness-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song instrumentalness', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_instrumentalness', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0905', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_key-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'C', - 'C♯/D♭', - 'D', - 'D♯/E♭', - 'E', - 'F', - 'F♯/G♭', - 'G', - 'G♯/A♭', - 'A', - 'A♯/B♭', - 'B', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_key', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Song key', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'key', - 'unique_id': '1112264111_key', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_key-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Spotify spotify_1 Song key', - 'options': list([ - 'C', - 'C♯/D♭', - 'D', - 'D♯/E♭', - 'E', - 'F', - 'F♯/G♭', - 'G', - 'G♯/A♭', - 'A', - 'A♯/B♭', - 'B', - ]), - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_key', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'D♯/E♭', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_liveness-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_liveness', - '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': None, - 'original_icon': None, - 'original_name': 'Song liveness', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'liveness', - 'unique_id': '1112264111_liveness', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_liveness-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song liveness', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_liveness', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '30.2', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'major', - 'minor', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Song mode', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mode', - 'unique_id': '1112264111_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Spotify spotify_1 Song mode', - 'options': list([ - 'major', - 'minor', - ]), - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'major', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_speechiness-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_speechiness', - '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': None, - 'original_icon': None, - 'original_name': 'Song speechiness', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'speechiness', - 'unique_id': '1112264111_speechiness', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_speechiness-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song speechiness', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_speechiness', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10.3', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_tempo-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_tempo', - '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': None, - 'original_icon': None, - 'original_name': 'Song tempo', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'song_tempo', - 'unique_id': '1112264111_bpm', - 'unit_of_measurement': 'bpm', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_tempo-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song tempo', - 'unit_of_measurement': 'bpm', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_tempo', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '114.944', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_time_signature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '3/4', - '4/4', - '5/4', - '6/4', - '7/4', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_time_signature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Song time signature', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'time_signature', - 'unique_id': '1112264111_time_signature', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_time_signature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Spotify spotify_1 Song time signature', - 'options': list([ - '3/4', - '4/4', - '5/4', - '6/4', - '7/4', - ]), - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_time_signature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4/4', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_valence-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_valence', - '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': None, - 'original_icon': None, - 'original_name': 'Song valence', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'valence', - 'unique_id': '1112264111_valence', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_valence-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song valence', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_valence', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '62.5', - }) -# --- diff --git a/tests/components/spotify/test_sensor.py b/tests/components/spotify/test_sensor.py deleted file mode 100644 index 11ce361034ada..0000000000000 --- a/tests/components/spotify/test_sensor.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Tests for the Spotify sensor platform.""" - -from unittest.mock import MagicMock, patch - -import pytest -from spotifyaio import PlaybackState -from syrupy import SnapshotAssertion - -from homeassistant.components.spotify import DOMAIN -from homeassistant.const import STATE_UNKNOWN, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import setup_integration - -from tests.common import MockConfigEntry, load_fixture, snapshot_platform - - -@pytest.mark.usefixtures("setup_credentials") -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_entities( - hass: HomeAssistant, - mock_spotify: MagicMock, - mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test the Spotify entities.""" - with patch("homeassistant.components.spotify.PLATFORMS", [Platform.SENSOR]): - await setup_integration(hass, mock_config_entry) - - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -@pytest.mark.usefixtures("setup_credentials") -async def test_audio_features_unavailable( - hass: HomeAssistant, - mock_spotify: MagicMock, - mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test the Spotify entities.""" - mock_spotify.return_value.get_audio_features.return_value = None - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("sensor.spotify_spotify_1_song_tempo").state == STATE_UNKNOWN - - -@pytest.mark.usefixtures("setup_credentials") -async def test_audio_features_unknown_during_podcast( - hass: HomeAssistant, - mock_spotify: MagicMock, - mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test the Spotify audio features sensor during a podcast.""" - mock_spotify.return_value.get_playback.return_value = PlaybackState.from_json( - load_fixture("playback_episode.json", DOMAIN) - ) - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("sensor.spotify_spotify_1_song_tempo").state == STATE_UNKNOWN From f02d2344fc9a3229d655653abb02ea3ca8b14708 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 Nov 2024 13:55:51 -0800 Subject: [PATCH 0975/1070] Bump uiprotect to 6.6.3 (#131764) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index a8ad956a66786..9a76ba6f984d8 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==6.6.2", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==6.6.3", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 6151da007c4cd..e4314e257f2ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2897,7 +2897,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.2 +uiprotect==6.6.3 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f88bda8215ada..6b6594e29bfcc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2310,7 +2310,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.2 +uiprotect==6.6.3 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 2fc01a02dbbb7c69d324d98394246a7767c12d35 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 27 Nov 2024 22:55:33 +0100 Subject: [PATCH 0976/1070] Bump pylamarzocco to 1.2.12 (#131765) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index a71da7c475441..43b1c7deb477a 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -36,5 +36,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["pylamarzocco"], - "requirements": ["pylamarzocco==1.2.11"] + "requirements": ["pylamarzocco==1.2.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index e4314e257f2ea..c7980fb9bdf0f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2027,7 +2027,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.2.11 +pylamarzocco==1.2.12 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b6594e29bfcc..714e9d39be4e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1632,7 +1632,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.2.11 +pylamarzocco==1.2.12 # homeassistant.components.lastfm pylast==5.1.0 From c9dde419a205608092c5e63427da9f04f1125deb Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 28 Nov 2024 01:35:23 +0100 Subject: [PATCH 0977/1070] Fix rounding of attributes in Habitica integration (#131772) --- homeassistant/components/habitica/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 03acb08baf9de..b2b4430c4905e 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -174,7 +174,7 @@ def get_attribute_points( ) return { - "level": min(round(user["stats"]["lvl"] / 2), 50), + "level": min(floor(user["stats"]["lvl"] / 2), 50), "equipment": equipment, "class": class_bonus, "allocated": user["stats"][attribute], From 71376229f63b138dd2fc6345f6e4687349f9d475 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 Nov 2024 15:29:29 -0800 Subject: [PATCH 0978/1070] Bump aioesphomeapi to 27.0.3 (#131773) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 5524e87e2a80c..77a3164d94c1c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==27.0.2", + "aioesphomeapi==27.0.3", "esphome-dashboard-api==1.2.3", "bleak-esphome==1.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index c7980fb9bdf0f..2284fa386b573 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==27.0.2 +aioesphomeapi==27.0.3 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 714e9d39be4e0..27ffd600131e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==27.0.2 +aioesphomeapi==27.0.3 # homeassistant.components.flo aioflo==2021.11.0 From 0a3a3edf7714a57d59b09400c3410c539a3a9347 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 27 Nov 2024 19:34:57 -0500 Subject: [PATCH 0979/1070] Bump ZHA to 0.0.41 (#131776) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index ded37fc4713db..1fbbd83bb9c00 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.40"], + "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.41"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 2284fa386b573..cc6ada55f72c9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3081,7 +3081,7 @@ zeroconf==0.136.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.40 +zha==0.0.41 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27ffd600131e9..452e6143c3425 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2464,7 +2464,7 @@ zeroconf==0.136.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.40 +zha==0.0.41 # homeassistant.components.zwave_js zwave-js-server-python==0.59.1 From b8c4ce932ce7a0b2de058c4fa032a31595e370a2 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 28 Nov 2024 08:06:31 +0100 Subject: [PATCH 0980/1070] Fix Home Connect microwave programs (#131782) --- homeassistant/components/home_connect/select.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 172b959b145dd..fdd1f38bf97d6 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -140,12 +140,12 @@ "Cooking.Oven.Program.HeatingMode.HotAir80Steam", "Cooking.Oven.Program.HeatingMode.HotAir100Steam", "Cooking.Oven.Program.HeatingMode.SabbathProgramme", - "Cooking.Oven.Program.Microwave90Watt", - "Cooking.Oven.Program.Microwave180Watt", - "Cooking.Oven.Program.Microwave360Watt", - "Cooking.Oven.Program.Microwave600Watt", - "Cooking.Oven.Program.Microwave900Watt", - "Cooking.Oven.Program.Microwave1000Watt", + "Cooking.Oven.Program.Microwave.90Watt", + "Cooking.Oven.Program.Microwave.180Watt", + "Cooking.Oven.Program.Microwave.360Watt", + "Cooking.Oven.Program.Microwave.600Watt", + "Cooking.Oven.Program.Microwave.900Watt", + "Cooking.Oven.Program.Microwave.1000Watt", "Cooking.Oven.Program.Microwave.Max", "Cooking.Oven.Program.HeatingMode.WarmingDrawer", "LaundryCare.Washer.Program.Cotton", From 3af0bc2c33f8b7ec8179fe4b0997ac981c80fad0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 Nov 2024 08:44:28 +0100 Subject: [PATCH 0981/1070] Bump version to 2024.12.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 93a909f6aadda..bf84dbb6ff957 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 68b0a7e5a59be..419d04fdf25af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.12.0b0" +version = "2024.12.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 99f8dbd278c71031a16084f727844e29772e7f51 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Thu, 28 Nov 2024 09:58:16 +0100 Subject: [PATCH 0982/1070] Bump bimmer_connected to 0.17.0 (#131352) --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index ed0919a5dcfe3..d1ca735ce55eb 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected[china]==0.16.4"] + "requirements": ["bimmer-connected[china]==0.17.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cc6ada55f72c9..d6699f420f5fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -582,7 +582,7 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.4 +bimmer-connected[china]==0.17.0 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 452e6143c3425..dc79e8900730f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -516,7 +516,7 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.4 +bimmer-connected[china]==0.17.0 # homeassistant.components.eq3btsmart # homeassistant.components.esphome From 7ab1bfcf1ffff63a7b3454bdabffc2eaaf45596a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 27 Nov 2024 21:12:42 +0100 Subject: [PATCH 0983/1070] Improve recorder history queries (#131702) * Improve recorder history queries * Remove some comments * Update StatesManager._oldest_ts when adding pending state * Update after review * Improve tests * Improve post-purge logic * Avoid calling dt_util.utc_to_timestamp in new code --------- Co-authored-by: J. Nick Koston --- homeassistant/components/history/__init__.py | 7 ++-- homeassistant/components/history/helpers.py | 13 ++++---- .../components/history/websocket_api.py | 7 ++-- homeassistant/components/recorder/core.py | 1 + .../components/recorder/history/legacy.py | 18 +++++------ .../components/recorder/history/modern.py | 31 +++++++++--------- homeassistant/components/recorder/purge.py | 3 ++ homeassistant/components/recorder/queries.py | 9 ++++++ .../recorder/table_managers/states.py | 32 +++++++++++++++++++ homeassistant/components/recorder/tasks.py | 2 -- tests/components/recorder/test_purge.py | 17 ++++++++++ 11 files changed, 102 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 365be06fd2db6..7241e1fac9ad7 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -22,7 +22,7 @@ from . import websocket_api from .const import DOMAIN -from .helpers import entities_may_have_state_changes_after, has_recorder_run_after +from .helpers import entities_may_have_state_changes_after, has_states_before CONF_ORDER = "use_include_order" @@ -107,7 +107,10 @@ async def get( no_attributes = "no_attributes" in request.query if ( - (end_time and not has_recorder_run_after(hass, end_time)) + # has_states_before will return True if there are states older than + # end_time. If it's false, we know there are no states in the + # database up until end_time. + (end_time and not has_states_before(hass, end_time)) or not include_start_time_state and entity_ids and not entities_may_have_state_changes_after( diff --git a/homeassistant/components/history/helpers.py b/homeassistant/components/history/helpers.py index bd477e7e4ed43..2010b7373ffa9 100644 --- a/homeassistant/components/history/helpers.py +++ b/homeassistant/components/history/helpers.py @@ -6,7 +6,6 @@ from datetime import datetime as dt from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import process_timestamp from homeassistant.core import HomeAssistant @@ -26,8 +25,10 @@ def entities_may_have_state_changes_after( return False -def has_recorder_run_after(hass: HomeAssistant, run_time: dt) -> bool: - """Check if the recorder has any runs after a specific time.""" - return run_time >= process_timestamp( - get_instance(hass).recorder_runs_manager.first.start - ) +def has_states_before(hass: HomeAssistant, run_time: dt) -> bool: + """Check if the recorder has states as old or older than run_time. + + Returns True if there may be such states. + """ + oldest_ts = get_instance(hass).states_manager.oldest_ts + return oldest_ts is not None and run_time.timestamp() >= oldest_ts diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index c85d975c3c97d..35f8ed5f1acdc 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -39,7 +39,7 @@ import homeassistant.util.dt as dt_util from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES -from .helpers import entities_may_have_state_changes_after, has_recorder_run_after +from .helpers import entities_may_have_state_changes_after, has_states_before _LOGGER = logging.getLogger(__name__) @@ -142,7 +142,10 @@ async def ws_get_history_during_period( no_attributes = msg["no_attributes"] if ( - (end_time and not has_recorder_run_after(hass, end_time)) + # has_states_before will return True if there are states older than + # end_time. If it's false, we know there are no states in the + # database up until end_time. + (end_time and not has_states_before(hass, end_time)) or not include_start_time_state and entity_ids and not entities_may_have_state_changes_after( diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 6ba64d4a5717e..8c2e1c9e006a3 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1424,6 +1424,7 @@ def _setup_run(self) -> None: with session_scope(session=self.get_session()) as session: end_incomplete_runs(session, self.recorder_runs_manager.recording_start) self.recorder_runs_manager.start(session) + self.states_manager.load_from_db(session) self._open_event_session() diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index b59fc43c3d0b9..3a0fe79455b57 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -22,9 +22,9 @@ from homeassistant.helpers.recorder import get_instance import homeassistant.util.dt as dt_util -from ..db_schema import RecorderRuns, StateAttributes, States +from ..db_schema import StateAttributes, States from ..filters import Filters -from ..models import process_timestamp, process_timestamp_to_utc_isoformat +from ..models import process_timestamp_to_utc_isoformat from ..models.legacy import LegacyLazyState, legacy_row_to_compressed_state from ..util import execute_stmt_lambda_element, session_scope from .const import ( @@ -436,7 +436,7 @@ def get_last_state_changes( def _get_states_for_entities_stmt( - run_start: datetime, + run_start_ts: float, utc_point_in_time: datetime, entity_ids: list[str], no_attributes: bool, @@ -447,7 +447,6 @@ def _get_states_for_entities_stmt( ) # We got an include-list of entities, accelerate the query by filtering already # in the inner query. - run_start_ts = process_timestamp(run_start).timestamp() utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) stmt += lambda q: q.join( ( @@ -483,7 +482,7 @@ def _get_rows_with_session( session: Session, utc_point_in_time: datetime, entity_ids: list[str], - run: RecorderRuns | None = None, + *, no_attributes: bool = False, ) -> Iterable[Row]: """Return the states at a specific point in time.""" @@ -495,17 +494,16 @@ def _get_rows_with_session( ), ) - if run is None: - run = get_instance(hass).recorder_runs_manager.get(utc_point_in_time) + oldest_ts = get_instance(hass).states_manager.oldest_ts - if run is None or process_timestamp(run.start) > utc_point_in_time: - # History did not run before utc_point_in_time + if oldest_ts is None or oldest_ts > utc_point_in_time.timestamp(): + # We don't have any states for the requested time return [] # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. stmt = _get_states_for_entities_stmt( - run.start, utc_point_in_time, entity_ids, no_attributes + oldest_ts, utc_point_in_time, entity_ids, no_attributes ) return execute_stmt_lambda_element(session, stmt) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index b44bec0d0ee13..902f1b5dc24b2 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -34,7 +34,6 @@ LazyState, datetime_to_timestamp_or_none, extract_metadata_ids, - process_timestamp, row_to_compressed_state, ) from ..util import execute_stmt_lambda_element, session_scope @@ -246,9 +245,9 @@ def get_significant_states_with_session( if metadata_id is not None and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS ] - run_start_ts: float | None = None + oldest_ts: float | None = None if include_start_time_state and not ( - run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time) + oldest_ts := _get_oldest_possible_ts(hass, start_time) ): include_start_time_state = False start_time_ts = dt_util.utc_to_timestamp(start_time) @@ -264,7 +263,7 @@ def get_significant_states_with_session( significant_changes_only, no_attributes, include_start_time_state, - run_start_ts, + oldest_ts, ), track_on=[ bool(single_metadata_id), @@ -411,9 +410,9 @@ def state_changes_during_period( entity_id_to_metadata_id: dict[str, int | None] = { entity_id: single_metadata_id } - run_start_ts: float | None = None + oldest_ts: float | None = None if include_start_time_state and not ( - run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time) + oldest_ts := _get_oldest_possible_ts(hass, start_time) ): include_start_time_state = False start_time_ts = dt_util.utc_to_timestamp(start_time) @@ -426,7 +425,7 @@ def state_changes_during_period( no_attributes, limit, include_start_time_state, - run_start_ts, + oldest_ts, has_last_reported, ), track_on=[ @@ -600,17 +599,17 @@ def _get_start_time_state_for_entities_stmt( ) -def _get_run_start_ts_for_utc_point_in_time( +def _get_oldest_possible_ts( hass: HomeAssistant, utc_point_in_time: datetime ) -> float | None: - """Return the start time of a run.""" - run = get_instance(hass).recorder_runs_manager.get(utc_point_in_time) - if ( - run is not None - and (run_start := process_timestamp(run.start)) < utc_point_in_time - ): - return run_start.timestamp() - # History did not run before utc_point_in_time but we still + """Return the oldest possible timestamp. + + Returns None if there are no states as old as utc_point_in_time. + """ + + oldest_ts = get_instance(hass).states_manager.oldest_ts + if oldest_ts is not None and oldest_ts < utc_point_in_time.timestamp(): + return oldest_ts return None diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 329f48e5455c4..28a5a2ed32d16 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -123,6 +123,9 @@ def purge_old_data( _purge_old_entity_ids(instance, session) _purge_old_recorder_runs(instance, session, purge_before) + with session_scope(session=instance.get_session(), read_only=True) as session: + instance.recorder_runs_manager.load_from_db(session) + instance.states_manager.load_from_db(session) if repack: repack_database(instance) return True diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 2e4b588a0b095..8ca7bef269115 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -637,6 +637,15 @@ def find_states_to_purge( ) +def find_oldest_state() -> StatementLambdaElement: + """Find the last_updated_ts of the oldest state.""" + return lambda_stmt( + lambda: select(States.last_updated_ts).where( + States.state_id.in_(select(func.min(States.state_id))) + ) + ) + + def find_short_term_statistics_to_purge( purge_before: datetime, max_bind_vars: int ) -> StatementLambdaElement: diff --git a/homeassistant/components/recorder/table_managers/states.py b/homeassistant/components/recorder/table_managers/states.py index d5cef759c5423..fafcfa0ea61c6 100644 --- a/homeassistant/components/recorder/table_managers/states.py +++ b/homeassistant/components/recorder/table_managers/states.py @@ -2,7 +2,15 @@ from __future__ import annotations +from collections.abc import Sequence +from typing import Any, cast + +from sqlalchemy.engine.row import Row +from sqlalchemy.orm.session import Session + from ..db_schema import States +from ..queries import find_oldest_state +from ..util import execute_stmt_lambda_element class StatesManager: @@ -13,6 +21,12 @@ def __init__(self) -> None: self._pending: dict[str, States] = {} self._last_committed_id: dict[str, int] = {} self._last_reported: dict[int, float] = {} + self._oldest_ts: float | None = None + + @property + def oldest_ts(self) -> float | None: + """Return the oldest timestamp.""" + return self._oldest_ts def pop_pending(self, entity_id: str) -> States | None: """Pop a pending state. @@ -44,6 +58,8 @@ def add_pending(self, entity_id: str, state: States) -> None: recorder thread. """ self._pending[entity_id] = state + if self._oldest_ts is None: + self._oldest_ts = state.last_updated_ts def update_pending_last_reported( self, state_id: int, last_reported_timestamp: float @@ -74,6 +90,22 @@ def reset(self) -> None: """ self._last_committed_id.clear() self._pending.clear() + self._oldest_ts = None + + def load_from_db(self, session: Session) -> None: + """Update the cache. + + Must run in the recorder thread. + """ + result = cast( + Sequence[Row[Any]], + execute_stmt_lambda_element(session, find_oldest_state()), + ) + if not result: + ts = None + else: + ts = result[0].last_updated_ts + self._oldest_ts = ts def evict_purged_state_ids(self, purged_state_ids: set[int]) -> None: """Evict purged states from the committed states. diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 783f0a80b8e31..fa10c12aa6889 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -120,8 +120,6 @@ def run(self, instance: Recorder) -> None: if purge.purge_old_data( instance, self.purge_before, self.repack, self.apply_filter ): - with instance.get_session() as session: - instance.recorder_runs_manager.load_from_db(session) # We always need to do the db cleanups after a purge # is finished to ensure the WAL checkpoint and other # tasks happen after a vacuum. diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index ca160e5201b02..f721a260c14e0 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -112,6 +112,9 @@ async def test_purge_big_database(hass: HomeAssistant, recorder_mock: Recorder) async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> None: """Test deleting old states.""" + assert recorder_mock.states_manager.oldest_ts is None + oldest_ts = recorder_mock.states_manager.oldest_ts + await _add_test_states(hass) # make sure we start with 6 states @@ -127,6 +130,10 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> events = session.query(Events).filter(Events.event_type == "state_changed") assert events.count() == 0 + assert recorder_mock.states_manager.oldest_ts != oldest_ts + assert recorder_mock.states_manager.oldest_ts == states[0].last_updated_ts + oldest_ts = recorder_mock.states_manager.oldest_ts + assert "test.recorder2" in recorder_mock.states_manager._last_committed_id purge_before = dt_util.utcnow() - timedelta(days=4) @@ -140,6 +147,8 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> repack=False, ) assert not finished + # states_manager.oldest_ts is not updated until after the purge is complete + assert recorder_mock.states_manager.oldest_ts == oldest_ts with session_scope(hass=hass) as session: states = session.query(States) @@ -162,6 +171,8 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> finished = purge_old_data(recorder_mock, purge_before, repack=False) assert finished + # states_manager.oldest_ts should now be updated + assert recorder_mock.states_manager.oldest_ts != oldest_ts with session_scope(hass=hass) as session: states = session.query(States) @@ -169,6 +180,10 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> assert states.count() == 2 assert state_attributes.count() == 1 + assert recorder_mock.states_manager.oldest_ts != oldest_ts + assert recorder_mock.states_manager.oldest_ts == states[0].last_updated_ts + oldest_ts = recorder_mock.states_manager.oldest_ts + assert "test.recorder2" in recorder_mock.states_manager._last_committed_id # run purge_old_data again @@ -181,6 +196,8 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> repack=False, ) assert not finished + # states_manager.oldest_ts is not updated until after the purge is complete + assert recorder_mock.states_manager.oldest_ts == oldest_ts with session_scope(hass=hass) as session: assert states.count() == 0 From 80bc70771e91f8332f23b333d1c94b6a48699cfd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Nov 2024 12:34:06 +0100 Subject: [PATCH 0984/1070] Remove Spotify featured playlists and categories from media browser (#131758) --- .../components/spotify/browse_media.py | 72 ---------- tests/components/spotify/conftest.py | 14 -- .../spotify/fixtures/categories.json | 36 ----- .../components/spotify/fixtures/category.json | 12 -- .../spotify/fixtures/category_playlists.json | 84 ------------ .../spotify/fixtures/featured_playlists.json | 85 ------------ .../spotify/snapshots/test_media_browser.ambr | 125 ------------------ .../components/spotify/test_media_browser.py | 3 - 8 files changed, 431 deletions(-) delete mode 100644 tests/components/spotify/fixtures/categories.json delete mode 100644 tests/components/spotify/fixtures/category.json delete mode 100644 tests/components/spotify/fixtures/category_playlists.json delete mode 100644 tests/components/spotify/fixtures/featured_playlists.json diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index 403ec608a7c97..1ae5804ea66a8 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -101,8 +101,6 @@ class BrowsableMedia(StrEnum): CURRENT_USER_RECENTLY_PLAYED = "current_user_recently_played" CURRENT_USER_TOP_ARTISTS = "current_user_top_artists" CURRENT_USER_TOP_TRACKS = "current_user_top_tracks" - CATEGORIES = "categories" - FEATURED_PLAYLISTS = "featured_playlists" NEW_RELEASES = "new_releases" @@ -115,8 +113,6 @@ class BrowsableMedia(StrEnum): BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED.value: "Recently played", BrowsableMedia.CURRENT_USER_TOP_ARTISTS.value: "Top Artists", BrowsableMedia.CURRENT_USER_TOP_TRACKS.value: "Top Tracks", - BrowsableMedia.CATEGORIES.value: "Categories", - BrowsableMedia.FEATURED_PLAYLISTS.value: "Featured Playlists", BrowsableMedia.NEW_RELEASES.value: "New Releases", } @@ -153,18 +149,6 @@ class BrowsableMedia(StrEnum): "parent": MediaClass.DIRECTORY, "children": MediaClass.TRACK, }, - BrowsableMedia.FEATURED_PLAYLISTS.value: { - "parent": MediaClass.DIRECTORY, - "children": MediaClass.PLAYLIST, - }, - BrowsableMedia.CATEGORIES.value: { - "parent": MediaClass.DIRECTORY, - "children": MediaClass.GENRE, - }, - "category_playlists": { - "parent": MediaClass.DIRECTORY, - "children": MediaClass.PLAYLIST, - }, BrowsableMedia.NEW_RELEASES.value: { "parent": MediaClass.DIRECTORY, "children": MediaClass.ALBUM, @@ -354,32 +338,6 @@ async def build_item_response( # noqa: C901 elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_TRACKS: if top_tracks := await spotify.get_top_tracks(): items = [_get_track_item_payload(track) for track in top_tracks] - elif media_content_type == BrowsableMedia.FEATURED_PLAYLISTS: - if featured_playlists := await spotify.get_featured_playlists(): - items = [ - _get_playlist_item_payload(playlist) for playlist in featured_playlists - ] - elif media_content_type == BrowsableMedia.CATEGORIES: - if categories := await spotify.get_categories(): - items = [ - { - "id": category.category_id, - "name": category.name, - "type": "category_playlists", - "uri": category.category_id, - "thumbnail": category.icons[0].url if category.icons else None, - } - for category in categories - ] - elif media_content_type == "category_playlists": - if ( - playlists := await spotify.get_category_playlists( - category_id=media_content_id - ) - ) and (category := await spotify.get_category(media_content_id)): - title = category.name - image = category.icons[0].url if category.icons else None - items = [_get_playlist_item_payload(playlist) for playlist in playlists] elif media_content_type == BrowsableMedia.NEW_RELEASES: if new_releases := await spotify.get_new_releases(): items = [_get_album_item_payload(album) for album in new_releases] @@ -429,36 +387,6 @@ async def build_item_response( # noqa: C901 _LOGGER.debug("Unknown media type received: %s", media_content_type) return None - if media_content_type == BrowsableMedia.CATEGORIES: - media_item = BrowseMedia( - can_expand=True, - can_play=False, - children_media_class=media_class["children"], - media_class=media_class["parent"], - media_content_id=media_content_id, - media_content_type=f"{MEDIA_PLAYER_PREFIX}{media_content_type}", - title=LIBRARY_MAP.get(media_content_id, "Unknown"), - ) - - media_item.children = [] - for item in items: - if (item_id := item["id"]) is None: - _LOGGER.debug("Missing ID for media item: %s", item) - continue - media_item.children.append( - BrowseMedia( - can_expand=True, - can_play=False, - children_media_class=MediaClass.TRACK, - media_class=MediaClass.PLAYLIST, - media_content_id=item_id, - media_content_type=f"{MEDIA_PLAYER_PREFIX}category_playlists", - thumbnail=item["thumbnail"], - title=item["name"], - ) - ) - return media_item - if title is None: title = LIBRARY_MAP.get(media_content_id, "Unknown") diff --git a/tests/components/spotify/conftest.py b/tests/components/spotify/conftest.py index cc1f423246c15..67d4eac396012 100644 --- a/tests/components/spotify/conftest.py +++ b/tests/components/spotify/conftest.py @@ -9,11 +9,7 @@ Album, Artist, ArtistResponse, - CategoriesResponse, - Category, - CategoryPlaylistResponse, Devices, - FeaturedPlaylistResponse, NewReleasesResponse, NewReleasesResponseInner, PlaybackState, @@ -134,7 +130,6 @@ def mock_spotify() -> Generator[AsyncMock]: PlaybackState, ), ("current_user.json", "get_current_user", UserProfile), - ("category.json", "get_category", Category), ("playlist.json", "get_playlist", Playlist), ("album.json", "get_album", Album), ("artist.json", "get_artist", Artist), @@ -146,15 +141,6 @@ def mock_spotify() -> Generator[AsyncMock]: client.get_followed_artists.return_value = ArtistResponse.from_json( load_fixture("followed_artists.json", DOMAIN) ).artists.items - client.get_featured_playlists.return_value = FeaturedPlaylistResponse.from_json( - load_fixture("featured_playlists.json", DOMAIN) - ).playlists.items - client.get_categories.return_value = CategoriesResponse.from_json( - load_fixture("categories.json", DOMAIN) - ).categories.items - client.get_category_playlists.return_value = CategoryPlaylistResponse.from_json( - load_fixture("category_playlists.json", DOMAIN) - ).playlists.items client.get_new_releases.return_value = NewReleasesResponse.from_json( load_fixture("new_releases.json", DOMAIN) ).albums.items diff --git a/tests/components/spotify/fixtures/categories.json b/tests/components/spotify/fixtures/categories.json deleted file mode 100644 index ed873c95c304a..0000000000000 --- a/tests/components/spotify/fixtures/categories.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "categories": { - "href": "https://api.spotify.com/v1/browse/categories?offset=0&limit=20&locale=en-US,en;q%3D0.5", - "items": [ - { - "href": "https://api.spotify.com/v1/browse/categories/0JQ5DAt0tbjZptfcdMSKl3", - "id": "0JQ5DAt0tbjZptfcdMSKl3", - "icons": [ - { - "height": 274, - "url": "https://t.scdn.co/images/728ed47fc1674feb95f7ac20236eb6d7.jpeg", - "width": 274 - } - ], - "name": "Made For You" - }, - { - "href": "https://api.spotify.com/v1/browse/categories/0JQ5DAqbMKFz6FAsUtgAab", - "id": "0JQ5DAqbMKFz6FAsUtgAab", - "icons": [ - { - "height": 274, - "url": "https://t.scdn.co/images/728ed47fc1674feb95f7ac20236eb6d7.jpeg", - "width": 274 - } - ], - "name": "New Releases" - } - ], - "limit": 20, - "next": "https://api.spotify.com/v1/browse/categories?offset=20&limit=20&locale=en-US,en;q%3D0.5", - "offset": 0, - "previous": null, - "total": 56 - } -} diff --git a/tests/components/spotify/fixtures/category.json b/tests/components/spotify/fixtures/category.json deleted file mode 100644 index d60605cf94f8c..0000000000000 --- a/tests/components/spotify/fixtures/category.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "href": "https://api.spotify.com/v1/browse/categories/0JQ5DAqbMKFRY5ok2pxXJ0", - "id": "0JQ5DAqbMKFRY5ok2pxXJ0", - "icons": [ - { - "height": 274, - "url": "https://t.scdn.co/media/original/dinner_1b6506abba0ba52c54e6d695c8571078_274x274.jpg", - "width": 274 - } - ], - "name": "Cooking & Dining" -} diff --git a/tests/components/spotify/fixtures/category_playlists.json b/tests/components/spotify/fixtures/category_playlists.json deleted file mode 100644 index c2262708d5a93..0000000000000 --- a/tests/components/spotify/fixtures/category_playlists.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "playlists": { - "href": "https://api.spotify.com/v1/browse/categories/0JQ5DAqbMKFRY5ok2pxXJ0/playlists?country=NL&offset=0&limit=20", - "items": [ - { - "collaborative": false, - "description": "Lekker eten en lang natafelen? Daar hoort muziek bij.", - "external_urls": { - "spotify": "https://open.spotify.com/playlist/37i9dQZF1DX7yhuKT9G4qk" - }, - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DX7yhuKT9G4qk", - "id": "37i9dQZF1DX7yhuKT9G4qk", - "images": [ - { - "height": null, - "url": "https://i.scdn.co/image/ab67706f0000000343319faa9428405f3312b588", - "width": null - } - ], - "name": "eten met vrienden", - "owner": { - "display_name": "Spotify", - "external_urls": { - "spotify": "https://open.spotify.com/user/spotify" - }, - "href": "https://api.spotify.com/v1/users/spotify", - "id": "spotify", - "type": "user", - "uri": "spotify:user:spotify" - }, - "primary_color": null, - "public": null, - "snapshot_id": "MTcwMTY5Njk3NywwMDAwMDAwMDkyY2JjZDA1MjA2YTBmNzMxMmFlNGI0YzRhMjg0ZWZl", - "tracks": { - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DX7yhuKT9G4qk/tracks", - "total": 313 - }, - "type": "playlist", - "uri": "spotify:playlist:37i9dQZF1DX7yhuKT9G4qk" - }, - { - "collaborative": false, - "description": "From new retro to classic country blues, honky tonk, rockabilly, and more.", - "external_urls": { - "spotify": "https://open.spotify.com/playlist/37i9dQZF1DXbvE0SE0Cczh" - }, - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DXbvE0SE0Cczh", - "id": "37i9dQZF1DXbvE0SE0Cczh", - "images": [ - { - "height": null, - "url": "https://i.scdn.co/image/ab67706f00000003b93c270883619dde61725fc8", - "width": null - } - ], - "name": "Jukebox Joint", - "owner": { - "display_name": "Spotify", - "external_urls": { - "spotify": "https://open.spotify.com/user/spotify" - }, - "href": "https://api.spotify.com/v1/users/spotify", - "id": "spotify", - "type": "user", - "uri": "spotify:user:spotify" - }, - "primary_color": null, - "public": null, - "snapshot_id": "MTY4NjkxODgwMiwwMDAwMDAwMGUwNWRkNjY5N2UzM2Q4NzI4NzRiZmNhMGVmMzAyZTA5", - "tracks": { - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DXbvE0SE0Cczh/tracks", - "total": 60 - }, - "type": "playlist", - "uri": "spotify:playlist:37i9dQZF1DXbvE0SE0Cczh" - } - ], - "limit": 20, - "next": "https://api.spotify.com/v1/browse/categories/0JQ5DAqbMKFRY5ok2pxXJ0/playlists?country=NL&offset=20&limit=20", - "offset": 0, - "previous": null, - "total": 46 - } -} diff --git a/tests/components/spotify/fixtures/featured_playlists.json b/tests/components/spotify/fixtures/featured_playlists.json deleted file mode 100644 index 5e6e53a7ee1ba..0000000000000 --- a/tests/components/spotify/fixtures/featured_playlists.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "message": "Popular Playlists", - "playlists": { - "href": "https://api.spotify.com/v1/browse/featured-playlists?country=NL×tamp=2023-12-18T18%3A35%3A35&offset=0&limit=20", - "items": [ - { - "collaborative": false, - "description": "De ideale playlist voor het fijne kerstgevoel bij de boom!", - "external_urls": { - "spotify": "https://open.spotify.com/playlist/37i9dQZF1DX4dopZ9vOp1t" - }, - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DX4dopZ9vOp1t", - "id": "37i9dQZF1DX4dopZ9vOp1t", - "images": [ - { - "height": null, - "url": "https://i.scdn.co/image/ab67706f000000037d14c267b8ee5fea2246a8fe", - "width": null - } - ], - "name": "Kerst Hits 2023", - "owner": { - "display_name": "Spotify", - "external_urls": { - "spotify": "https://open.spotify.com/user/spotify" - }, - "href": "https://api.spotify.com/v1/users/spotify", - "id": "spotify", - "type": "user", - "uri": "spotify:user:spotify" - }, - "primary_color": null, - "public": null, - "snapshot_id": "MTcwMjU2ODI4MSwwMDAwMDAwMDE1ZGRiNzI3OGY4OGU2MzA1MWNkZGMyNTdmNDUwMTc1", - "tracks": { - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DX4dopZ9vOp1t/tracks", - "total": 298 - }, - "type": "playlist", - "uri": "spotify:playlist:37i9dQZF1DX4dopZ9vOp1t" - }, - { - "collaborative": false, - "description": "De 50 populairste hits van Nederland. Cover: Jack Harlow", - "external_urls": { - "spotify": "https://open.spotify.com/playlist/37i9dQZF1DWSBi5svWQ9Nk" - }, - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DWSBi5svWQ9Nk", - "id": "37i9dQZF1DWSBi5svWQ9Nk", - "images": [ - { - "height": null, - "url": "https://i.scdn.co/image/ab67706f00000003f7b99051789611a49101c1cf", - "width": null - } - ], - "name": "Top Hits NL", - "owner": { - "display_name": "Spotify", - "external_urls": { - "spotify": "https://open.spotify.com/user/spotify" - }, - "href": "https://api.spotify.com/v1/users/spotify", - "id": "spotify", - "type": "user", - "uri": "spotify:user:spotify" - }, - "primary_color": null, - "public": null, - "snapshot_id": "MTcwMjU5NDgwMCwwMDAwMDAwMDU4NWY2MTE4NmU4NmIwMDdlMGE4ZGRkOTZkN2U2MzAx", - "tracks": { - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DWSBi5svWQ9Nk/tracks", - "total": 50 - }, - "type": "playlist", - "uri": "spotify:playlist:37i9dQZF1DWSBi5svWQ9Nk" - } - ], - "limit": 20, - "next": "https://api.spotify.com/v1/browse/featured-playlists?country=NL×tamp=2023-12-18T18%3A35%3A35&offset=20&limit=20", - "offset": 0, - "previous": null, - "total": 24 - } -} diff --git a/tests/components/spotify/snapshots/test_media_browser.ambr b/tests/components/spotify/snapshots/test_media_browser.ambr index e1ff42cb7c851..764dc7a10e159 100644 --- a/tests/components/spotify/snapshots/test_media_browser.ambr +++ b/tests/components/spotify/snapshots/test_media_browser.ambr @@ -84,26 +84,6 @@ 'thumbnail': None, 'title': 'Top Tracks', }), - dict({ - 'can_expand': True, - 'can_play': False, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/categories', - 'media_content_type': 'spotify://categories', - 'thumbnail': None, - 'title': 'Categories', - }), - dict({ - 'can_expand': True, - 'can_play': False, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/featured_playlists', - 'media_content_type': 'spotify://featured_playlists', - 'thumbnail': None, - 'title': 'Featured Playlists', - }), dict({ 'can_expand': True, 'can_play': False, @@ -299,76 +279,6 @@ 'title': 'Pitbull', }) # --- -# name: test_browsing[categories-categories] - dict({ - 'can_expand': True, - 'can_play': False, - 'children': list([ - dict({ - 'can_expand': True, - 'can_play': False, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/0JQ5DAt0tbjZptfcdMSKl3', - 'media_content_type': 'spotify://category_playlists', - 'thumbnail': 'https://t.scdn.co/images/728ed47fc1674feb95f7ac20236eb6d7.jpeg', - 'title': 'Made For You', - }), - dict({ - 'can_expand': True, - 'can_play': False, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/0JQ5DAqbMKFz6FAsUtgAab', - 'media_content_type': 'spotify://category_playlists', - 'thumbnail': 'https://t.scdn.co/images/728ed47fc1674feb95f7ac20236eb6d7.jpeg', - 'title': 'New Releases', - }), - ]), - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/categories', - 'media_content_type': 'spotify://categories', - 'not_shown': 0, - 'thumbnail': None, - 'title': 'Categories', - }) -# --- -# name: test_browsing[category_playlists-dinner] - dict({ - 'can_expand': True, - 'can_play': False, - 'children': list([ - dict({ - 'can_expand': True, - 'can_play': True, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:37i9dQZF1DX7yhuKT9G4qk', - 'media_content_type': 'spotify://playlist', - 'thumbnail': 'https://i.scdn.co/image/ab67706f0000000343319faa9428405f3312b588', - 'title': 'eten met vrienden', - }), - dict({ - 'can_expand': True, - 'can_play': True, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:37i9dQZF1DXbvE0SE0Cczh', - 'media_content_type': 'spotify://playlist', - 'thumbnail': 'https://i.scdn.co/image/ab67706f00000003b93c270883619dde61725fc8', - 'title': 'Jukebox Joint', - }), - ]), - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/dinner', - 'media_content_type': 'spotify://category_playlists', - 'not_shown': 0, - 'thumbnail': 'https://t.scdn.co/media/original/dinner_1b6506abba0ba52c54e6d695c8571078_274x274.jpg', - 'title': 'Cooking & Dining', - }) -# --- # name: test_browsing[current_user_followed_artists-current_user_followed_artists] dict({ 'can_expand': True, @@ -649,41 +559,6 @@ 'title': 'Top Tracks', }) # --- -# name: test_browsing[featured_playlists-featured_playlists] - dict({ - 'can_expand': True, - 'can_play': False, - 'children': list([ - dict({ - 'can_expand': True, - 'can_play': True, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:37i9dQZF1DX4dopZ9vOp1t', - 'media_content_type': 'spotify://playlist', - 'thumbnail': 'https://i.scdn.co/image/ab67706f000000037d14c267b8ee5fea2246a8fe', - 'title': 'Kerst Hits 2023', - }), - dict({ - 'can_expand': True, - 'can_play': True, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:37i9dQZF1DWSBi5svWQ9Nk', - 'media_content_type': 'spotify://playlist', - 'thumbnail': 'https://i.scdn.co/image/ab67706f00000003f7b99051789611a49101c1cf', - 'title': 'Top Hits NL', - }), - ]), - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/featured_playlists', - 'media_content_type': 'spotify://featured_playlists', - 'not_shown': 0, - 'thumbnail': None, - 'title': 'Featured Playlists', - }) -# --- # name: test_browsing[new_releases-new_releases] dict({ 'can_expand': True, diff --git a/tests/components/spotify/test_media_browser.py b/tests/components/spotify/test_media_browser.py index dcacc23bbeeae..ff3404dcfe979 100644 --- a/tests/components/spotify/test_media_browser.py +++ b/tests/components/spotify/test_media_browser.py @@ -112,9 +112,6 @@ async def test_browse_media_playlists( ("current_user_recently_played", "current_user_recently_played"), ("current_user_top_artists", "current_user_top_artists"), ("current_user_top_tracks", "current_user_top_tracks"), - ("featured_playlists", "featured_playlists"), - ("categories", "categories"), - ("category_playlists", "dinner"), ("new_releases", "new_releases"), ("playlist", "spotify:playlist:3cEYpjA9oz9GiPac4AsH4n"), ("album", "spotify:album:3IqzqH6ShrRtie9Yd2ODyG"), From 3ca49dc8a6e8d4ef1055f5dbf33ec8f9c9c284a9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 28 Nov 2024 09:18:00 +0100 Subject: [PATCH 0985/1070] Bump samsungtvws to 2.7.1 (#131784) --- homeassistant/components/samsungtv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index d25501b356d82..041e9b8fe9b3b 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -37,7 +37,7 @@ "requirements": [ "getmac==0.9.4", "samsungctl[websocket]==0.7.1", - "samsungtvws[async,encrypted]==2.7.0", + "samsungtvws[async,encrypted]==2.7.1", "wakeonlan==2.1.0", "async-upnp-client==0.41.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index d6699f420f5fd..fc13e72c128df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2610,7 +2610,7 @@ rxv==0.7.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==2.7.0 +samsungtvws[async,encrypted]==2.7.1 # homeassistant.components.sanix sanix==1.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc79e8900730f..0923b49757552 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2086,7 +2086,7 @@ rxv==0.7.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==2.7.0 +samsungtvws[async,encrypted]==2.7.1 # homeassistant.components.sanix sanix==1.0.6 From e2cda54473312be901bb88b0c693a4b4d67dbcd9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 Nov 2024 12:25:16 +0100 Subject: [PATCH 0986/1070] Ensure custom integrations are assigned the custom IQS scale (#131795) --- homeassistant/loader.py | 3 +++ tests/test_loader.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 4313cd2d6e0cd..1fa9d0cd49ded 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -830,6 +830,9 @@ def loggers(self) -> list[str] | None: @cached_property def quality_scale(self) -> str | None: """Return Integration Quality Scale.""" + # Custom integrations default to "custom" quality scale. + if not self.is_built_in or self.overwrites_built_in: + return "custom" return self.manifest.get("quality_scale") @cached_property diff --git a/tests/test_loader.py b/tests/test_loader.py index a39bd63ad0d13..4c3c4eb309fa9 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -547,6 +547,7 @@ def test_integration_properties(hass: HomeAssistant) -> None: ], "mqtt": ["hue/discovery"], "version": "1.0.0", + "quality_scale": "gold", }, ) assert integration.name == "Philips Hue" @@ -585,6 +586,7 @@ def test_integration_properties(hass: HomeAssistant) -> None: assert integration.is_built_in is True assert integration.overwrites_built_in is False assert integration.version == "1.0.0" + assert integration.quality_scale == "gold" integration = loader.Integration( hass, @@ -595,6 +597,7 @@ def test_integration_properties(hass: HomeAssistant) -> None: "domain": "hue", "dependencies": ["test-dep"], "requirements": ["test-req==1.0.0"], + "quality_scale": "gold", }, ) assert integration.is_built_in is False @@ -607,6 +610,7 @@ def test_integration_properties(hass: HomeAssistant) -> None: assert integration.ssdp is None assert integration.mqtt is None assert integration.version is None + assert integration.quality_scale == "custom" integration = loader.Integration( hass, From 9677c6e24c0131104b1217497795f504ad463adb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 28 Nov 2024 13:44:40 +0100 Subject: [PATCH 0987/1070] Remove wrong plural "s" in 'todo.remove_item' action (#131814) --- homeassistant/components/todo/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 45e378c3de523..245e5c82fc8a1 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -78,7 +78,7 @@ "fields": { "item": { "name": "Item name", - "description": "The name for the to-do list items." + "description": "The name for the to-do list item." } } } From e08b71086faa0ba91b969e85e7735530d15c5522 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 28 Nov 2024 13:41:30 +0100 Subject: [PATCH 0988/1070] Fix more flaky translation checks (#131824) --- tests/components/stt/test_init.py | 4 ++++ tests/components/tts/test_init.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index 92225123995a6..3d5daab2bec8b 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -34,6 +34,7 @@ mock_integration, mock_platform, mock_restore_cache, + reset_translation_cache, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -518,6 +519,9 @@ async def test_default_engine_prefer_cloud_entity( assert provider_engine.name == "test" assert async_default_engine(hass) == "stt.cloud_stt_entity" + # Reset the `cloud` translations cache to avoid flaky translation checks + reset_translation_cache(hass, ["cloud"]) + async def test_get_engine_legacy( hass: HomeAssistant, tmp_path: Path, mock_provider: MockSTTProvider diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 9d8dbf3ef94ed..0b01a24720d40 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1990,5 +1990,5 @@ async def test_default_engine_prefer_cloud_entity( assert provider_engine == "test" assert tts.async_default_engine(hass) == "tts.cloud_tts_entity" - # Reset the `cloud` translations cache + # Reset the `cloud` translations cache to avoid flaky translation checks reset_translation_cache(hass, ["cloud"]) From be25b9d4d0632391894e6860fa55641898169ed8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Nov 2024 13:45:10 +0100 Subject: [PATCH 0989/1070] Bump spotifyaio to 0.8.10 (#131827) --- .../components/spotify/browse_media.py | 35 +- .../components/spotify/manifest.json | 4 +- .../components/spotify/media_player.py | 2 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/spotify/fixtures/playlist.json | 466 ++++++++++++++++++ .../spotify/snapshots/test_diagnostics.ambr | 63 +++ .../spotify/snapshots/test_media_browser.ambr | 10 + 8 files changed, 566 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index 1ae5804ea66a8..81cdfdfb3cfb0 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -14,6 +14,7 @@ SpotifyClient, Track, ) +from spotifyaio.models import ItemType, SimplifiedEpisode import yarl from homeassistant.components.media_player import ( @@ -90,6 +91,16 @@ def _get_track_item_payload( } +def _get_episode_item_payload(episode: SimplifiedEpisode) -> ItemPayload: + return { + "id": episode.episode_id, + "name": episode.name, + "type": MediaType.EPISODE, + "uri": episode.uri, + "thumbnail": fetch_image_url(episode.images), + } + + class BrowsableMedia(StrEnum): """Enum of browsable media.""" @@ -345,10 +356,15 @@ async def build_item_response( # noqa: C901 if playlist := await spotify.get_playlist(media_content_id): title = playlist.name image = playlist.images[0].url if playlist.images else None - items = [ - _get_track_item_payload(playlist_track.track) - for playlist_track in playlist.tracks.items - ] + for playlist_item in playlist.tracks.items: + if playlist_item.track.type is ItemType.TRACK: + if TYPE_CHECKING: + assert isinstance(playlist_item.track, Track) + items.append(_get_track_item_payload(playlist_item.track)) + elif playlist_item.track.type is ItemType.EPISODE: + if TYPE_CHECKING: + assert isinstance(playlist_item.track, SimplifiedEpisode) + items.append(_get_episode_item_payload(playlist_item.track)) elif media_content_type == MediaType.ALBUM: if album := await spotify.get_album(media_content_id): title = album.name @@ -370,16 +386,7 @@ async def build_item_response( # noqa: C901 ): title = show.name image = show.images[0].url if show.images else None - items = [ - { - "id": episode.episode_id, - "name": episode.name, - "type": MediaType.EPISODE, - "uri": episode.uri, - "thumbnail": fetch_image_url(episode.images), - } - for episode in show_episodes - ] + items = [_get_episode_item_payload(episode) for episode in show_episodes] try: media_class = CONTENT_TYPE_MEDIA_CLASS[media_content_type] diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index e7b24cb3e1d86..6c5b7382bbbda 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/spotify", "integration_type": "service", "iot_class": "cloud_polling", - "loggers": ["spotipy"], - "requirements": ["spotifyaio==0.8.8"], + "loggers": ["spotifyaio"], + "requirements": ["spotifyaio==0.8.10"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 7687936fe4cd9..20a634efb4244 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -361,6 +361,8 @@ async def async_select_source(self, source: str) -> None: """Select playback device.""" for device in self.devices.data: if device.name == source: + if TYPE_CHECKING: + assert device.device_id is not None await self.coordinator.client.transfer_playback(device.device_id) return diff --git a/requirements_all.txt b/requirements_all.txt index fc13e72c128df..9a4c93ee96e01 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2719,7 +2719,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.8 +spotifyaio==0.8.10 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0923b49757552..b17bd38a84928 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2171,7 +2171,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.8 +spotifyaio==0.8.10 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/tests/components/spotify/fixtures/playlist.json b/tests/components/spotify/fixtures/playlist.json index 36c28cc814b7d..5680ac9109c71 100644 --- a/tests/components/spotify/fixtures/playlist.json +++ b/tests/components/spotify/fixtures/playlist.json @@ -514,6 +514,472 @@ "uri": "spotify:track:2E2znCPaS8anQe21GLxcvJ", "is_local": false } + }, + { + "added_at": "2024-11-28T11:20:58Z", + "added_by": { + "external_urls": { + "spotify": "https://open.spotify.com/user/1112264649" + }, + "href": "https://api.spotify.com/v1/users/1112264649", + "id": "1112264649", + "type": "user", + "uri": "spotify:user:1112264649" + }, + "is_local": false, + "primary_color": null, + "track": { + "explicit": false, + "audio_preview_url": "https://podz-content.spotifycdn.com/audio/clips/06lRxUmh8UNVTByuyxLYqh/clip_132296_192296.mp3", + "description": "Patreon: https://www.patreon.com/safetythirdMerch: https://safetythird.shopYouTube: https://www.youtube.com/@safetythird/Advertising Inquiries: https://redcircle.com/brandsPrivacy & Opt-Out: https://redcircle.com/privacy", + "duration_ms": 3690161, + "episode": true, + "external_urls": { + "spotify": "https://open.spotify.com/episode/3o0RYoo5iOMKSmEbunsbvW" + }, + "href": "https://api.spotify.com/v1/episodes/3o0RYoo5iOMKSmEbunsbvW", + "html_description": "

Patreon: https://www.patreon.com/safetythird

Merch: https://safetythird.shop

YouTube: https://www.youtube.com/@safetythird/



Advertising Inquiries: https://redcircle.com/brands

Privacy & Opt-Out: https://redcircle.com/privacy", + "id": "3o0RYoo5iOMKSmEbunsbvW", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67656300005f1fc7bedd27a4413b1abf926d8a", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab6765630000f68dc7bedd27a4413b1abf926d8a", + "width": 64 + } + ], + "is_externally_hosted": false, + "language": "en-US", + "languages": ["en-US"], + "name": "My Squirrel Has Brain Damage - Safety Third 119", + "release_date": "2024-07-26", + "release_date_precision": "day", + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "PR", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "show": { + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "PR", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "copyrights": [], + "description": "Safety Third is a weekly show hosted by William Osman, NileRed, The Backyard Scientist, Allen Pan, and a couple other YouTube \"Scientists\". Sometimes we have guests, sometimes it's just us, but always: safety is our number three priority.", + "explicit": true, + "external_urls": { + "spotify": "https://open.spotify.com/show/1Y9ExMgMxoBVrgrfU7u0nD" + }, + "href": "https://api.spotify.com/v1/shows/1Y9ExMgMxoBVrgrfU7u0nD", + "html_description": "

Safety Third is a weekly show hosted by William Osman, NileRed, The Backyard Scientist, Allen Pan, and a couple other YouTube "Scientists". Sometimes we have guests, sometimes it's just us, but always: safety is our number three priority.

", + "id": "1Y9ExMgMxoBVrgrfU7u0nD", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67656300005f1fc7bedd27a4413b1abf926d8a", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab6765630000f68dc7bedd27a4413b1abf926d8a", + "width": 64 + } + ], + "is_externally_hosted": false, + "languages": ["en-US"], + "media_type": "audio", + "name": "Safety Third", + "publisher": "Safety Third ", + "total_episodes": 120, + "type": "show", + "uri": "spotify:show:1Y9ExMgMxoBVrgrfU7u0nD" + }, + "track": false, + "type": "episode", + "uri": "spotify:episode:3o0RYoo5iOMKSmEbunsbvW" + }, + "video_thumbnail": { + "url": null + } } ] } diff --git a/tests/components/spotify/snapshots/test_diagnostics.ambr b/tests/components/spotify/snapshots/test_diagnostics.ambr index 40502562da36f..0ac375d18e31d 100644 --- a/tests/components/spotify/snapshots/test_diagnostics.ambr +++ b/tests/components/spotify/snapshots/test_diagnostics.ambr @@ -409,6 +409,69 @@ 'uri': 'spotify:track:2E2znCPaS8anQe21GLxcvJ', }), }), + dict({ + 'track': dict({ + 'description': 'Patreon: https://www.patreon.com/safetythirdMerch: https://safetythird.shopYouTube: https://www.youtube.com/@safetythird/Advertising Inquiries: https://redcircle.com/brandsPrivacy & Opt-Out: https://redcircle.com/privacy', + 'duration_ms': 3690161, + 'episode_id': '3o0RYoo5iOMKSmEbunsbvW', + 'explicit': False, + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/episode/3o0RYoo5iOMKSmEbunsbvW', + }), + 'href': 'https://api.spotify.com/v1/episodes/3o0RYoo5iOMKSmEbunsbvW', + 'images': list([ + dict({ + 'height': 640, + 'url': 'https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a', + 'width': 640, + }), + dict({ + 'height': 300, + 'url': 'https://i.scdn.co/image/ab67656300005f1fc7bedd27a4413b1abf926d8a', + 'width': 300, + }), + dict({ + 'height': 64, + 'url': 'https://i.scdn.co/image/ab6765630000f68dc7bedd27a4413b1abf926d8a', + 'width': 64, + }), + ]), + 'name': 'My Squirrel Has Brain Damage - Safety Third 119', + 'release_date': '2024-07-26', + 'release_date_precision': 'day', + 'show': dict({ + 'description': 'Safety Third is a weekly show hosted by William Osman, NileRed, The Backyard Scientist, Allen Pan, and a couple other YouTube "Scientists". Sometimes we have guests, sometimes it\'s just us, but always: safety is our number three priority.', + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/show/1Y9ExMgMxoBVrgrfU7u0nD', + }), + 'href': 'https://api.spotify.com/v1/shows/1Y9ExMgMxoBVrgrfU7u0nD', + 'images': list([ + dict({ + 'height': 640, + 'url': 'https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a', + 'width': 640, + }), + dict({ + 'height': 300, + 'url': 'https://i.scdn.co/image/ab67656300005f1fc7bedd27a4413b1abf926d8a', + 'width': 300, + }), + dict({ + 'height': 64, + 'url': 'https://i.scdn.co/image/ab6765630000f68dc7bedd27a4413b1abf926d8a', + 'width': 64, + }), + ]), + 'name': 'Safety Third', + 'publisher': 'Safety Third ', + 'show_id': '1Y9ExMgMxoBVrgrfU7u0nD', + 'total_episodes': 120, + 'uri': 'spotify:show:1Y9ExMgMxoBVrgrfU7u0nD', + }), + 'type': 'episode', + 'uri': 'spotify:episode:3o0RYoo5iOMKSmEbunsbvW', + }), + }), ]), }), 'uri': 'spotify:playlist:3cEYpjA9oz9GiPac4AsH4n', diff --git a/tests/components/spotify/snapshots/test_media_browser.ambr b/tests/components/spotify/snapshots/test_media_browser.ambr index 764dc7a10e159..6b21797722742 100644 --- a/tests/components/spotify/snapshots/test_media_browser.ambr +++ b/tests/components/spotify/snapshots/test_media_browser.ambr @@ -649,6 +649,16 @@ 'thumbnail': 'https://i.scdn.co/image/ab67616d0000b27304e57d181ff062f8339d6c71', 'title': 'You Are So Beautiful', }), + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': , + 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:episode:3o0RYoo5iOMKSmEbunsbvW', + 'media_content_type': 'spotify://episode', + 'thumbnail': 'https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a', + 'title': 'My Squirrel Has Brain Damage - Safety Third 119', + }), ]), 'children_media_class': , 'media_class': , From 157198bf419153c5e1eaab0530d0b9d668e420fc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Nov 2024 13:45:51 +0100 Subject: [PATCH 0990/1070] Make wake word selection part of configuration (#131832) --- homeassistant/components/esphome/select.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index ab7654478a7de..71a21186d3da9 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -10,6 +10,7 @@ ) from homeassistant.components.assist_satellite import AssistSatelliteConfiguration from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import restore_state from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -100,7 +101,9 @@ class EsphomeAssistSatelliteWakeWordSelect( """Wake word selector for esphome devices.""" entity_description = SelectEntityDescription( - key="wake_word", translation_key="wake_word" + key="wake_word", + translation_key="wake_word", + entity_category=EntityCategory.CONFIG, ) _attr_should_poll = False _attr_current_option: str | None = None From 9d48f36754379881373f6421917c381ad01ffab5 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 28 Nov 2024 18:30:05 +0100 Subject: [PATCH 0991/1070] Allow empty trigger sentence responses in conversations (#131849) allow empty trigger sentence responses --- homeassistant/components/assist_pipeline/pipeline.py | 2 +- tests/components/conversation/test_init.py | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 96beaf792a7ab..5bbc81adb865c 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1040,7 +1040,7 @@ async def recognize_intent( := await conversation.async_handle_sentence_triggers( self.hass, user_input ) - ): + ) is not None: # Sentence trigger matched trigger_response = intent.IntentResponse( self.pipeline.conversation_language diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 0100e62cf8131..6900ba2d4193c 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -236,12 +236,17 @@ async def test_prepare_agent( assert len(mock_prepare.mock_calls) == 1 -async def test_async_handle_sentence_triggers(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("response_template", "expected_response"), + [("response {{ trigger.device_id }}", "response 1234"), ("", "")], +) +async def test_async_handle_sentence_triggers( + hass: HomeAssistant, response_template: str, expected_response: str +) -> None: """Test handling sentence triggers with async_handle_sentence_triggers.""" assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) - response_template = "response {{ trigger.device_id }}" assert await async_setup_component( hass, "automation", @@ -260,7 +265,6 @@ async def test_async_handle_sentence_triggers(hass: HomeAssistant) -> None: # Device id will be available in response template device_id = "1234" - expected_response = f"response {device_id}" actual_response = await async_handle_sentence_triggers( hass, ConversationInput( From eeb63d42a01662e012e8d5af52102ca0b767be06 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Nov 2024 18:04:00 +0100 Subject: [PATCH 0992/1070] Bump pyatv to 0.16.0 (#131852) --- homeassistant/components/apple_tv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index b4e1b3548783c..b10a14af32b0c 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.15.1"], + "requirements": ["pyatv==0.16.0"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 9a4c93ee96e01..535a5bbc34b93 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1778,7 +1778,7 @@ pyatag==0.3.5.3 pyatmo==8.1.0 # homeassistant.components.apple_tv -pyatv==0.15.1 +pyatv==0.16.0 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b17bd38a84928..077847d260b0e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1449,7 +1449,7 @@ pyatag==0.3.5.3 pyatmo==8.1.0 # homeassistant.components.apple_tv -pyatv==0.15.1 +pyatv==0.16.0 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 From ac4ae0430ee62d411bc21f729e3a2fedd12e8cb4 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 28 Nov 2024 20:50:53 +0100 Subject: [PATCH 0993/1070] Update frontend to 20241127.1 (#131855) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 3063d3d844056..7bd500f17ea93 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241127.0"] + "requirements": ["home-assistant-frontend==20241127.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 691d80f31bfbc..cb3f51476c89f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ habluetooth==3.6.0 hass-nabucasa==0.85.0 hassil==2.0.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241127.0 +home-assistant-frontend==20241127.1 home-assistant-intents==2024.11.27 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 535a5bbc34b93..e0dbe09526e94 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1130,7 +1130,7 @@ hole==0.8.0 holidays==0.61 # homeassistant.components.frontend -home-assistant-frontend==20241127.0 +home-assistant-frontend==20241127.1 # homeassistant.components.conversation home-assistant-intents==2024.11.27 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 077847d260b0e..4083eb83844f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -956,7 +956,7 @@ hole==0.8.0 holidays==0.61 # homeassistant.components.frontend -home-assistant-frontend==20241127.0 +home-assistant-frontend==20241127.1 # homeassistant.components.conversation home-assistant-intents==2024.11.27 From dd186723416a74f960edfc97a02003c79e14267b Mon Sep 17 00:00:00 2001 From: Madhan Date: Thu, 28 Nov 2024 18:48:38 +0000 Subject: [PATCH 0994/1070] Bump PyMetEireann to 2024.11.0 (#131860) Co-authored-by: Joostlek --- homeassistant/components/met_eireann/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/met_eireann/manifest.json b/homeassistant/components/met_eireann/manifest.json index 72afc6977dd97..7b913df4d3c9a 100644 --- a/homeassistant/components/met_eireann/manifest.json +++ b/homeassistant/components/met_eireann/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/met_eireann", "iot_class": "cloud_polling", "loggers": ["meteireann"], - "requirements": ["PyMetEireann==2021.8.0"] + "requirements": ["PyMetEireann==2024.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e0dbe09526e94..0226fa8d924ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -60,7 +60,7 @@ PyFronius==0.7.3 PyLoadAPI==1.3.2 # homeassistant.components.met_eireann -PyMetEireann==2021.8.0 +PyMetEireann==2024.11.0 # homeassistant.components.met # homeassistant.components.norway_air diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4083eb83844f6..ac180f8c6508a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -57,7 +57,7 @@ PyFronius==0.7.3 PyLoadAPI==1.3.2 # homeassistant.components.met_eireann -PyMetEireann==2021.8.0 +PyMetEireann==2024.11.0 # homeassistant.components.met # homeassistant.components.norway_air From 2ea0c547883f56ea390fd755313e977e9d3af20c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Nov 2024 20:52:51 +0100 Subject: [PATCH 0995/1070] Only download translation strings we have defined (#131864) --- script/translations/deduplicate.py | 3 +-- script/translations/develop.py | 25 +------------------- script/translations/download.py | 37 +++++++++++++++++++++++++++++- script/translations/util.py | 23 +++++++++++++++++++ 4 files changed, 61 insertions(+), 27 deletions(-) diff --git a/script/translations/deduplicate.py b/script/translations/deduplicate.py index 8cc4cee3b101a..f92f90115cefc 100644 --- a/script/translations/deduplicate.py +++ b/script/translations/deduplicate.py @@ -7,8 +7,7 @@ from homeassistant.const import Platform from . import upload -from .develop import flatten_translations -from .util import get_base_arg_parser, load_json_from_path +from .util import flatten_translations, get_base_arg_parser, load_json_from_path def get_arguments() -> argparse.Namespace: diff --git a/script/translations/develop.py b/script/translations/develop.py index 00465e1bc2453..9e3a2ded046dc 100644 --- a/script/translations/develop.py +++ b/script/translations/develop.py @@ -9,7 +9,7 @@ from . import download, upload from .const import INTEGRATIONS_DIR -from .util import get_base_arg_parser +from .util import flatten_translations, get_base_arg_parser def valid_integration(integration): @@ -32,29 +32,6 @@ def get_arguments() -> argparse.Namespace: return parser.parse_args() -def flatten_translations(translations): - """Flatten all translations.""" - stack = [iter(translations.items())] - key_stack = [] - flattened_translations = {} - while stack: - for k, v in stack[-1]: - key_stack.append(k) - if isinstance(v, dict): - stack.append(iter(v.items())) - break - if isinstance(v, str): - common_key = "::".join(key_stack) - flattened_translations[common_key] = v - key_stack.pop() - else: - stack.pop() - if key_stack: - key_stack.pop() - - return flattened_translations - - def substitute_translation_references(integration_strings, flattened_translations): """Recursively processes all translation strings for the integration.""" result = {} diff --git a/script/translations/download.py b/script/translations/download.py index 756de46fb612d..3fa7065d058b4 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -7,10 +7,11 @@ from pathlib import Path import re import subprocess +from typing import Any from .const import CLI_2_DOCKER_IMAGE, CORE_PROJECT_ID, INTEGRATIONS_DIR from .error import ExitApp -from .util import get_lokalise_token, load_json_from_path +from .util import flatten_translations, get_lokalise_token, load_json_from_path FILENAME_FORMAT = re.compile(r"strings\.(?P\w+)\.json") DOWNLOAD_DIR = Path("build/translations-download").absolute() @@ -103,7 +104,15 @@ def save_language_translations(lang, translations): f"Skipping {lang} for {component}, as the integration doesn't seem to exist." ) continue + if not ( + Path("homeassistant") / "components" / component / "strings.json" + ).exists(): + print( + f"Skipping {lang} for {component}, as the integration doesn't have a strings.json file." + ) + continue path.parent.mkdir(parents=True, exist_ok=True) + base_translations = pick_keys(component, base_translations) save_json(path, base_translations) if "platform" not in component_translations: @@ -131,6 +140,32 @@ def delete_old_translations(): fil.unlink() +def get_current_keys(component: str) -> dict[str, Any]: + """Get the current keys for a component.""" + strings_path = Path("homeassistant") / "components" / component / "strings.json" + return load_json_from_path(strings_path) + + +def pick_keys(component: str, translations: dict[str, Any]) -> dict[str, Any]: + """Pick the keys that are in the current strings.""" + flat_translations = flatten_translations(translations) + flat_current_keys = flatten_translations(get_current_keys(component)) + flatten_result = {} + for key in flat_current_keys: + if key in flat_translations: + flatten_result[key] = flat_translations[key] + result = {} + for key, value in flatten_result.items(): + parts = key.split("::") + d = result + for part in parts[:-1]: + if part not in d: + d[part] = {} + d = d[part] + d[parts[-1]] = value + return result + + def run(): """Run the script.""" DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) diff --git a/script/translations/util.py b/script/translations/util.py index 8892bb46b7af5..d78b2c4faff21 100644 --- a/script/translations/util.py +++ b/script/translations/util.py @@ -66,3 +66,26 @@ def load_json_from_path(path: pathlib.Path) -> Any: return json.loads(path.read_text()) except json.JSONDecodeError as err: raise JSONDecodeErrorWithPath(err.msg, err.doc, err.pos, path) from err + + +def flatten_translations(translations): + """Flatten all translations.""" + stack = [iter(translations.items())] + key_stack = [] + flattened_translations = {} + while stack: + for k, v in stack[-1]: + key_stack.append(k) + if isinstance(v, dict): + stack.append(iter(v.items())) + break + if isinstance(v, str): + common_key = "::".join(key_stack) + flattened_translations[common_key] = v + key_stack.pop() + else: + stack.pop() + if key_stack: + key_stack.pop() + + return flattened_translations From ee960933dba5f0d2433a54bf96dc4c4d36ab461e Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 28 Nov 2024 11:51:23 -0800 Subject: [PATCH 0996/1070] Fix flaky test in history stats (#131869) --- tests/components/history_stats/test_sensor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 694c5c2070733..d60203676e6f9 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -459,7 +459,11 @@ async def test_async_on_entire_period( def _fake_states(*args, **kwargs): return { "binary_sensor.test_on_id": [ - ha.State("binary_sensor.test_on_id", "on", last_changed=start_time), + ha.State( + "binary_sensor.test_on_id", + "on", + last_changed=(start_time - timedelta(seconds=10)), + ), ha.State("binary_sensor.test_on_id", "on", last_changed=t0), ha.State("binary_sensor.test_on_id", "on", last_changed=t1), ha.State("binary_sensor.test_on_id", "on", last_changed=t2), From f97d96e3aeafd95834627997bfc969d8bc8a4cd4 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Thu, 28 Nov 2024 21:01:00 +0100 Subject: [PATCH 0997/1070] Add captcha to BMW ConfigFlow (#131351) Co-authored-by: Franck Nijhof --- .../bmw_connected_drive/config_flow.py | 71 ++++++++-- .../components/bmw_connected_drive/const.py | 5 + .../bmw_connected_drive/coordinator.py | 5 - .../bmw_connected_drive/strings.json | 10 ++ .../bmw_connected_drive/__init__.py | 9 +- .../snapshots/test_diagnostics.ambr | 6 +- .../bmw_connected_drive/test_config_flow.py | 121 ++++++++++-------- 7 files changed, 153 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 409bfdca6f189..8831895c71eff 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -27,9 +27,18 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.util.ssl import get_default_context from . import DOMAIN -from .const import CONF_ALLOWED_REGIONS, CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN +from .const import ( + CONF_ALLOWED_REGIONS, + CONF_CAPTCHA_REGIONS, + CONF_CAPTCHA_TOKEN, + CONF_CAPTCHA_URL, + CONF_GCID, + CONF_READ_ONLY, + CONF_REFRESH_TOKEN, +) DATA_SCHEMA = vol.Schema( { @@ -41,7 +50,14 @@ translation_key="regions", ) ), - } + }, + extra=vol.REMOVE_EXTRA, +) +CAPTCHA_SCHEMA = vol.Schema( + { + vol.Required(CONF_CAPTCHA_TOKEN): str, + }, + extra=vol.REMOVE_EXTRA, ) @@ -54,6 +70,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, data[CONF_USERNAME], data[CONF_PASSWORD], get_region_from_name(data[CONF_REGION]), + hcaptcha_token=data.get(CONF_CAPTCHA_TOKEN), + verify=get_default_context(), ) try: @@ -79,15 +97,17 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + data: dict[str, Any] = {} + _existing_entry_data: Mapping[str, Any] | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors: dict[str, str] = {} + errors: dict[str, str] = self.data.pop("errors", {}) - if user_input is not None: + if user_input is not None and not errors: unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}" await self.async_set_unique_id(unique_id) @@ -96,22 +116,35 @@ async def async_step_user( else: self._abort_if_unique_id_configured() + # Store user input for later use + self.data.update(user_input) + + # North America and Rest of World require captcha token + if ( + self.data.get(CONF_REGION) in CONF_CAPTCHA_REGIONS + and CONF_CAPTCHA_TOKEN not in self.data + ): + return await self.async_step_captcha() + info = None try: - info = await validate_input(self.hass, user_input) - entry_data = { - **user_input, - CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), - CONF_GCID: info.get(CONF_GCID), - } + info = await validate_input(self.hass, self.data) except MissingCaptcha: errors["base"] = "missing_captcha" except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" + finally: + self.data.pop(CONF_CAPTCHA_TOKEN, None) if info: + entry_data = { + **self.data, + CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), + CONF_GCID: info.get(CONF_GCID), + } + if self.source == SOURCE_REAUTH: return self.async_update_reload_and_abort( self._get_reauth_entry(), data=entry_data @@ -128,7 +161,7 @@ async def async_step_user( schema = self.add_suggested_values_to_schema( DATA_SCHEMA, - self._existing_entry_data, + self._existing_entry_data or self.data, ) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) @@ -147,6 +180,22 @@ async def async_step_reconfigure( self._existing_entry_data = self._get_reconfigure_entry().data return await self.async_step_user() + async def async_step_captcha( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show captcha form.""" + if user_input and user_input.get(CONF_CAPTCHA_TOKEN): + self.data[CONF_CAPTCHA_TOKEN] = user_input[CONF_CAPTCHA_TOKEN].strip() + return await self.async_step_user(self.data) + + return self.async_show_form( + step_id="captcha", + data_schema=CAPTCHA_SCHEMA, + description_placeholders={ + "captcha_url": CONF_CAPTCHA_URL.format(region=self.data[CONF_REGION]) + }, + ) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index 98d4acbfc91d8..750289e9d0a0d 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -8,10 +8,15 @@ ATTR_VIN = "vin" CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"] +CONF_CAPTCHA_REGIONS = ["north_america", "rest_of_world"] CONF_READ_ONLY = "read_only" CONF_ACCOUNT = "account" CONF_REFRESH_TOKEN = "refresh_token" CONF_GCID = "gcid" +CONF_CAPTCHA_TOKEN = "captcha_token" +CONF_CAPTCHA_URL = ( + "https://bimmer-connected.readthedocs.io/en/stable/captcha/{region}.html" +) DATA_HASS_CONFIG = "hass_config" diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index d38b7ffacc2a7..4f560d16f9cd1 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -84,11 +84,6 @@ async def _async_update_data(self) -> None: if self.account.refresh_token != old_refresh_token: self._update_config_entry_refresh_token(self.account.refresh_token) - _LOGGER.debug( - "bimmer_connected: refresh token %s > %s", - old_refresh_token, - self.account.refresh_token, - ) def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None: """Update or delete the refresh_token in the Config Entry.""" diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 0e7a4a32ef45e..8078971acd170 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -7,6 +7,16 @@ "password": "[%key:common::config_flow::data::password%]", "region": "ConnectedDrive Region" } + }, + "captcha": { + "title": "Are you a robot?", + "description": "A captcha is required for BMW login. Visit the external website to complete the challenge and submit the form. Copy the resulting token into the field below.\n\n{captcha_url}\n\nNo data will be exposed outside of your Home Assistant instance.", + "data": { + "captcha_token": "Captcha token" + }, + "data_description": { + "captcha_token": "One-time token retrieved from the captcha challenge." + } } }, "error": { diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index 4d280a1d0e5c8..f490b85474915 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -9,6 +9,7 @@ from homeassistant import config_entries from homeassistant.components.bmw_connected_drive.const import ( + CONF_CAPTCHA_TOKEN, CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, @@ -24,8 +25,12 @@ CONF_PASSWORD: "p4ssw0rd", CONF_REGION: "rest_of_world", } -FIXTURE_REFRESH_TOKEN = "SOME_REFRESH_TOKEN" -FIXTURE_GCID = "SOME_GCID" +FIXTURE_CAPTCHA_INPUT = { + CONF_CAPTCHA_TOKEN: "captcha_token", +} +FIXTURE_USER_INPUT_W_CAPTCHA = FIXTURE_USER_INPUT | FIXTURE_CAPTCHA_INPUT +FIXTURE_REFRESH_TOKEN = "another_token_string" +FIXTURE_GCID = "DUMMY" FIXTURE_CONFIG_ENTRY = { "entry_id": "1", diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 81ef122006976..b87da22a332ee 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -4833,7 +4833,7 @@ }), ]), 'info': dict({ - 'gcid': 'SOME_GCID', + 'gcid': 'DUMMY', 'password': '**REDACTED**', 'refresh_token': '**REDACTED**', 'region': 'rest_of_world', @@ -7202,7 +7202,7 @@ }), ]), 'info': dict({ - 'gcid': 'SOME_GCID', + 'gcid': 'DUMMY', 'password': '**REDACTED**', 'refresh_token': '**REDACTED**', 'region': 'rest_of_world', @@ -8925,7 +8925,7 @@ }), ]), 'info': dict({ - 'gcid': 'SOME_GCID', + 'gcid': 'DUMMY', 'password': '**REDACTED**', 'refresh_token': '**REDACTED**', 'region': 'rest_of_world', diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index f57f1a304ac01..8fa9d9be22b64 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -4,17 +4,14 @@ from unittest.mock import patch from bimmer_connected.api.authentication import MyBMWAuthentication -from bimmer_connected.models import ( - MyBMWAPIError, - MyBMWAuthError, - MyBMWCaptchaMissingError, -) +from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError from httpx import RequestError import pytest from homeassistant import config_entries from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN from homeassistant.components.bmw_connected_drive.const import ( + CONF_CAPTCHA_TOKEN, CONF_READ_ONLY, CONF_REFRESH_TOKEN, ) @@ -23,10 +20,12 @@ from homeassistant.data_entry_flow import FlowResultType from . import ( + FIXTURE_CAPTCHA_INPUT, FIXTURE_CONFIG_ENTRY, FIXTURE_GCID, FIXTURE_REFRESH_TOKEN, FIXTURE_USER_INPUT, + FIXTURE_USER_INPUT_W_CAPTCHA, ) from tests.common import MockConfigEntry @@ -61,7 +60,7 @@ async def test_authentication_error(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, + data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), ) assert result["type"] is FlowResultType.FORM @@ -79,7 +78,7 @@ async def test_connection_error(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, + data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), ) assert result["type"] is FlowResultType.FORM @@ -97,7 +96,7 @@ async def test_api_error(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=deepcopy(FIXTURE_USER_INPUT), + data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), ) assert result["type"] is FlowResultType.FORM @@ -105,6 +104,28 @@ async def test_api_error(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_flow_missing_error(hass: HomeAssistant) -> None: + """Test the external flow with captcha failing once and succeeding the second time.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=deepcopy(FIXTURE_USER_INPUT), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_CAPTCHA_TOKEN: " "} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "missing_captcha"} + + async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: """Test registering an integration and finishing flow works.""" with ( @@ -118,14 +139,22 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=deepcopy(FIXTURE_USER_INPUT), ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME] - assert result2["data"] == FIXTURE_COMPLETE_ENTRY + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_CAPTCHA_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME] + assert result["data"] == FIXTURE_COMPLETE_ENTRY assert len(mock_setup_entry.mock_calls) == 1 @@ -206,13 +235,20 @@ async def test_reauth(hass: HomeAssistant) -> None: assert suggested_values[CONF_PASSWORD] == wrong_password assert suggested_values[CONF_REGION] == FIXTURE_USER_INPUT[CONF_REGION] - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_USER_INPUT + result = await hass.config_entries.flow.async_configure( + result["flow_id"], deepcopy(FIXTURE_USER_INPUT) ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_CAPTCHA_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert config_entry.data == FIXTURE_COMPLETE_ENTRY assert len(mock_setup_entry.mock_calls) == 2 @@ -243,13 +279,13 @@ async def test_reauth_unique_id_abort(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {**FIXTURE_USER_INPUT, CONF_REGION: "north_america"} ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "account_mismatch" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "account_mismatch" assert config_entry.data == config_entry_with_wrong_password["data"] @@ -279,13 +315,20 @@ async def test_reconfigure(hass: HomeAssistant) -> None: assert suggested_values[CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] assert suggested_values[CONF_REGION] == FIXTURE_USER_INPUT[CONF_REGION] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reconfigure_successful" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_CAPTCHA_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert config_entry.data == FIXTURE_COMPLETE_ENTRY @@ -307,40 +350,12 @@ async def test_reconfigure_unique_id_abort(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {**FIXTURE_USER_INPUT, CONF_USERNAME: "somebody@email.com"}, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "account_mismatch" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "account_mismatch" assert config_entry.data == FIXTURE_COMPLETE_ENTRY - - -@pytest.mark.usefixtures("bmw_fixture") -async def test_captcha_flow_not_set(hass: HomeAssistant) -> None: - """Test the external flow with captcha failing once and succeeding the second time.""" - - TEST_REGION = "north_america" - - # Start flow and open form - # Start flow and open form - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - # Add login data - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication._login_row_na", - side_effect=MyBMWCaptchaMissingError( - "Missing hCaptcha token for North America login" - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION}, - ) - assert result["errors"]["base"] == "missing_captcha" From 06838c028001b8a79f04aaaea40819c12b2cf827 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 Nov 2024 21:02:37 +0100 Subject: [PATCH 0998/1070] Bump version to 2024.12.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index bf84dbb6ff957..1e49ea64c0765 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 419d04fdf25af..f57013e7dd96c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.12.0b1" +version = "2024.12.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 4326689f5225143b722af6c8175c8fe2153499f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Nov 2024 21:07:51 -0600 Subject: [PATCH 0999/1070] Bump SQLAlchemy to 2.0.36 (#126683) * Bump SQLAlchemy to 2.0.35 changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.35 * fix mocking * adjust to .36 * remove ignored as these are now typed * fix SQLAlchemy --- .github/workflows/wheels.yml | 2 +- .../components/recorder/db_schema.py | 6 +-- .../components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/sql/test_config_flow.py | 52 ++++++++----------- tests/components/sql/test_sensor.py | 41 +++++++++------ 11 files changed, 59 insertions(+), 56 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index b9f54bba08144..e0a850fa34086 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -143,7 +143,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-dev" - skip-binary: aiohttp;multidict;yarl + skip-binary: aiohttp;multidict;yarl;SQLAlchemy constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 7e8343321c3c5..dbe2b775297bb 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -162,14 +162,14 @@ class Unused(CHAR): """An unused column type that behaves like a string.""" -@compiles(UnusedDateTime, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call] -@compiles(Unused, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call] +@compiles(UnusedDateTime, "mysql", "mariadb", "sqlite") +@compiles(Unused, "mysql", "mariadb", "sqlite") def compile_char_zero(type_: TypeDecorator, compiler: Any, **kw: Any) -> str: """Compile UnusedDateTime and Unused as CHAR(0) on mysql, mariadb, and sqlite.""" return "CHAR(0)" # Uses 1 byte on MySQL (no change on sqlite) -@compiles(Unused, "postgresql") # type: ignore[misc,no-untyped-call] +@compiles(Unused, "postgresql") def compile_char_one(type_: TypeDecorator, compiler: Any, **kw: Any) -> str: """Compile Unused as CHAR(1) on postgresql.""" return "CHAR(1)" # Uses 1 byte diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 2be4b6862bafb..93ffb12d18cf4 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.31", + "SQLAlchemy==2.0.36", "fnv-hash-fast==1.0.2", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index dcb5f47829c61..01c95d6c5e4af 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.31", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.36", "sqlparse==0.5.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cb3f51476c89f..cb7aa1219abe0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -59,7 +59,7 @@ pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 securetar==2024.11.0 -SQLAlchemy==2.0.31 +SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 diff --git a/pyproject.toml b/pyproject.toml index f57013e7dd96c..10e169f711682 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ dependencies = [ "PyYAML==6.0.2", "requests==2.32.3", "securetar==2024.11.0", - "SQLAlchemy==2.0.31", + "SQLAlchemy==2.0.36", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", "typing-extensions>=4.12.2,<5.0", diff --git a/requirements.txt b/requirements.txt index 2cbdeb14b9804..1fa82f175bb48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,7 +37,7 @@ python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 securetar==2024.11.0 -SQLAlchemy==2.0.31 +SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0226fa8d924ba..421c833964d95 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.31 +SQLAlchemy==2.0.36 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac180f8c6508a..5b7a91d41768f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -110,7 +110,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.31 +SQLAlchemy==2.0.36 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index cb990e454b7d0..3f2400c0a323a 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from pathlib import Path from unittest.mock import patch from sqlalchemy.exc import SQLAlchemyError @@ -597,9 +598,6 @@ async def test_options_flow_db_url_empty( "homeassistant.components.sql.async_setup_entry", return_value=True, ), - patch( - "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", - ), ): result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -621,7 +619,9 @@ async def test_options_flow_db_url_empty( async def test_full_flow_not_recorder_db( - recorder_mock: Recorder, hass: HomeAssistant + recorder_mock: Recorder, + hass: HomeAssistant, + tmp_path: Path, ) -> None: """Test full config flow with not using recorder db.""" result = await hass.config_entries.flow.async_init( @@ -629,20 +629,19 @@ async def test_full_flow_not_recorder_db( ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} + db_path = tmp_path / "db.db" + db_path_str = f"sqlite:///{db_path}" with ( patch( "homeassistant.components.sql.async_setup_entry", return_value=True, ), - patch( - "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", - ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "db_url": "sqlite://path/to/db.db", + "db_url": db_path_str, "name": "Get Value", "query": "SELECT 5 as value", "column": "value", @@ -654,7 +653,7 @@ async def test_full_flow_not_recorder_db( assert result2["title"] == "Get Value" assert result2["options"] == { "name": "Get Value", - "db_url": "sqlite://path/to/db.db", + "db_url": db_path_str, "query": "SELECT 5 as value", "column": "value", } @@ -671,15 +670,12 @@ async def test_full_flow_not_recorder_db( "homeassistant.components.sql.async_setup_entry", return_value=True, ), - patch( - "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", - ), ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ "query": "SELECT 5 as value", - "db_url": "sqlite://path/to/db.db", + "db_url": db_path_str, "column": "value", "unit_of_measurement": "MiB", }, @@ -689,7 +685,7 @@ async def test_full_flow_not_recorder_db( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "name": "Get Value", - "db_url": "sqlite://path/to/db.db", + "db_url": db_path_str, "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", @@ -697,24 +693,22 @@ async def test_full_flow_not_recorder_db( # Need to test same again to mitigate issue with db_url removal result = await hass.config_entries.options.async_init(entry.entry_id) - with patch( - "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", - ): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as value", - "db_url": "sqlite://path/to/db.db", - "column": "value", - "unit_of_measurement": "MB", - }, - ) - await hass.async_block_till_done() + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "query": "SELECT 5 as value", + "db_url": db_path_str, + "column": "value", + "unit_of_measurement": "MB", + }, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "name": "Get Value", - "db_url": "sqlite://path/to/db.db", + "db_url": db_path_str, "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MB", @@ -722,7 +716,7 @@ async def test_full_flow_not_recorder_db( assert entry.options == { "name": "Get Value", - "db_url": "sqlite://path/to/db.db", + "db_url": db_path_str, "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MB", diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index b219ad47f3a94..6b4032323d0fb 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -3,12 +3,13 @@ from __future__ import annotations from datetime import timedelta +from pathlib import Path +import sqlite3 from typing import Any from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest -from sqlalchemy import text as sql_text from sqlalchemy.exc import SQLAlchemyError from homeassistant.components.recorder import Recorder @@ -143,29 +144,37 @@ async def test_query_no_value( assert text in caplog.text -async def test_query_mssql_no_result( - recorder_mock: Recorder, hass: HomeAssistant, caplog: pytest.LogCaptureFixture +async def test_query_on_disk_sqlite_no_result( + recorder_mock: Recorder, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + tmp_path: Path, ) -> None: """Test the SQL sensor with a query that returns no value.""" + db_path = tmp_path / "test.db" + db_path_str = f"sqlite:///{db_path}" + + def make_test_db(): + """Create a test database.""" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE users (value INTEGER)") + conn.commit() + conn.close() + + await hass.async_add_executor_job(make_test_db) + config = { - "db_url": "mssql://", - "query": "SELECT 5 as value where 1=2", + "db_url": db_path_str, + "query": "SELECT value from users", "column": "value", - "name": "count_tables", + "name": "count_users", } - with ( - patch("homeassistant.components.sql.sensor.sqlalchemy"), - patch( - "homeassistant.components.sql.sensor.sqlalchemy.text", - return_value=sql_text("SELECT TOP 1 5 as value where 1=2"), - ), - ): - await init_integration(hass, config) + await init_integration(hass, config) - state = hass.states.get("sensor.count_tables") + state = hass.states.get("sensor.count_users") assert state.state == STATE_UNKNOWN - text = "SELECT TOP 1 5 AS VALUE WHERE 1=2 returned no results" + text = "SELECT value from users LIMIT 1; returned no results" assert text in caplog.text From 8eb52edabf4013b638abf6f607fdfba841de4105 Mon Sep 17 00:00:00 2001 From: "Glenn Vandeuren (aka Iondependent)" Date: Sat, 30 Nov 2024 09:30:24 +0100 Subject: [PATCH 1000/1070] Fix modbus state not dumped on restart (#131319) * Fix modbus state not dumped on restart * Update test_init.py * Set event back to stop * Update test_init.py --------- Co-authored-by: VandeurenGlenn <8685280+VandeurenGlenn@users.noreply.github.com> --- homeassistant/components/modbus/modbus.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index d85b4e0e67f42..18d91f8dd3bfc 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -158,8 +158,6 @@ async def async_modbus_setup( async def async_stop_modbus(event: Event) -> None: """Stop Modbus service.""" - - async_dispatcher_send(hass, SIGNAL_STOP_ENTITY) for client in hub_collect.values(): await client.async_close() From 5bf972ff16c3df16658115970552cc2c1152bed9 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Fri, 29 Nov 2024 21:43:31 -0800 Subject: [PATCH 1001/1070] Fix history stats count update immediately after change (#131856) * Fix history stats count update immediately after change * rerun CI --- homeassistant/components/history_stats/data.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 40cf351fd9eb3..f9b79d74cb44c 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -4,6 +4,8 @@ from dataclasses import dataclass import datetime +import logging +import math from homeassistant.components.recorder import get_instance, history from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State @@ -14,6 +16,8 @@ MIN_TIME_UTC = datetime.datetime.min.replace(tzinfo=dt_util.UTC) +_LOGGER = logging.getLogger(__name__) + @dataclass class HistoryStatsState: @@ -186,8 +190,13 @@ def _async_compute_seconds_and_changes( current_state_matches = history_state.state in self._entity_states state_change_timestamp = history_state.last_changed - if state_change_timestamp > now_timestamp: + if math.floor(state_change_timestamp) > now_timestamp: # Shouldn't count states that are in the future + _LOGGER.debug( + "Skipping future timestamp %s (now %s)", + state_change_timestamp, + now_timestamp, + ) continue if previous_state_matches: From aaf3f61675c8cc73009374027f906650a84afc84 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sat, 30 Nov 2024 04:31:56 +0100 Subject: [PATCH 1002/1070] Guard against hostname change in lamarzocco discovery (#131873) * Guard against hostname change in lamarzocco discovery * switch to abort_entries_match --- .../components/lamarzocco/config_flow.py | 1 + .../components/lamarzocco/test_config_flow.py | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 0f288e22c4a01..a727e3fe35796 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -291,6 +291,7 @@ async def async_step_dhcp( CONF_ADDRESS: discovery_info.macaddress, } ) + self._async_abort_entries_match({CONF_ADDRESS: discovery_info.macaddress}) _LOGGER.debug( "Discovered La Marzocco machine %s through DHCP at address %s", diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index f8103ac305441..b206b7b68a3c2 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -493,6 +493,27 @@ async def test_dhcp_discovery( } +async def test_dhcp_discovery_abort_on_hostname_changed( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_cloud_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery aborts when hostname was changed manually.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.1.42", + hostname="custom_name", + macaddress="00:00:00:00:00:00", + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_dhcp_already_configured_and_update( hass: HomeAssistant, mock_lamarzocco: MagicMock, From b60b2fdd7c945219642037fb9420223629d4d18d Mon Sep 17 00:00:00 2001 From: Oliver <10700296+ol-iver@users.noreply.github.com> Date: Sat, 30 Nov 2024 17:27:31 +0100 Subject: [PATCH 1003/1070] Bump denonavr to v1.0.1 (#131882) --- homeassistant/components/denonavr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index eff70b94a18fd..328ab504bd1d3 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==1.0.0"], + "requirements": ["denonavr==1.0.1"], "ssdp": [ { "manufacturer": "Denon", diff --git a/requirements_all.txt b/requirements_all.txt index 421c833964d95..00cce2bd5754a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -752,7 +752,7 @@ deluge-client==1.10.2 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==1.0.0 +denonavr==1.0.1 # homeassistant.components.devialet devialet==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b7a91d41768f..0f66554467cbf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -642,7 +642,7 @@ deluge-client==1.10.2 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==1.0.0 +denonavr==1.0.1 # homeassistant.components.devialet devialet==1.4.5 From 29e80e56c61e0ee57861d68798bbec2791e8580e Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sat, 30 Nov 2024 04:32:20 +0100 Subject: [PATCH 1004/1070] Bump aioacaia to 0.1.10 (#131906) --- homeassistant/components/acaia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json index 49b3489cf9ad6..3f3e1c14d58d1 100644 --- a/homeassistant/components/acaia/manifest.json +++ b/homeassistant/components/acaia/manifest.json @@ -25,5 +25,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioacaia"], - "requirements": ["aioacaia==0.1.9"] + "requirements": ["aioacaia==0.1.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 00cce2bd5754a..84c41569da58b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -173,7 +173,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.9 +aioacaia==0.1.10 # homeassistant.components.airq aioairq==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f66554467cbf..342b8592486dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -161,7 +161,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.9 +aioacaia==0.1.10 # homeassistant.components.airq aioairq==0.4.3 From 572347025b378310bac8ab6d2be252e9948dc321 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 30 Nov 2024 04:11:57 +0100 Subject: [PATCH 1005/1070] Fix media player join action for Music Assistant integration (#131910) * Fix media player join action for Music Assistant integration * Add tests for join/unjoin * add one more test --- .../music_assistant/media_player.py | 10 +-- .../music_assistant/test_media_player.py | 68 +++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index d1d707c92e127..fdf3a0c0c48dc 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -400,13 +400,13 @@ async def async_play_media( async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" player_ids: list[str] = [] + entity_registry = er.async_get(self.hass) for child_entity_id in group_members: # resolve HA entity_id to MA player_id - if (hass_state := self.hass.states.get(child_entity_id)) is None: - continue - if (mass_player_id := hass_state.attributes.get("mass_player_id")) is None: - continue - player_ids.append(mass_player_id) + if not (entity_reg_entry := entity_registry.async_get(child_entity_id)): + raise HomeAssistantError(f"Entity {child_entity_id} not found") + # unique id is the MA player_id + player_ids.append(entity_reg_entry.unique_id) await self.mass.players.player_command_group_many(self.player_id, player_ids) @catch_musicassistant_error diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 26ed5d1e53820..13716b6a47902 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -8,6 +8,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.media_player import ( + ATTR_GROUP_MEMBERS, ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_REPEAT, ATTR_MEDIA_SEEK_POSITION, @@ -16,6 +17,8 @@ ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, + SERVICE_JOIN, + SERVICE_UNJOIN, ) from homeassistant.components.music_assistant.const import DOMAIN as MASS_DOMAIN from homeassistant.components.music_assistant.media_player import ( @@ -269,6 +272,71 @@ async def test_media_player_repeat_set_action( ) +async def test_media_player_join_players_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity join_players action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: entity_id, + ATTR_GROUP_MEMBERS: ["media_player.my_super_test_player_2"], + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/group_many", + target_player=mass_player_id, + child_player_ids=["00:00:00:00:00:02"], + ) + # test again with invalid source player + music_assistant_client.send_command.reset_mock() + with pytest.raises( + HomeAssistantError, match="Entity media_player.blah_blah not found" + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: entity_id, + ATTR_GROUP_MEMBERS: ["media_player.blah_blah"], + }, + blocking=True, + ) + + +async def test_media_player_unjoin_player_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity unjoin player action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/ungroup", player_id=mass_player_id + ) + + async def test_media_player_clear_playlist_action( hass: HomeAssistant, music_assistant_client: MagicMock, From e9b34eaad09b9b05306cf635c81a19cddd697b13 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Fri, 29 Nov 2024 17:29:10 +0000 Subject: [PATCH 1006/1070] Bump aiohomekit to 3.2.7 (#131924) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index cddd61a12c114..b7c82b9fd51be 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.6"], + "requirements": ["aiohomekit==3.2.7"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 84c41569da58b..e0330ce0cd182 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -265,7 +265,7 @@ aioharmony==0.2.10 aiohasupervisor==0.2.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.6 +aiohomekit==3.2.7 # homeassistant.components.hue aiohue==4.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 342b8592486dc..87b4f9b6ccda7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -250,7 +250,7 @@ aioharmony==0.2.10 aiohasupervisor==0.2.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.6 +aiohomekit==3.2.7 # homeassistant.components.hue aiohue==4.7.3 From bb847b346d8dad6dd65a5d33de6fdb5409ef3cb4 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 29 Nov 2024 19:47:33 +0100 Subject: [PATCH 1007/1070] Bump uiprotect to 6.6.4 (#131931) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 9a76ba6f984d8..9730c1e37410a 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==6.6.3", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==6.6.4", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index e0330ce0cd182..87c4bc43c6a71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2897,7 +2897,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.3 +uiprotect==6.6.4 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87b4f9b6ccda7..169c57a587c3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2310,7 +2310,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.3 +uiprotect==6.6.4 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 787a1613ecbd80b875f9608375128ba72a25f388 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 30 Nov 2024 01:13:52 +0100 Subject: [PATCH 1008/1070] Fix KNX IP Secure tunnelling endpoint selection with keyfile (#131941) --- homeassistant/components/knx/__init__.py | 3 +++ homeassistant/components/knx/const.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 9180e287618b1..ea654c358e749 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -54,6 +54,7 @@ CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, CONF_KNX_TELEGRAM_LOG_SIZE, + CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, @@ -352,6 +353,7 @@ def connection_config(self) -> ConnectionConfig: if _conn_type == CONF_KNX_TUNNELING_TCP: return ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP, + individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), gateway_ip=self.entry.data[CONF_HOST], gateway_port=self.entry.data[CONF_PORT], auto_reconnect=True, @@ -364,6 +366,7 @@ def connection_config(self) -> ConnectionConfig: if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE: return ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP_SECURE, + individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), gateway_ip=self.entry.data[CONF_HOST], gateway_port=self.entry.data[CONF_PORT], secure_config=SecureConfig( diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 7a9dfc3454687..a946ded035948 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -104,7 +104,7 @@ class KNXConfigEntryData(TypedDict, total=False): route_back: bool # not required host: str # only required for tunnelling port: int # only required for tunnelling - tunnel_endpoint_ia: str | None + tunnel_endpoint_ia: str | None # tunnelling only - not required (use get()) # KNX secure user_id: int | None # not required user_password: str | None # not required From e48be5c406c5b3fcc7e2a7253c577823da09e45d Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sat, 30 Nov 2024 11:15:19 +0000 Subject: [PATCH 1009/1070] Bump aiomealie to 0.9.4 (#131951) --- homeassistant/components/mealie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../mealie/snapshots/test_diagnostics.ambr | 66 +++++++++---------- .../mealie/snapshots/test_services.ambr | 36 +++++----- 5 files changed, 54 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index f594f1398e307..c555fcbc3d667 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mealie", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["aiomealie==0.9.3"] + "requirements": ["aiomealie==0.9.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 87c4bc43c6a71..cba073a315024 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -298,7 +298,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.3 +aiomealie==0.9.4 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 169c57a587c3b..856d4cc5c4645 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -280,7 +280,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.3 +aiomealie==0.9.4 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index ecb5d1d6cd196..a694c72fcf696 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -15,7 +15,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '229', + 'mealplan_id': 229, 'recipe': dict({ 'description': 'The BEST Roast Chicken recipe is simple, budget friendly, and gives you a tender, mouth-watering chicken full of flavor! Served with roasted vegetables, this recipe is simple enough for any cook!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -42,7 +42,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': '230', + 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -67,7 +67,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '222', + 'mealplan_id': 222, 'recipe': dict({ 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -92,7 +92,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '221', + 'mealplan_id': 221, 'recipe': dict({ 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -117,7 +117,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '219', + 'mealplan_id': 219, 'recipe': dict({ 'description': 'This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -142,7 +142,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': '217', + 'mealplan_id': 217, 'recipe': dict({ 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -167,7 +167,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '212', + 'mealplan_id': 212, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -192,7 +192,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': '211', + 'mealplan_id': 211, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -217,7 +217,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '196', + 'mealplan_id': 196, 'recipe': dict({ 'description': 'Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -242,7 +242,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': '195', + 'mealplan_id': 195, 'recipe': dict({ 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -267,7 +267,7 @@ '__type': "", 'isoformat': '2024-01-21', }), - 'mealplan_id': '1', + 'mealplan_id': 1, 'recipe': None, 'title': 'Aquavite', 'user_id': '6caa6e4d-521f-4ef4-9ed7-388bdd63f47d', @@ -283,7 +283,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '226', + 'mealplan_id': 226, 'recipe': dict({ 'description': 'Te explicamos paso a paso, de manera sencilla, la elaboración de la receta de pollo al curry con leche de coco en 10 minutos. Ingredientes, tiempo de...', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -308,7 +308,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '224', + 'mealplan_id': 224, 'recipe': dict({ 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -333,7 +333,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': '216', + 'mealplan_id': 216, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -360,7 +360,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '220', + 'mealplan_id': 220, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -385,15 +385,15 @@ 'checked': False, 'disable_amount': True, 'display': '2 Apples', - 'food_id': 'None', + 'food_id': None, 'is_food': False, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': 'Apples', 'position': 0, 'quantity': 2.0, - 'unit_id': 'None', + 'unit_id': None, }), dict({ 'checked': False, @@ -402,7 +402,7 @@ 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', 'is_food': True, 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 1, @@ -416,12 +416,12 @@ 'food_id': '96801494-4e26-4148-849a-8155deb76327', 'is_food': True, 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 2, 'quantity': 0.0, - 'unit_id': 'None', + 'unit_id': None, }), ]), 'shopping_list': dict({ @@ -435,15 +435,15 @@ 'checked': False, 'disable_amount': True, 'display': '2 Apples', - 'food_id': 'None', + 'food_id': None, 'is_food': False, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': 'Apples', 'position': 0, 'quantity': 2.0, - 'unit_id': 'None', + 'unit_id': None, }), dict({ 'checked': False, @@ -452,7 +452,7 @@ 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', 'is_food': True, 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 1, @@ -466,12 +466,12 @@ 'food_id': '96801494-4e26-4148-849a-8155deb76327', 'is_food': True, 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 2, 'quantity': 0.0, - 'unit_id': 'None', + 'unit_id': None, }), ]), 'shopping_list': dict({ @@ -485,15 +485,15 @@ 'checked': False, 'disable_amount': True, 'display': '2 Apples', - 'food_id': 'None', + 'food_id': None, 'is_food': False, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': 'Apples', 'position': 0, 'quantity': 2.0, - 'unit_id': 'None', + 'unit_id': None, }), dict({ 'checked': False, @@ -502,7 +502,7 @@ 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', 'is_food': True, 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 1, @@ -516,12 +516,12 @@ 'food_id': '96801494-4e26-4148-849a-8155deb76327', 'is_food': True, 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 2, 'quantity': 0.0, - 'unit_id': 'None', + 'unit_id': None, }), ]), 'shopping_list': dict({ diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 93b5f2cad1d39..4f9ee6a5c0913 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -199,7 +199,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': '230', + 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -221,7 +221,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '229', + 'mealplan_id': 229, 'recipe': dict({ 'description': 'The BEST Roast Chicken recipe is simple, budget friendly, and gives you a tender, mouth-watering chicken full of flavor! Served with roasted vegetables, this recipe is simple enough for any cook!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -243,7 +243,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '226', + 'mealplan_id': 226, 'recipe': dict({ 'description': 'Te explicamos paso a paso, de manera sencilla, la elaboración de la receta de pollo al curry con leche de coco en 10 minutos. Ingredientes, tiempo de...', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -265,7 +265,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '224', + 'mealplan_id': 224, 'recipe': dict({ 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -287,7 +287,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '222', + 'mealplan_id': 222, 'recipe': dict({ 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -309,7 +309,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '221', + 'mealplan_id': 221, 'recipe': dict({ 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -331,7 +331,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '220', + 'mealplan_id': 220, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -353,7 +353,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '219', + 'mealplan_id': 219, 'recipe': dict({ 'description': 'This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -375,7 +375,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': '217', + 'mealplan_id': 217, 'recipe': dict({ 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -397,7 +397,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': '216', + 'mealplan_id': 216, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -419,7 +419,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '212', + 'mealplan_id': 212, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -441,7 +441,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': '211', + 'mealplan_id': 211, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -463,7 +463,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '196', + 'mealplan_id': 196, 'recipe': dict({ 'description': 'Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -485,7 +485,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': '195', + 'mealplan_id': 195, 'recipe': dict({ 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -507,7 +507,7 @@ 'group_id': '3931df86-0679-4579-8c63-4bedc9ca9a85', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 21), - 'mealplan_id': '1', + 'mealplan_id': 1, 'recipe': None, 'title': 'Aquavite', 'user_id': '6caa6e4d-521f-4ef4-9ed7-388bdd63f47d', @@ -714,7 +714,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': datetime.date(2024, 1, 22), - 'mealplan_id': '230', + 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -740,7 +740,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': datetime.date(2024, 1, 22), - 'mealplan_id': '230', + 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -766,7 +766,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': datetime.date(2024, 1, 22), - 'mealplan_id': '230', + 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', From 0d155c416a661c1a84757fba157eb27c53187bbd Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 30 Nov 2024 17:32:53 +0100 Subject: [PATCH 1010/1070] Bump reolink_aio to 0.11.4 (#131957) --- homeassistant/components/reolink/host.py | 2 ++ homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index d2b2bba627612..a8e1de07642d1 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -536,6 +536,8 @@ async def subscribe(self) -> None: async def renew(self) -> None: """Renew the subscription of motion events (lease time is 15 minutes).""" + await self._api.baichuan.check_subscribe_events() + if self._api.baichuan.events_active and self._api.subscribed(SubType.push): # TCP push active, unsubscribe from ONVIF push because not needed self.unregister_webhook() diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 4846ec8cb9454..913864a92fa4d 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.11.3"] + "requirements": ["reolink-aio==0.11.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index cba073a315024..5427fd617181b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2556,7 +2556,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.3 +reolink-aio==0.11.4 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 856d4cc5c4645..abb6578d83233 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2047,7 +2047,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.3 +reolink-aio==0.11.4 # homeassistant.components.rflink rflink==0.0.66 From e8ef990e72325d3cf36451e6c572154fa07ce153 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Nov 2024 14:47:40 -0600 Subject: [PATCH 1011/1070] Strip trailing spaces from HomeKit names (#131971) --- homeassistant/components/homekit/util.py | 2 +- tests/components/homekit/test_accessories.py | 4 ++-- tests/components/homekit/test_util.py | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index ae7e35030be29..8fc2a039304f9 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -114,7 +114,7 @@ NUMBERS_ONLY_RE = re.compile(r"[^\d.]+") VERSION_RE = re.compile(r"([0-9]+)(\.[0-9]+)?(\.[0-9]+)?") -INVALID_END_CHARS = "-_" +INVALID_END_CHARS = "-_ " MAX_VERSION_PART = 2**32 - 1 diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index c37cac84b8ab5..00cf42bb91631 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -121,7 +121,7 @@ async def test_home_accessory(hass: HomeAssistant, hk_driver) -> None: serv = acc3.services[0] # SERV_ACCESSORY_INFO assert ( serv.get_characteristic(CHAR_NAME).value - == "Home Accessory that exceeds the maximum maximum maximum maximum " + == "Home Accessory that exceeds the maximum maximum maximum maximum" ) assert ( serv.get_characteristic(CHAR_MANUFACTURER).value @@ -154,7 +154,7 @@ async def test_home_accessory(hass: HomeAssistant, hk_driver) -> None: serv = acc4.services[0] # SERV_ACCESSORY_INFO assert ( serv.get_characteristic(CHAR_NAME).value - == "Home Accessory that exceeds the maximum maximum maximum maximum " + == "Home Accessory that exceeds the maximum maximum maximum maximum" ) assert ( serv.get_characteristic(CHAR_MANUFACTURER).value diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 7f7e3ee0ce080..20e536baf81fe 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -256,6 +256,7 @@ def test_cleanup_name_for_homekit() -> None: """Ensure name sanitize works as expected.""" assert cleanup_name_for_homekit("abc") == "abc" + assert cleanup_name_for_homekit("abc ") == "abc" assert cleanup_name_for_homekit("a b c") == "a b c" assert cleanup_name_for_homekit("ab_c") == "ab c" assert ( From 673bdcc556bc6a39c9f5b7c54d82b0d02c38cdeb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Nov 2024 16:09:37 -0600 Subject: [PATCH 1012/1070] Reduce precision loss when converting HomeKit temperature (#131973) --- homeassistant/components/homekit/util.py | 12 ++---------- tests/components/homekit/test_type_thermostats.py | 10 +++++----- tests/components/homekit/test_util.py | 8 +++++--- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 8fc2a039304f9..8395c1a8c9a60 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -424,20 +424,12 @@ def cleanup_name_for_homekit(name: str | None) -> str: def temperature_to_homekit(temperature: float, unit: str) -> float: """Convert temperature to Celsius for HomeKit.""" - return round( - TemperatureConverter.convert(temperature, unit, UnitOfTemperature.CELSIUS), 1 - ) + return TemperatureConverter.convert(temperature, unit, UnitOfTemperature.CELSIUS) def temperature_to_states(temperature: float, unit: str) -> float: """Convert temperature back from Celsius to Home Assistant unit.""" - return ( - round( - TemperatureConverter.convert(temperature, UnitOfTemperature.CELSIUS, unit) - * 2 - ) - / 2 - ) + return TemperatureConverter.convert(temperature, UnitOfTemperature.CELSIUS, unit) def density_to_air_quality(density: float) -> int: diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 8454610566b44..e99db8f6234aa 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -921,8 +921,8 @@ async def test_thermostat_fahrenheit( await hass.async_block_till_done() assert call_set_temperature[0] assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id - assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 73.5 - assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 68 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 73.4 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 68.18 assert len(events) == 1 assert events[-1].data[ATTR_VALUE] == "CoolingThresholdTemperature to 23°C" @@ -942,8 +942,8 @@ async def test_thermostat_fahrenheit( await hass.async_block_till_done() assert call_set_temperature[1] assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id - assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 73.5 - assert call_set_temperature[1].data[ATTR_TARGET_TEMP_LOW] == 71.5 + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 73.4 + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_LOW] == 71.6 assert len(events) == 2 assert events[-1].data[ATTR_VALUE] == "HeatingThresholdTemperature to 22°C" @@ -962,7 +962,7 @@ async def test_thermostat_fahrenheit( await hass.async_block_till_done() assert call_set_temperature[2] assert call_set_temperature[2].data[ATTR_ENTITY_ID] == entity_id - assert call_set_temperature[2].data[ATTR_TEMPERATURE] == 75.0 + assert call_set_temperature[2].data[ATTR_TEMPERATURE] == 75.2 assert len(events) == 3 assert events[-1].data[ATTR_VALUE] == "TargetTemperature to 24.0°C" diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 20e536baf81fe..30efd7fcc5c46 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -268,14 +268,16 @@ def test_cleanup_name_for_homekit() -> None: def test_temperature_to_homekit() -> None: """Test temperature conversion from HA to HomeKit.""" - assert temperature_to_homekit(20.46, UnitOfTemperature.CELSIUS) == 20.5 - assert temperature_to_homekit(92.1, UnitOfTemperature.FAHRENHEIT) == 33.4 + assert temperature_to_homekit(20.46, UnitOfTemperature.CELSIUS) == 20.46 + assert temperature_to_homekit(92.1, UnitOfTemperature.FAHRENHEIT) == pytest.approx( + 33.388888888888886 + ) def test_temperature_to_states() -> None: """Test temperature conversion from HomeKit to HA.""" assert temperature_to_states(20, UnitOfTemperature.CELSIUS) == 20.0 - assert temperature_to_states(20.2, UnitOfTemperature.FAHRENHEIT) == 68.5 + assert temperature_to_states(20.2, UnitOfTemperature.FAHRENHEIT) == 68.36 def test_density_to_air_quality() -> None: From d7428786cdcac337e1e5f383e69832b1e7bdab44 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 1 Dec 2024 03:14:16 +0000 Subject: [PATCH 1013/1070] Bump version to 2024.12.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1e49ea64c0765..b91d0cd53be63 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 10e169f711682..469abb4aca02e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.12.0b2" +version = "2024.12.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From e2073d7762f3e639b5fa5ec6533d1a6327c41f43 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 1 Dec 2024 22:01:19 +0100 Subject: [PATCH 1014/1070] Bugfix for Plugwise, small code optimization (#131990) --- homeassistant/components/plugwise/climate.py | 51 +++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index f1f54aa6647c6..242b0944782b2 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -78,19 +78,18 @@ def __init__( self._attr_extra_state_attributes = {} self._attr_unique_id = f"{device_id}-climate" + self._devices = coordinator.data.devices + self._gateway = coordinator.data.gateway + gateway_id: str = self._gateway["gateway_id"] + self._gateway_data = self._devices[gateway_id] + self._location = device_id if (location := self.device.get("location")) is not None: self._location = location - self.cdr_gateway = coordinator.data.gateway - gateway_id: str = coordinator.data.gateway["gateway_id"] - self.gateway_data = coordinator.data.devices[gateway_id] # Determine supported features self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - if ( - self.cdr_gateway["cooling_present"] - and self.cdr_gateway["smile_name"] != "Adam" - ): + if self._gateway["cooling_present"] and self._gateway["smile_name"] != "Adam": self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) @@ -116,10 +115,10 @@ def _previous_action_mode(self, coordinator: PlugwiseDataUpdateCoordinator) -> N """ # When no cooling available, _previous_mode is always heating if ( - "regulation_modes" in self.gateway_data - and "cooling" in self.gateway_data["regulation_modes"] + "regulation_modes" in self._gateway_data + and "cooling" in self._gateway_data["regulation_modes"] ): - mode = self.gateway_data["select_regulation_mode"] + mode = self._gateway_data["select_regulation_mode"] if mode in ("cooling", "heating"): self._previous_mode = mode @@ -166,17 +165,17 @@ def hvac_mode(self) -> HVACMode: def hvac_modes(self) -> list[HVACMode]: """Return a list of available HVACModes.""" hvac_modes: list[HVACMode] = [] - if "regulation_modes" in self.gateway_data: + if "regulation_modes" in self._gateway_data: hvac_modes.append(HVACMode.OFF) if "available_schedules" in self.device: hvac_modes.append(HVACMode.AUTO) - if self.cdr_gateway["cooling_present"]: - if "regulation_modes" in self.gateway_data: - if self.gateway_data["select_regulation_mode"] == "cooling": + if self._gateway["cooling_present"]: + if "regulation_modes" in self._gateway_data: + if self._gateway_data["select_regulation_mode"] == "cooling": hvac_modes.append(HVACMode.COOL) - if self.gateway_data["select_regulation_mode"] == "heating": + if self._gateway_data["select_regulation_mode"] == "heating": hvac_modes.append(HVACMode.HEAT) else: hvac_modes.append(HVACMode.HEAT_COOL) @@ -192,17 +191,21 @@ def hvac_action(self) -> HVACAction: self._previous_action_mode(self.coordinator) # Adam provides the hvac_action for each thermostat - if (control_state := self.device.get("control_state")) == "cooling": - return HVACAction.COOLING - if control_state == "heating": - return HVACAction.HEATING - if control_state == "preheating": - return HVACAction.PREHEATING - if control_state == "off": + if self._gateway["smile_name"] == "Adam": + if (control_state := self.device.get("control_state")) == "cooling": + return HVACAction.COOLING + if control_state == "heating": + return HVACAction.HEATING + if control_state == "preheating": + return HVACAction.PREHEATING + if control_state == "off": + return HVACAction.IDLE + return HVACAction.IDLE - heater: str = self.coordinator.data.gateway["heater_id"] - heater_data = self.coordinator.data.devices[heater] + # Anna + heater: str = self._gateway["heater_id"] + heater_data = self._devices[heater] if heater_data["binary_sensors"]["heating_state"]: return HVACAction.HEATING if heater_data["binary_sensors"].get("cooling_state", False): From b6dec11487b011c1060c37a8c6a63162f627d2ab Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 1 Dec 2024 16:17:55 +0100 Subject: [PATCH 1015/1070] Freeze integration setup timeout for recorder during non-live data migration (#131998) --- homeassistant/components/recorder/core.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 8c2e1c9e006a3..0c61f8a955ec9 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -740,7 +740,7 @@ def _run(self) -> None: self.schema_version = schema_status.current_version # Do non-live data migration - migration.migrate_data_non_live(self, self.get_session, schema_status) + self._migrate_data_offline(schema_status) # Non-live migration is now completed, remaining steps are live self.migration_is_live = True @@ -916,6 +916,13 @@ def _setup_recorder(self) -> bool: return False + def _migrate_data_offline( + self, schema_status: migration.SchemaValidationStatus + ) -> None: + """Migrate data.""" + with self.hass.timeout.freeze(DOMAIN): + migration.migrate_data_non_live(self, self.get_session, schema_status) + def _migrate_schema_offline( self, schema_status: migration.SchemaValidationStatus ) -> tuple[bool, migration.SchemaValidationStatus]: From 79c919f62d18acdb0a55f528e07e91bf7053d732 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Sun, 1 Dec 2024 21:45:31 +0100 Subject: [PATCH 1016/1070] Bump bimmer_connected to 0.17.2 (#132005) --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index d1ca735ce55eb..81928a59a52bc 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected[china]==0.17.0"] + "requirements": ["bimmer-connected[china]==0.17.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5427fd617181b..c72ecc77647d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -582,7 +582,7 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.17.0 +bimmer-connected[china]==0.17.2 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index abb6578d83233..079f82089aebc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -516,7 +516,7 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.17.0 +bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome From 4e0cdb0537496a557dbc11b475fd8236c4acb111 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Dec 2024 14:37:03 -0600 Subject: [PATCH 1017/1070] Bump propcache to 0.2.1 (#132022) --- .github/workflows/wheels.yml | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e0a850fa34086..749f95fa92216 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -143,7 +143,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-dev" - skip-binary: aiohttp;multidict;yarl;SQLAlchemy + skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cb7aa1219abe0..5c0db0659d6a6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,7 +45,7 @@ orjson==3.10.12 packaging>=23.1 paho-mqtt==1.6.1 Pillow==11.0.0 -propcache==0.2.0 +propcache==0.2.1 psutil-home-assistant==0.0.1 PyJWT==2.10.0 pymicro-vad==1.0.1 diff --git a/pyproject.toml b/pyproject.toml index 469abb4aca02e..aee114d01bf6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ # PyJWT has loose dependency. We want the latest one. "cryptography==43.0.1", "Pillow==11.0.0", - "propcache==0.2.0", + "propcache==0.2.1", "pyOpenSSL==24.2.1", "orjson==3.10.12", "packaging>=23.1", diff --git a/requirements.txt b/requirements.txt index 1fa82f175bb48..514ab132bc87e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ lru-dict==1.3.0 PyJWT==2.10.0 cryptography==43.0.1 Pillow==11.0.0 -propcache==0.2.0 +propcache==0.2.1 pyOpenSSL==24.2.1 orjson==3.10.12 packaging>=23.1 From f2bafee84a3f64f8ad497d8c3015feedf907a40e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Dec 2024 20:17:36 -0600 Subject: [PATCH 1018/1070] Bump yarl to 1.18.3 (#132025) changelog: https://github.com/aio-libs/yarl/compare/v1.18.0...v1.18.3 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5c0db0659d6a6..a07536160a9da 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,7 +70,7 @@ voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 -yarl==1.18.0 +yarl==1.18.3 zeroconf==0.136.2 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index aee114d01bf6a..5f72c2bf99b37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.18.0", + "yarl==1.18.3", "webrtc-models==0.3.0", ] diff --git a/requirements.txt b/requirements.txt index 514ab132bc87e..40a372856b648 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,5 +47,5 @@ uv==0.5.4 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.18.0 +yarl==1.18.3 webrtc-models==0.3.0 From 6b6fc6bbebca5944ca467de5a67df77b099488c0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 1 Dec 2024 22:39:26 +0100 Subject: [PATCH 1019/1070] Bump yt-dlp to 2024.11.18 (#132026) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index ebfa79d71902c..866215839bf9b 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2024.11.04"], + "requirements": ["yt-dlp[default]==2024.11.18"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index c72ecc77647d2..79cb785b52197 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3066,7 +3066,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.11.04 +yt-dlp[default]==2024.11.18 # homeassistant.components.zamg zamg==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 079f82089aebc..80f1da80096ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2452,7 +2452,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.11.04 +yt-dlp[default]==2024.11.18 # homeassistant.components.zamg zamg==0.3.6 From e4d19541f5ffc0bbde35483d1e30c62e41bdb0bc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 1 Dec 2024 23:05:34 +0100 Subject: [PATCH 1020/1070] Bump spotifyaio to 0.8.11 (#132032) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 6c5b7382bbbda..27b8da7cecf0f 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["spotifyaio"], - "requirements": ["spotifyaio==0.8.10"], + "requirements": ["spotifyaio==0.8.11"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 79cb785b52197..7a6252c2102ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2719,7 +2719,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.10 +spotifyaio==0.8.11 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80f1da80096ac..3b98cc097aa98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2171,7 +2171,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.10 +spotifyaio==0.8.11 # homeassistant.components.sql sqlparse==0.5.0 From fab35f227d6bf308a70397bca2475cc8906dff4d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 2 Dec 2024 03:17:07 +0100 Subject: [PATCH 1021/1070] Handle not found playlists in Spotify (#132033) * Handle not found playlists * Handle not found playlists * Handle not found playlists * Handle not found playlists * Handle not found playlists * Update homeassistant/components/spotify/coordinator.py --------- Co-authored-by: Paulus Schoutsen --- .../components/spotify/coordinator.py | 25 ++++- tests/components/spotify/test_media_player.py | 93 +++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index a7c95e3124573..099b1cb3ca857 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -11,6 +11,7 @@ Playlist, SpotifyClient, SpotifyConnectionError, + SpotifyNotFoundError, UserProfile, ) @@ -62,6 +63,7 @@ def __init__(self, hass: HomeAssistant, client: SpotifyClient) -> None: ) self.client = client self._playlist: Playlist | None = None + self._checked_playlist_id: str | None = None async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -87,15 +89,29 @@ async def _async_update_data(self) -> SpotifyCoordinatorData: dj_playlist = False if (context := current.context) is not None: - if self._playlist is None or self._playlist.uri != context.uri: + dj_playlist = context.uri == SPOTIFY_DJ_PLAYLIST_URI + if not ( + context.uri + in ( + self._checked_playlist_id, + SPOTIFY_DJ_PLAYLIST_URI, + ) + or (self._playlist is None and context.uri == self._checked_playlist_id) + ): + self._checked_playlist_id = context.uri self._playlist = None - if context.uri == SPOTIFY_DJ_PLAYLIST_URI: - dj_playlist = True - elif context.context_type == ContextType.PLAYLIST: + if context.context_type == ContextType.PLAYLIST: # Make sure any playlist lookups don't break the current # playback state update try: self._playlist = await self.client.get_playlist(context.uri) + except SpotifyNotFoundError: + _LOGGER.debug( + "Spotify playlist '%s' not found. " + "Most likely a Spotify-created playlist", + context.uri, + ) + self._playlist = None except SpotifyConnectionError: _LOGGER.debug( "Unable to load spotify playlist '%s'. " @@ -103,6 +119,7 @@ async def _async_update_data(self) -> SpotifyCoordinatorData: context.uri, ) self._playlist = None + self._checked_playlist_id = None return SpotifyCoordinatorData( current_playback=current, position_updated_at=position_updated_at, diff --git a/tests/components/spotify/test_media_player.py b/tests/components/spotify/test_media_player.py index b03424f8459ce..55e0ea8f1d8d3 100644 --- a/tests/components/spotify/test_media_player.py +++ b/tests/components/spotify/test_media_player.py @@ -10,6 +10,7 @@ ProductType, RepeatMode as SpotifyRepeatMode, SpotifyConnectionError, + SpotifyNotFoundError, ) from syrupy import SnapshotAssertion @@ -142,6 +143,7 @@ async def test_spotify_dj_list( hass: HomeAssistant, mock_spotify: MagicMock, mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Spotify entities with a Spotify DJ playlist.""" mock_spotify.return_value.get_playback.return_value.context.uri = ( @@ -152,12 +154,67 @@ async def test_spotify_dj_list( assert state assert state.attributes["media_playlist"] == "DJ" + mock_spotify.return_value.get_playlist.assert_not_called() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("media_player.spotify_spotify_1") + assert state + assert state.attributes["media_playlist"] == "DJ" + + mock_spotify.return_value.get_playlist.assert_not_called() + + +@pytest.mark.usefixtures("setup_credentials") +async def test_normal_playlist( + hass: HomeAssistant, + mock_spotify: MagicMock, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, +) -> None: + """Test normal playlist switching.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("media_player.spotify_spotify_1") + assert state + assert state.attributes["media_playlist"] == "Spotify Web API Testing playlist" + + mock_spotify.return_value.get_playlist.assert_called_once_with( + "spotify:user:rushofficial:playlist:2r35vbe6hHl6yDSMfjKgmm" + ) + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("media_player.spotify_spotify_1") + assert state + assert state.attributes["media_playlist"] == "Spotify Web API Testing playlist" + + mock_spotify.return_value.get_playlist.assert_called_once_with( + "spotify:user:rushofficial:playlist:2r35vbe6hHl6yDSMfjKgmm" + ) + + mock_spotify.return_value.get_playback.return_value.context.uri = ( + "spotify:playlist:123123123123123" + ) + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playlist.assert_called_with( + "spotify:playlist:123123123123123" + ) + @pytest.mark.usefixtures("setup_credentials") async def test_fetching_playlist_does_not_fail( hass: HomeAssistant, mock_spotify: MagicMock, mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test failing fetching playlist does not fail update.""" mock_spotify.return_value.get_playlist.side_effect = SpotifyConnectionError @@ -166,6 +223,42 @@ async def test_fetching_playlist_does_not_fail( assert state assert "media_playlist" not in state.attributes + mock_spotify.return_value.get_playlist.assert_called_once() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_spotify.return_value.get_playlist.call_count == 2 + + +@pytest.mark.usefixtures("setup_credentials") +async def test_fetching_playlist_once( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that not being able to find a playlist doesn't retry.""" + mock_spotify.return_value.get_playlist.side_effect = SpotifyNotFoundError + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("media_player.spotify_spotify_1") + assert state + assert "media_playlist" not in state.attributes + + mock_spotify.return_value.get_playlist.assert_called_once() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("media_player.spotify_spotify_1") + assert state + assert "media_playlist" not in state.attributes + + mock_spotify.return_value.get_playlist.assert_called_once() + @pytest.mark.usefixtures("setup_credentials") async def test_idle( From 8ff8cd8b657f1e6d42c8f5885b1332b7399c71b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Dec 2024 20:05:45 -0600 Subject: [PATCH 1022/1070] Bump aiohttp to 3.11.9 (#132036) changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.8...v3.11.9 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a07536160a9da..4b6777417e94f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.2.0 -aiohttp==3.11.8 +aiohttp==3.11.9 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 5f72c2bf99b37..98a9778e8dd67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.8", + "aiohttp==3.11.9", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index 40a372856b648..0f5047a0bbb34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.8 +aiohttp==3.11.9 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 aiozoneinfo==0.2.1 From d956e4b11d5780cb26a574169d1d8b90a43fa942 Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 2 Dec 2024 19:24:49 +1100 Subject: [PATCH 1023/1070] Bump psymlight v0.1.4 (#132045) --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index c1eca45871b20..cb791ac111bde 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.1.3"], + "requirements": ["pysmlight==0.1.4"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 7a6252c2102ad..eae98405012b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2269,7 +2269,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.3 +pysmlight==0.1.4 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b98cc097aa98..2a14c769ce537 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1829,7 +1829,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.3 +pysmlight==0.1.4 # homeassistant.components.snmp pysnmp==6.2.6 From 1e5a5925e6cf6155dee7b7556d923ec4cf8c339c Mon Sep 17 00:00:00 2001 From: ashionky <35916938+ashionky@users.noreply.github.com> Date: Mon, 2 Dec 2024 18:05:09 +0800 Subject: [PATCH 1024/1070] Bump refoss to v1.2.5 (#132051) --- homeassistant/components/refoss/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/refoss/manifest.json b/homeassistant/components/refoss/manifest.json index bf046e954d1f7..da7050433f3d3 100644 --- a/homeassistant/components/refoss/manifest.json +++ b/homeassistant/components/refoss/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/refoss", "iot_class": "local_polling", - "requirements": ["refoss-ha==1.2.4"] + "requirements": ["refoss-ha==1.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index eae98405012b7..0100b67d7858e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2544,7 +2544,7 @@ rapt-ble==0.1.2 raspyrfm-client==1.2.8 # homeassistant.components.refoss -refoss-ha==1.2.4 +refoss-ha==1.2.5 # homeassistant.components.rainmachine regenmaschine==2024.03.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2a14c769ce537..c938702efb797 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2035,7 +2035,7 @@ radiotherm==2.1.0 rapt-ble==0.1.2 # homeassistant.components.refoss -refoss-ha==1.2.4 +refoss-ha==1.2.5 # homeassistant.components.rainmachine regenmaschine==2024.03.0 From c3c500955ac9d3dc18a5316cc4222af30b9017c9 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:38:39 +0100 Subject: [PATCH 1025/1070] Use format_mac correctly for acaia (#132062) --- homeassistant/components/acaia/config_flow.py | 10 +++++----- homeassistant/components/acaia/entity.py | 7 ++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/acaia/config_flow.py b/homeassistant/components/acaia/config_flow.py index 36727059c8a8a..fb2639fc886df 100644 --- a/homeassistant/components/acaia/config_flow.py +++ b/homeassistant/components/acaia/config_flow.py @@ -42,7 +42,7 @@ async def async_step_user( errors: dict[str, str] = {} if user_input is not None: - mac = format_mac(user_input[CONF_ADDRESS]) + mac = user_input[CONF_ADDRESS] try: is_new_style_scale = await is_new_scale(mac) except AcaiaDeviceNotFound: @@ -53,12 +53,12 @@ async def async_step_user( except AcaiaUnknownDevice: return self.async_abort(reason="unsupported_device") else: - await self.async_set_unique_id(mac) + await self.async_set_unique_id(format_mac(mac)) self._abort_if_unique_id_configured() if not errors: return self.async_create_entry( - title=self._discovered_devices[user_input[CONF_ADDRESS]], + title=self._discovered_devices[mac], data={ CONF_ADDRESS: mac, CONF_IS_NEW_STYLE_SCALE: is_new_style_scale, @@ -99,10 +99,10 @@ async def async_step_bluetooth( ) -> ConfigFlowResult: """Handle a discovered Bluetooth device.""" - self._discovered[CONF_ADDRESS] = mac = format_mac(discovery_info.address) + self._discovered[CONF_ADDRESS] = discovery_info.address self._discovered[CONF_NAME] = discovery_info.name - await self.async_set_unique_id(mac) + await self.async_set_unique_id(format_mac(discovery_info.address)) self._abort_if_unique_id_configured() try: diff --git a/homeassistant/components/acaia/entity.py b/homeassistant/components/acaia/entity.py index 8a2108d26871b..db01b414b9903 100644 --- a/homeassistant/components/acaia/entity.py +++ b/homeassistant/components/acaia/entity.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,10 +25,11 @@ def __init__( super().__init__(coordinator) self.entity_description = entity_description self._scale = coordinator.scale - self._attr_unique_id = f"{self._scale.mac}_{entity_description.key}" + formatted_mac = format_mac(self._scale.mac) + self._attr_unique_id = f"{formatted_mac}_{entity_description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._scale.mac)}, + identifiers={(DOMAIN, formatted_mac)}, manufacturer="Acaia", model=self._scale.model, suggested_area="Kitchen", From be40db3dff0a54edb33da7045b897e00cc82f588 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 2 Dec 2024 13:02:23 +0100 Subject: [PATCH 1026/1070] Bump version to 2024.12.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b91d0cd53be63..5617ab1d22ac7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 98a9778e8dd67..2f86ff4a6c2cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.12.0b3" +version = "2024.12.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 110935461e9a0d256878dbeca5451f32c18b3470 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 3 Dec 2024 13:06:18 +0100 Subject: [PATCH 1027/1070] Add support for features changing at runtime in Matter integration (#129426) --- homeassistant/components/matter/adapter.py | 18 ++++--- .../components/matter/binary_sensor.py | 1 + homeassistant/components/matter/button.py | 1 + homeassistant/components/matter/const.py | 2 + homeassistant/components/matter/discovery.py | 24 ++++++++-- homeassistant/components/matter/entity.py | 39 ++++++++++++++- homeassistant/components/matter/lock.py | 1 - homeassistant/components/matter/models.py | 7 +++ .../matter/fixtures/nodes/door_lock.json | 2 +- .../matter/snapshots/test_binary_sensor.ambr | 47 ------------------- tests/components/matter/test_binary_sensor.py | 32 +++++++++++++ 11 files changed, 113 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 475e4a4453802..0ccd3e065ffb0 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -45,6 +45,7 @@ def __init__( self.hass = hass self.config_entry = config_entry self.platform_handlers: dict[Platform, AddEntitiesCallback] = {} + self.discovered_entities: set[str] = set() def register_platform_handler( self, platform: Platform, add_entities: AddEntitiesCallback @@ -54,23 +55,19 @@ def register_platform_handler( async def setup_nodes(self) -> None: """Set up all existing nodes and subscribe to new nodes.""" - initialized_nodes: set[int] = set() for node in self.matter_client.get_nodes(): - initialized_nodes.add(node.node_id) self._setup_node(node) def node_added_callback(event: EventType, node: MatterNode) -> None: """Handle node added event.""" - initialized_nodes.add(node.node_id) self._setup_node(node) def node_updated_callback(event: EventType, node: MatterNode) -> None: """Handle node updated event.""" - if node.node_id in initialized_nodes: - return if not node.available: return - initialized_nodes.add(node.node_id) + # We always run the discovery logic again, + # because the firmware version could have been changed or features added. self._setup_node(node) def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None: @@ -237,11 +234,20 @@ def _setup_endpoint(self, endpoint: MatterEndpoint) -> None: self._create_device_registry(endpoint) # run platform discovery from device type instances for entity_info in async_discover_entities(endpoint): + discovery_key = ( + f"{entity_info.platform}_{endpoint.node.node_id}_{endpoint.endpoint_id}_" + f"{entity_info.primary_attribute.cluster_id}_" + f"{entity_info.primary_attribute.attribute_id}_" + f"{entity_info.entity_description.key}" + ) + if discovery_key in self.discovered_entities: + continue LOGGER.debug( "Creating %s entity for %s", entity_info.platform, entity_info.primary_attribute, ) + self.discovered_entities.add(discovery_key) new_entity = entity_info.entity_class( self.matter_client, endpoint, entity_info ) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 875b063dc88be..6882078a71219 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -159,6 +159,7 @@ def _update_from_device(self) -> None: ), entity_class=MatterBinarySensor, required_attributes=(clusters.DoorLock.Attributes.DoorState,), + featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 918b334061bbb..153124a4f7ec7 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -69,6 +69,7 @@ async def async_press(self) -> None: entity_class=MatterCommandButton, required_attributes=(clusters.Identify.Attributes.AcceptedCommandList,), value_contains=clusters.Identify.Commands.Identify.command_id, + allow_multi=True, ), MatterDiscoverySchema( platform=Platform.BUTTON, diff --git a/homeassistant/components/matter/const.py b/homeassistant/components/matter/const.py index a0e160a6c016d..8018d5e09edf7 100644 --- a/homeassistant/components/matter/const.py +++ b/homeassistant/components/matter/const.py @@ -13,3 +13,5 @@ # prefixes to identify device identifier id types ID_TYPE_DEVICE_ID = "deviceid" ID_TYPE_SERIAL = "serial" + +FEATUREMAP_ATTRIBUTE_ID = 65532 diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 5b07f9a069fed..3b9fb0b8a94d6 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -13,6 +13,7 @@ from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS from .button import DISCOVERY_SCHEMAS as BUTTON_SCHEMAS from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS +from .const import FEATUREMAP_ATTRIBUTE_ID from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS @@ -121,12 +122,24 @@ def async_discover_entities( continue # check for required value in (primary) attribute + primary_attribute = schema.required_attributes[0] + primary_value = endpoint.get_attribute_value(None, primary_attribute) if schema.value_contains is not None and ( - (primary_attribute := next((x for x in schema.required_attributes), None)) - is None - or (value := endpoint.get_attribute_value(None, primary_attribute)) is None - or not isinstance(value, list) - or schema.value_contains not in value + isinstance(primary_value, list) + and schema.value_contains not in primary_value + ): + continue + + # check for required value in cluster featuremap + if schema.featuremap_contains is not None and ( + not bool( + int( + endpoint.get_attribute_value( + primary_attribute.cluster_id, FEATUREMAP_ATTRIBUTE_ID + ) + ) + & schema.featuremap_contains + ) ): continue @@ -147,6 +160,7 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, + discovery_schema=schema, ) # prevent re-discovery of the primary attribute if not allowed diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 7c378fe465eee..50a0f2b1fee26 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -16,9 +16,10 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import UndefinedType -from .const import DOMAIN, ID_TYPE_DEVICE_ID +from .const import DOMAIN, FEATUREMAP_ATTRIBUTE_ID, ID_TYPE_DEVICE_ID from .helpers import get_device_id if TYPE_CHECKING: @@ -140,6 +141,19 @@ async def async_added_to_hass(self) -> None: node_filter=self._endpoint.node.node_id, ) ) + # subscribe to FeatureMap attribute (as that can dynamically change) + self._unsubscribes.append( + self.matter_client.subscribe_events( + callback=self._on_featuremap_update, + event_filter=EventType.ATTRIBUTE_UPDATED, + node_filter=self._endpoint.node.node_id, + attr_path_filter=create_attribute_path( + endpoint=self._endpoint.endpoint_id, + cluster_id=self._entity_info.primary_attribute.cluster_id, + attribute_id=FEATUREMAP_ATTRIBUTE_ID, + ), + ) + ) @cached_property def name(self) -> str | UndefinedType | None: @@ -159,6 +173,29 @@ def _on_matter_event(self, event: EventType, data: Any = None) -> None: self._update_from_device() self.async_write_ha_state() + @callback + def _on_featuremap_update( + self, event: EventType, data: tuple[int, str, int] | None + ) -> None: + """Handle FeatureMap attribute updates.""" + if data is None: + return + new_value = data[2] + # handle edge case where a Feature is removed from a cluster + if ( + self._entity_info.discovery_schema.featuremap_contains is not None + and not bool( + new_value & self._entity_info.discovery_schema.featuremap_contains + ) + ): + # this entity is no longer supported by the device + ent_reg = er.async_get(self.hass) + ent_reg.async_remove(self.entity_id) + + return + # all other cases, just update the entity + self._on_matter_event(event, data) + @callback def _update_from_device(self) -> None: """Update data from Matter device.""" diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index c5e10554fe704..d69d0fd3dab19 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -206,6 +206,5 @@ def _calculate_features( ), entity_class=MatterLock, required_attributes=(clusters.DoorLock.Attributes.LockState,), - optional_attributes=(clusters.DoorLock.Attributes.DoorState,), ), ] diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index f04c0f7e107d0..a00963c825a71 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -51,6 +51,9 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type + # the original discovery schema used to create this entity + discovery_schema: MatterDiscoverySchema + @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -113,6 +116,10 @@ class MatterDiscoverySchema: # NOTE: only works for list values value_contains: Any | None = None + # [optional] the primary attribute's cluster featuremap must contain this value + # for example for the DoorSensor on a DoorLock Cluster + featuremap_contains: int | None = None + # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False diff --git a/tests/components/matter/fixtures/nodes/door_lock.json b/tests/components/matter/fixtures/nodes/door_lock.json index b6231e04af4cc..acd327ac56c2d 100644 --- a/tests/components/matter/fixtures/nodes/door_lock.json +++ b/tests/components/matter/fixtures/nodes/door_lock.json @@ -495,7 +495,7 @@ "1/257/48": 3, "1/257/49": 10, "1/257/51": false, - "1/257/65532": 3507, + "1/257/65532": 0, "1/257/65533": 6, "1/257/65528": [12, 15, 18, 28, 35, 37], "1/257/65529": [ diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index 2e3367121e973..82dcc166f13d6 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -46,53 +46,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[door_lock][binary_sensor.mock_door_lock_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.mock_door_lock_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Door', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-LockDoorStateSensor-257-3', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[door_lock][binary_sensor.mock_door_lock_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Mock Door Lock Door', - }), - 'context': , - 'entity_id': 'binary_sensor.mock_door_lock_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_binary_sensors[door_lock_with_unbolt][binary_sensor.mock_door_lock_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 7ae483162bfe8..cddee975ac8d6 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch from matter_server.client.models.node import MatterNode +from matter_server.common.models import EventType import pytest from syrupy import SnapshotAssertion @@ -115,3 +116,34 @@ async def test_battery_sensor( state = hass.states.get(entity_id) assert state assert state.state == "on" + + +@pytest.mark.parametrize("node_fixture", ["door_lock"]) +async def test_optional_sensor_from_featuremap( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test discovery of optional doorsensor in doorlock featuremap.""" + entity_id = "binary_sensor.mock_door_lock_door" + state = hass.states.get(entity_id) + assert state is None + + # update the feature map to include the optional door sensor feature + # and fire a node updated event + set_node_attribute(matter_node, 1, 257, 65532, 32) + await trigger_subscription_callback( + hass, matter_client, event=EventType.NODE_UPDATED, data=matter_node + ) + # this should result in a new binary sensor entity being discovered + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + # now test the reverse, by removing the feature from the feature map + set_node_attribute(matter_node, 1, 257, 65532, 0) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/257/65532", 0) + ) + state = hass.states.get(entity_id) + assert state is None From c3499e52943356761a0b2bed58490e45c29037b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 2 Dec 2024 12:52:59 +0000 Subject: [PATCH 1028/1070] Update buienradar sensors only after being added to HA (#131830) * Update buienradar sensors only after being added to HA * Move check to util * Check for platform in sensor state property * Move check to unit translation key property * Add test for sensor check * Properly handle added_to_hass * Remove redundant comment --- homeassistant/components/buienradar/sensor.py | 21 ++++++++-- homeassistant/components/sensor/__init__.py | 5 +++ tests/components/sensor/test_init.py | 39 +++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index afce293402e05..712f765237e85 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -742,6 +742,7 @@ def __init__( ) -> None: """Initialize the sensor.""" self.entity_description = description + self._data: BrData | None = None self._measured = None self._attr_unique_id = ( f"{coordinates[CONF_LATITUDE]:2.6f}{coordinates[CONF_LONGITUDE]:2.6f}" @@ -756,17 +757,29 @@ def __init__( if description.key.startswith(PRECIPITATION_FORECAST): self._timeframe = None + async def async_added_to_hass(self) -> None: + """Handle entity being added to hass.""" + if self._data is None: + return + self._update() + @callback def data_updated(self, data: BrData): - """Update data.""" - if self._load_data(data.data) and self.hass: + """Handle data update.""" + self._data = data + if not self.hass: + return + self._update() + + def _update(self): + """Update sensor data.""" + _LOGGER.debug("Updating sensor %s", self.entity_id) + if self._load_data(self._data.data): self.async_write_ha_state() @callback def _load_data(self, data): # noqa: C901 """Load the sensor with relevant data.""" - # Find sensor - # Check if we have a new measurement, # otherwise we do not have to update the sensor if self._measured == data.get(MEASURED): diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index f1864458ce8a8..1e3b5d10c98d5 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -509,6 +509,11 @@ def _unit_of_measurement_translation_key(self) -> str | None: """Return translation key for unit of measurement.""" if self.translation_key is None: return None + if self.platform is None: + raise ValueError( + f"Sensor {type(self)} cannot have a translation key for " + "unit of measurement before being added to the entity platform" + ) platform = self.platform return ( f"component.{platform.platform_name}.entity.{platform.domain}" diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 19c25d819b671..d53818e77b383 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -548,6 +548,45 @@ async def test_translated_unit_with_native_unit_raises( assert entity0.entity_id is None +async def test_unit_translation_key_without_platform_raises( + hass: HomeAssistant, +) -> None: + """Test that unit translation key property raises if the entity has no platform yet.""" + + with patch( + "homeassistant.helpers.service.translation.async_get_translations", + return_value={ + "component.test.entity.sensor.test_translation_key.unit_of_measurement": "Tests" + }, + ): + entity0 = MockSensor( + name="Test", + native_value="123", + unique_id="very_unique", + ) + entity0.entity_description = SensorEntityDescription( + "test", + translation_key="test_translation_key", + ) + with pytest.raises( + ValueError, + match="cannot have a translation key for unit of measurement before " + "being added to the entity platform", + ): + unit = entity0.unit_of_measurement # noqa: F841 + + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + + assert await async_setup_component( + hass, "sensor", {"sensor": {"platform": "test"}} + ) + await hass.async_block_till_done() + + # Should not raise after being added to the platform + unit = entity0.unit_of_measurement # noqa: F841 + assert unit == "Tests" + + @pytest.mark.parametrize( ( "device_class", From 97a725c2c6ddd052ef44d001beb6304a6d5c035c Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 2 Dec 2024 09:54:37 +0000 Subject: [PATCH 1029/1070] Add translated native unit of measurement - squeezebox (#131912) --- homeassistant/components/squeezebox/sensor.py | 6 ------ .../components/squeezebox/strings.json | 18 ++++++++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index ff9f86ccf1f8d..0ca33179f9f25 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -33,12 +33,10 @@ SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_ALBUMS, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="albums", ), SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_ARTISTS, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="artists", ), SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_DURATION, @@ -49,12 +47,10 @@ SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_GENRES, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="genres", ), SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_SONGS, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="songs", ), SensorEntityDescription( key=STATUS_SENSOR_LASTSCAN, @@ -63,13 +59,11 @@ SensorEntityDescription( key=STATUS_SENSOR_PLAYER_COUNT, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="players", ), SensorEntityDescription( key=STATUS_SENSOR_OTHER_PLAYER_COUNT, state_class=SensorStateClass.TOTAL, entity_registry_visible_default=False, - native_unit_of_measurement="players", ), ) diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index b1b71cd8c1d1a..406c7243a1a38 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -76,25 +76,31 @@ "name": "Last scan" }, "info_total_albums": { - "name": "Total albums" + "name": "Total albums", + "unit_of_measurement": "albums" }, "info_total_artists": { - "name": "Total artists" + "name": "Total artists", + "unit_of_measurement": "artists" }, "info_total_duration": { "name": "Total duration" }, "info_total_genres": { - "name": "Total genres" + "name": "Total genres", + "unit_of_measurement": "genres" }, "info_total_songs": { - "name": "Total songs" + "name": "Total songs", + "unit_of_measurement": "songs" }, "player_count": { - "name": "Player count" + "name": "Player count", + "unit_of_measurement": "players" }, "other_player_count": { - "name": "Player count off service" + "name": "Player count off service", + "unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]" } } } From 42c46a15b40c8f8df3fd3715c9c44859f5f987e0 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 2 Dec 2024 09:51:32 +0000 Subject: [PATCH 1030/1070] Add translated native unit of measurement - Transmission (#131913) --- homeassistant/components/transmission/sensor.py | 5 ----- .../components/transmission/strings.json | 15 ++++++++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 737520adb5fc8..652f5d51fbb2e 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -83,7 +83,6 @@ class TransmissionSensorEntityDescription(SensorEntityDescription): TransmissionSensorEntityDescription( key="active_torrents", translation_key="active_torrents", - native_unit_of_measurement="torrents", val_func=lambda coordinator: coordinator.data.active_torrent_count, extra_state_attr_func=lambda coordinator: _torrents_info_attr( coordinator=coordinator, key="active_torrents" @@ -92,7 +91,6 @@ class TransmissionSensorEntityDescription(SensorEntityDescription): TransmissionSensorEntityDescription( key="paused_torrents", translation_key="paused_torrents", - native_unit_of_measurement="torrents", val_func=lambda coordinator: coordinator.data.paused_torrent_count, extra_state_attr_func=lambda coordinator: _torrents_info_attr( coordinator=coordinator, key="paused_torrents" @@ -101,7 +99,6 @@ class TransmissionSensorEntityDescription(SensorEntityDescription): TransmissionSensorEntityDescription( key="total_torrents", translation_key="total_torrents", - native_unit_of_measurement="torrents", val_func=lambda coordinator: coordinator.data.torrent_count, extra_state_attr_func=lambda coordinator: _torrents_info_attr( coordinator=coordinator, key="total_torrents" @@ -110,7 +107,6 @@ class TransmissionSensorEntityDescription(SensorEntityDescription): TransmissionSensorEntityDescription( key="completed_torrents", translation_key="completed_torrents", - native_unit_of_measurement="torrents", val_func=lambda coordinator: len( _filter_torrents(coordinator.torrents, MODES["completed_torrents"]) ), @@ -121,7 +117,6 @@ class TransmissionSensorEntityDescription(SensorEntityDescription): TransmissionSensorEntityDescription( key="started_torrents", translation_key="started_torrents", - native_unit_of_measurement="torrents", val_func=lambda coordinator: len( _filter_torrents(coordinator.torrents, MODES["started_torrents"]) ), diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 20ae6ca723d66..578bc2625893a 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -60,19 +60,24 @@ } }, "active_torrents": { - "name": "Active torrents" + "name": "Active torrents", + "unit_of_measurement": "torrents" }, "paused_torrents": { - "name": "Paused torrents" + "name": "Paused torrents", + "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]" }, "total_torrents": { - "name": "Total torrents" + "name": "Total torrents", + "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]" }, "completed_torrents": { - "name": "Completed torrents" + "name": "Completed torrents", + "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]" }, "started_torrents": { - "name": "Started torrents" + "name": "Started torrents", + "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]" } }, "switch": { From 3dc0ca7e1e627453a10fc36133695634c8f22837 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 2 Dec 2024 09:51:50 +0000 Subject: [PATCH 1031/1070] Add translated native unit of measurement - PiHole (#131915) --- homeassistant/components/pi_hole/sensor.py | 29 ++++--------------- homeassistant/components/pi_hole/strings.json | 24 ++++++++++----- 2 files changed, 21 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 503883e932660..4cf5133e7001c 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -18,7 +18,6 @@ SensorEntityDescription( key="ads_blocked_today", translation_key="ads_blocked_today", - native_unit_of_measurement="ads", ), SensorEntityDescription( key="ads_percentage_today", @@ -28,38 +27,20 @@ SensorEntityDescription( key="clients_ever_seen", translation_key="clients_ever_seen", - native_unit_of_measurement="clients", ), SensorEntityDescription( - key="dns_queries_today", - translation_key="dns_queries_today", - native_unit_of_measurement="queries", + key="dns_queries_today", translation_key="dns_queries_today" ), SensorEntityDescription( key="domains_being_blocked", translation_key="domains_being_blocked", - native_unit_of_measurement="domains", ), + SensorEntityDescription(key="queries_cached", translation_key="queries_cached"), SensorEntityDescription( - key="queries_cached", - translation_key="queries_cached", - native_unit_of_measurement="queries", - ), - SensorEntityDescription( - key="queries_forwarded", - translation_key="queries_forwarded", - native_unit_of_measurement="queries", - ), - SensorEntityDescription( - key="unique_clients", - translation_key="unique_clients", - native_unit_of_measurement="clients", - ), - SensorEntityDescription( - key="unique_domains", - translation_key="unique_domains", - native_unit_of_measurement="domains", + key="queries_forwarded", translation_key="queries_forwarded" ), + SensorEntityDescription(key="unique_clients", translation_key="unique_clients"), + SensorEntityDescription(key="unique_domains", translation_key="unique_domains"), ) diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index b76b61f1903d0..9e1d5948a0952 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -41,31 +41,39 @@ }, "sensor": { "ads_blocked_today": { - "name": "Ads blocked today" + "name": "Ads blocked today", + "unit_of_measurement": "ads" }, "ads_percentage_today": { "name": "Ads percentage blocked today" }, "clients_ever_seen": { - "name": "Seen clients" + "name": "Seen clients", + "unit_of_measurement": "clients" }, "dns_queries_today": { - "name": "DNS queries today" + "name": "DNS queries today", + "unit_of_measurement": "queries" }, "domains_being_blocked": { - "name": "Domains blocked" + "name": "Domains blocked", + "unit_of_measurement": "domains" }, "queries_cached": { - "name": "DNS queries cached" + "name": "DNS queries cached", + "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::dns_queries_today::unit_of_measurement%]" }, "queries_forwarded": { - "name": "DNS queries forwarded" + "name": "DNS queries forwarded", + "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::dns_queries_today::unit_of_measurement%]" }, "unique_clients": { - "name": "DNS unique clients" + "name": "DNS unique clients", + "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::clients_ever_seen::unit_of_measurement%]" }, "unique_domains": { - "name": "DNS unique domains" + "name": "DNS unique domains", + "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::domains_being_blocked::unit_of_measurement%]" } }, "update": { From b5e7da426241325161a7d18bd6ae1ab9b3970ffe Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 2 Dec 2024 09:50:49 +0000 Subject: [PATCH 1032/1070] Add translated native unit of measurement - QBitTorrent (#131918) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/qbittorrent/sensor.py | 4 ---- homeassistant/components/qbittorrent/strings.json | 12 ++++++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index abc23f399752d..67eb856bb8318 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -100,13 +100,11 @@ class QBittorrentSensorEntityDescription(SensorEntityDescription): QBittorrentSensorEntityDescription( key=SENSOR_TYPE_ALL_TORRENTS, translation_key="all_torrents", - native_unit_of_measurement="torrents", value_fn=lambda coordinator: count_torrents_in_states(coordinator, []), ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_ACTIVE_TORRENTS, translation_key="active_torrents", - native_unit_of_measurement="torrents", value_fn=lambda coordinator: count_torrents_in_states( coordinator, ["downloading", "uploading"] ), @@ -114,7 +112,6 @@ class QBittorrentSensorEntityDescription(SensorEntityDescription): QBittorrentSensorEntityDescription( key=SENSOR_TYPE_INACTIVE_TORRENTS, translation_key="inactive_torrents", - native_unit_of_measurement="torrents", value_fn=lambda coordinator: count_torrents_in_states( coordinator, ["stalledDL", "stalledUP"] ), @@ -122,7 +119,6 @@ class QBittorrentSensorEntityDescription(SensorEntityDescription): QBittorrentSensorEntityDescription( key=SENSOR_TYPE_PAUSED_TORRENTS, translation_key="paused_torrents", - native_unit_of_measurement="torrents", value_fn=lambda coordinator: count_torrents_in_states( coordinator, ["pausedDL", "pausedUP"] ), diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 88015dad5c38f..9c9ee37173727 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -36,16 +36,20 @@ } }, "active_torrents": { - "name": "Active torrents" + "name": "Active torrents", + "unit_of_measurement": "torrents" }, "inactive_torrents": { - "name": "Inactive torrents" + "name": "Inactive torrents", + "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]" }, "paused_torrents": { - "name": "Paused torrents" + "name": "Paused torrents", + "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]" }, "all_torrents": { - "name": "All torrents" + "name": "All torrents", + "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]" } }, "switch": { From 43899b6f28a1fa7113c7aec411da56a487a8db9a Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 3 Dec 2024 15:11:15 +0100 Subject: [PATCH 1033/1070] Catch InverterReturnedError in APSystems (#131930) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/apsystems/coordinator.py | 18 ++++++++++--- .../components/apsystems/strings.json | 5 ++++ tests/components/apsystems/test_init.py | 25 +++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 tests/components/apsystems/test_init.py diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py index b6e951343f7ef..e56cb8268407a 100644 --- a/homeassistant/components/apsystems/coordinator.py +++ b/homeassistant/components/apsystems/coordinator.py @@ -5,12 +5,17 @@ from dataclasses import dataclass from datetime import timedelta -from APsystemsEZ1 import APsystemsEZ1M, ReturnAlarmInfo, ReturnOutputData +from APsystemsEZ1 import ( + APsystemsEZ1M, + InverterReturnedError, + ReturnAlarmInfo, + ReturnOutputData, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import LOGGER +from .const import DOMAIN, LOGGER @dataclass @@ -43,6 +48,11 @@ async def _async_setup(self) -> None: self.api.min_power = device_info.minPower async def _async_update_data(self) -> ApSystemsSensorData: - output_data = await self.api.get_output_data() - alarm_info = await self.api.get_alarm_info() + try: + output_data = await self.api.get_output_data() + alarm_info = await self.api.get_alarm_info() + except InverterReturnedError: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="inverter_error" + ) from None return ApSystemsSensorData(output_data=output_data, alarm_info=alarm_info) diff --git a/homeassistant/components/apsystems/strings.json b/homeassistant/components/apsystems/strings.json index e02f86c273055..b3a10ca49a70e 100644 --- a/homeassistant/components/apsystems/strings.json +++ b/homeassistant/components/apsystems/strings.json @@ -72,5 +72,10 @@ "name": "Inverter status" } } + }, + "exceptions": { + "inverter_error": { + "message": "Inverter returned an error" + } } } diff --git a/tests/components/apsystems/test_init.py b/tests/components/apsystems/test_init.py new file mode 100644 index 0000000000000..c85c4094e30c8 --- /dev/null +++ b/tests/components/apsystems/test_init.py @@ -0,0 +1,25 @@ +"""Test the APSystem setup.""" + +from unittest.mock import AsyncMock + +from APsystemsEZ1 import InverterReturnedError + +from homeassistant.components.apsystems.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_update_failed( + hass: HomeAssistant, + mock_apsystems: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test update failed.""" + mock_apsystems.get_output_data.side_effect = InverterReturnedError + await setup_integration(hass, mock_config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state is ConfigEntryState.SETUP_RETRY From 905769f0e878111b7c8a8a0d115abdb21216bcfe Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 2 Dec 2024 22:18:24 +0100 Subject: [PATCH 1034/1070] Fix Reolink dispatcher ID for onvif fallback (#131953) --- .../components/reolink/binary_sensor.py | 4 ++-- homeassistant/components/reolink/host.py | 10 ++++---- tests/components/reolink/test_host.py | 23 +++++++++++++++---- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index c59c1e7785f53..c168c97e8096e 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -176,14 +176,14 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( async_dispatcher_connect( self.hass, - f"{self._host.webhook_id}_{self._channel}", + f"{self._host.unique_id}_{self._channel}", self._async_handle_event, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - f"{self._host.webhook_id}_all", + f"{self._host.unique_id}_all", self._async_handle_event, ) ) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index a8e1de07642d1..97d888c0323a3 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -723,7 +723,7 @@ async def _async_poll_all_motion(self, *_: Any) -> None: self._hass, POLL_INTERVAL_NO_PUSH, self._poll_job ) - self._signal_write_ha_state(None) + self._signal_write_ha_state() async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request @@ -782,7 +782,7 @@ async def _process_webhook_data( "Could not poll motion state after losing connection during receiving ONVIF event" ) return - async_dispatcher_send(hass, f"{webhook_id}_all", {}) + self._signal_write_ha_state() return message = data.decode("utf-8") @@ -795,14 +795,14 @@ async def _process_webhook_data( self._signal_write_ha_state(channels) - def _signal_write_ha_state(self, channels: list[int] | None) -> None: + def _signal_write_ha_state(self, channels: list[int] | None = None) -> None: """Update the binary sensors with async_write_ha_state.""" if channels is None: - async_dispatcher_send(self._hass, f"{self.webhook_id}_all", {}) + async_dispatcher_send(self._hass, f"{self.unique_id}_all", {}) return for channel in channels: - async_dispatcher_send(self._hass, f"{self.webhook_id}_{channel}", {}) + async_dispatcher_send(self._hass, f"{self.unique_id}_{channel}", {}) @property def event_connection(self) -> str: diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index 2286ca5d266c6..c777e4064f0ed 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -21,13 +21,15 @@ ) from homeassistant.components.webhook import async_handle_webhook from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.network import NoURLAvailableError from homeassistant.util.aiohttp import MockRequest +from .conftest import TEST_NVR_NAME + from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -92,23 +94,32 @@ async def test_webhook_callback( entity_registry: er.EntityRegistry, ) -> None: """Test webhook callback with motion sensor.""" - assert await hass.config_entries.async_setup(config_entry.entry_id) + reolink_connect.motion_detected.return_value = False + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_motion" webhook_id = config_entry.runtime_data.host.webhook_id + unique_id = config_entry.runtime_data.host.unique_id signal_all = MagicMock() signal_ch = MagicMock() - async_dispatcher_connect(hass, f"{webhook_id}_all", signal_all) - async_dispatcher_connect(hass, f"{webhook_id}_0", signal_ch) + async_dispatcher_connect(hass, f"{unique_id}_all", signal_all) + async_dispatcher_connect(hass, f"{unique_id}_0", signal_ch) client = await hass_client_no_auth() + assert hass.states.get(entity_id).state == STATE_OFF + # test webhook callback success all channels + reolink_connect.motion_detected.return_value = True reolink_connect.ONVIF_event_callback.return_value = None await client.post(f"/api/webhook/{webhook_id}") signal_all.assert_called_once() + assert hass.states.get(entity_id).state == STATE_ON freezer.tick(timedelta(seconds=FIRST_ONVIF_TIMEOUT)) async_fire_time_changed(hass) @@ -120,10 +131,14 @@ async def test_webhook_callback( await client.post(f"/api/webhook/{webhook_id}") signal_all.assert_not_called() + assert hass.states.get(entity_id).state == STATE_ON + # test webhook callback success single channel + reolink_connect.motion_detected.return_value = False reolink_connect.ONVIF_event_callback.return_value = [0] await client.post(f"/api/webhook/{webhook_id}", data="test_data") signal_ch.assert_called_once() + assert hass.states.get(entity_id).state == STATE_OFF # test webhook callback single channel with error in event callback signal_ch.reset_mock() From f1ebda7c6f6b0ad9e5533b4b99916cc12442d6fb Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Tue, 3 Dec 2024 15:10:58 +0100 Subject: [PATCH 1035/1070] Instantiate new httpx client for lamarzocco (#132016) --- homeassistant/components/lamarzocco/__init__.py | 8 ++++---- homeassistant/components/lamarzocco/config_flow.py | 9 +++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index da513bc8cffa6..5de9a2eeed4f2 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -23,7 +23,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.httpx_client import create_async_httpx_client from .const import CONF_USE_BLUETOOTH, DOMAIN from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator @@ -47,11 +47,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - assert entry.unique_id serial = entry.unique_id - + client = create_async_httpx_client(hass) cloud_client = LaMarzoccoCloudClient( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], - client=get_async_client(hass), + client=client, ) # initialize local API @@ -61,7 +61,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - local_client = LaMarzoccoLocalClient( host=host, local_bearer=entry.data[CONF_TOKEN], - client=get_async_client(hass), + client=client, ) # initialize Bluetooth diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index a727e3fe35796..c01b55fb88599 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -6,6 +6,7 @@ import logging from typing import Any +from httpx import AsyncClient from pylamarzocco.client_cloud import LaMarzoccoCloudClient from pylamarzocco.client_local import LaMarzoccoLocalClient from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful @@ -37,7 +38,7 @@ ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.httpx_client import create_async_httpx_client from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -57,6 +58,8 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 2 + _client: AsyncClient + def __init__(self) -> None: """Initialize the config flow.""" self._config: dict[str, Any] = {} @@ -79,10 +82,12 @@ async def async_step_user( **user_input, **self._discovered, } + self._client = create_async_httpx_client(self.hass) cloud_client = LaMarzoccoCloudClient( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], + client=self._client, ) try: self._fleet = await cloud_client.get_customer_fleet() @@ -163,7 +168,7 @@ async def async_step_machine_selection( # validate local connection if host is provided if user_input.get(CONF_HOST): if not await LaMarzoccoLocalClient.validate_connection( - client=get_async_client(self.hass), + client=self._client, host=user_input[CONF_HOST], token=selected_device.communication_key, ): From f44103ac7f258662cd6df7b5ff0ec43bd4a7c033 Mon Sep 17 00:00:00 2001 From: Jan Rieger <271149+jrieger@users.noreply.github.com> Date: Mon, 2 Dec 2024 13:28:54 +0100 Subject: [PATCH 1036/1070] Add translated native unit of measurement to Jellyfin (#132055) --- homeassistant/components/jellyfin/sensor.py | 1 - homeassistant/components/jellyfin/strings.json | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py index 24aeecab7e5e8..5c519f661eec0 100644 --- a/homeassistant/components/jellyfin/sensor.py +++ b/homeassistant/components/jellyfin/sensor.py @@ -36,7 +36,6 @@ def _count_now_playing(data: dict[str, dict[str, Any]]) -> int: key="watching", translation_key="watching", value_fn=_count_now_playing, - native_unit_of_measurement="clients", ), ) diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json index f2afa0c8ad590..a9816b1fb78b7 100644 --- a/homeassistant/components/jellyfin/strings.json +++ b/homeassistant/components/jellyfin/strings.json @@ -29,7 +29,8 @@ "entity": { "sensor": { "watching": { - "name": "Active clients" + "name": "Active clients", + "unit_of_measurement": "clients" } } }, From d3a577ad894b445f066e243a14e9150fa5d5d7f5 Mon Sep 17 00:00:00 2001 From: Simone Rescio Date: Mon, 2 Dec 2024 13:18:53 +0100 Subject: [PATCH 1037/1070] Bump pyezviz to 0.2.2.3 (#132060) --- homeassistant/components/ezviz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json index 53976bf3002a6..7c796c74ef732 100644 --- a/homeassistant/components/ezviz/manifest.json +++ b/homeassistant/components/ezviz/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/ezviz", "iot_class": "cloud_polling", "loggers": ["paho_mqtt", "pyezviz"], - "requirements": ["pyezviz==0.2.1.2"] + "requirements": ["pyezviz==0.2.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0100b67d7858e..21611e914a0d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1907,7 +1907,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.1.2 +pyezviz==0.2.2.3 # homeassistant.components.fibaro pyfibaro==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c938702efb797..647f309cbbbbc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1536,7 +1536,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.1.2 +pyezviz==0.2.2.3 # homeassistant.components.fibaro pyfibaro==0.8.0 From 3f1286b3383832eb96d6145ae140178ad0be9796 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:27:44 +0100 Subject: [PATCH 1038/1070] Set connections on device for acaia (#132064) --- homeassistant/components/acaia/entity.py | 7 ++++++- tests/components/acaia/snapshots/test_init.ambr | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/acaia/entity.py b/homeassistant/components/acaia/entity.py index db01b414b9903..bef1ac313ca39 100644 --- a/homeassistant/components/acaia/entity.py +++ b/homeassistant/components/acaia/entity.py @@ -2,7 +2,11 @@ from dataclasses import dataclass -from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceInfo, + format_mac, +) from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -33,6 +37,7 @@ def __init__( manufacturer="Acaia", model=self._scale.model, suggested_area="Kitchen", + connections={(CONNECTION_BLUETOOTH, self._scale.mac)}, ) @property diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr index 1cc3d8dbbc0f4..7011b20f68c56 100644 --- a/tests/components/acaia/snapshots/test_init.ambr +++ b/tests/components/acaia/snapshots/test_init.ambr @@ -5,6 +5,10 @@ 'config_entries': , 'configuration_url': None, 'connections': set({ + tuple( + 'bluetooth', + 'aa:bb:cc:dd:ee:ff', + ), }), 'disabled_by': None, 'entry_type': None, From 895ffbabf734ae9f386ddcfd1375a669317d9850 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Mon, 2 Dec 2024 14:04:39 +0100 Subject: [PATCH 1039/1070] Round status light brightness number in HomeWizard (#132069) --- homeassistant/components/homewizard/number.py | 2 +- tests/components/homewizard/snapshots/test_number.ambr | 4 ++-- tests/components/homewizard/test_number.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 1b4a0643dbe6b..1ed4c642f6b90 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -64,4 +64,4 @@ def native_value(self) -> float | None: or (brightness := self.coordinator.data.state.brightness) is None ): return None - return brightness_to_value((0, 100), brightness) + return round(brightness_to_value((0, 100), brightness)) diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 49f23cf8e2fd1..b14028cd97c98 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -14,7 +14,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100.0', + 'state': '100', }) # --- # name: test_number_entities[HWE-SKT-11].1 @@ -106,7 +106,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100.0', + 'state': '100', }) # --- # name: test_number_entities[HWE-SKT-21].1 diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index ddadf09bb6ec1..623ba018dee66 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -42,7 +42,7 @@ async def test_number_entities( assert snapshot == device_entry # Test unknown handling - assert state.state == "100.0" + assert state.state == "100" mock_homewizardenergy.state.return_value.brightness = None From c6468aca2b1bfc20b4f843f7f28ae721088c6428 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 2 Dec 2024 21:54:57 +0100 Subject: [PATCH 1040/1070] Mark trend sensor unavailable when source entity is unknown/unavailable (#132080) --- .../components/trend/binary_sensor.py | 9 +++- tests/components/trend/test_binary_sensor.py | 44 ++++++++++++++++++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 681680f180fb0..9691ecf0744a9 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -227,10 +227,15 @@ def trend_sensor_state_listener( state = new_state.attributes.get(self._attribute) else: state = new_state.state - if state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + + if state in (STATE_UNKNOWN, STATE_UNAVAILABLE): + self._attr_available = False + else: + self._attr_available = True sample = (new_state.last_updated.timestamp(), float(state)) # type: ignore[arg-type] self.samples.append(sample) - self.async_schedule_update_ha_state(True) + + self.async_schedule_update_ha_state(True) except (ValueError, TypeError) as ex: _LOGGER.error(ex) diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index ad85f65a9fce8..4a829bb86d253 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant import setup from homeassistant.components.trend.const import DOMAIN -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -395,3 +395,45 @@ async def test_device_id( trend_entity = entity_registry.async_get("binary_sensor.trend") assert trend_entity is not None assert trend_entity.device_id == source_entity.device_id + + +@pytest.mark.parametrize( + "error_state", + [ + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ], +) +async def test_unavailable_source( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + setup_component: ComponentSetup, + error_state: str, +) -> None: + """Test for unavailable source.""" + await setup_component( + { + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, + }, + ) + + for val in (10, 20, 30, 40, 50, 60): + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" + + hass.states.async_set("sensor.test_state", error_state) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.test_trend_sensor").state == STATE_UNAVAILABLE + + hass.states.async_set("sensor.test_state", 50) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" From ab5165fdfa31a683ca22b73b1e2ad06f64e99188 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 3 Dec 2024 13:06:54 +0100 Subject: [PATCH 1041/1070] Fix imap sensor in case of alternative empty search response (#132081) --- homeassistant/components/imap/coordinator.py | 12 +++++++++++- tests/components/imap/const.py | 2 ++ tests/components/imap/test_init.py | 13 +++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index a9d0fdfbd48a5..2726b47a6797d 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -332,7 +332,17 @@ async def _async_fetch_number_of_messages(self) -> int | None: raise UpdateFailed( f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) - if not (count := len(message_ids := lines[0].split())): + # Check we do have returned items. + # + # In rare cases, when no UID's are returned, + # only the status line is returned, and not an empty line. + # See: https://github.com/home-assistant/core/issues/132042 + # + # Strictly the RfC notes that 0 or more numbers should be returned + # delimited by a space. + # + # See: https://datatracker.ietf.org/doc/html/rfc3501#section-7.2.5 + if len(lines) == 1 or not (count := len(message_ids := lines[0].split())): self._last_message_uid = None return 0 last_message_uid = ( diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 037960c9e5daa..8f6761bd79511 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -141,6 +141,8 @@ ) EMPTY_SEARCH_RESPONSE = ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) +EMPTY_SEARCH_RESPONSE_ALT = ("OK", [b"Search completed (0.0001 + 0.000 secs)."]) + BAD_RESPONSE = ("BAD", [b"", b"Unexpected error"]) TEST_SEARCH_RESPONSE = ("OK", [b"1", b"Search completed (0.0001 + 0.000 secs)."]) diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 7bdfc44571adf..d4281b9e51340 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -20,6 +20,7 @@ from .const import ( BAD_RESPONSE, EMPTY_SEARCH_RESPONSE, + EMPTY_SEARCH_RESPONSE_ALT, TEST_BADLY_ENCODED_CONTENT, TEST_FETCH_RESPONSE_BINARY, TEST_FETCH_RESPONSE_HTML, @@ -517,6 +518,11 @@ async def test_fetch_number_of_messages( assert state.state == STATE_UNAVAILABLE +@pytest.mark.parametrize( + "empty_search_reponse", + [EMPTY_SEARCH_RESPONSE, EMPTY_SEARCH_RESPONSE_ALT], + ids=["regular_empty_search_response", "alt_empty_search_response"], +) @pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) @pytest.mark.parametrize( ("imap_fetch", "valid_date"), @@ -525,7 +531,10 @@ async def test_fetch_number_of_messages( ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) async def test_reset_last_message( - hass: HomeAssistant, mock_imap_protocol: MagicMock, valid_date: bool + hass: HomeAssistant, + mock_imap_protocol: MagicMock, + valid_date: bool, + empty_search_reponse: tuple[str, list[bytes]], ) -> None: """Test receiving a message successfully.""" event = asyncio.Event() # needed for pushed coordinator to make a new loop @@ -580,7 +589,7 @@ async def _sleep_till_event() -> None: ) # Simulate an update where no messages are found (needed for pushed coordinator) - mock_imap_protocol.search.return_value = Response(*EMPTY_SEARCH_RESPONSE) + mock_imap_protocol.search.return_value = Response(*empty_search_reponse) # Make sure we have an update async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) From 2aea7380320d259325aad07a062a15a8ae2a9768 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 2 Dec 2024 13:09:35 -0600 Subject: [PATCH 1042/1070] Bump hassil and intents (#132092) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- script/hassfest/docker/Dockerfile | 2 +- tests/components/conversation/snapshots/test_http.ambr | 4 ++-- tests/testing_config/custom_sentences/en/beer.yaml | 4 ++-- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 26265a37cce6e..2d2f2f58a3ac8 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.0.4", "home-assistant-intents==2024.11.27"] + "requirements": ["hassil==2.0.5", "home-assistant-intents==2024.12.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4b6777417e94f..1e43a098712a6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,10 +32,10 @@ go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 habluetooth==3.6.0 hass-nabucasa==0.85.0 -hassil==2.0.4 +hassil==2.0.5 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241127.1 -home-assistant-intents==2024.11.27 +home-assistant-intents==2024.12.2 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 21611e914a0d4..60d4ac6170149 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1096,7 +1096,7 @@ hass-nabucasa==0.85.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.0.4 +hassil==2.0.5 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -1133,7 +1133,7 @@ holidays==0.61 home-assistant-frontend==20241127.1 # homeassistant.components.conversation -home-assistant-intents==2024.11.27 +home-assistant-intents==2024.12.2 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 647f309cbbbbc..cd2504a6d6858 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -931,7 +931,7 @@ habluetooth==3.6.0 hass-nabucasa==0.85.0 # homeassistant.components.conversation -hassil==2.0.4 +hassil==2.0.5 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -959,7 +959,7 @@ holidays==0.61 home-assistant-frontend==20241127.1 # homeassistant.components.conversation -home-assistant-intents==2024.11.27 +home-assistant-intents==2024.12.2 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index e11ffca025d56..044635d2d5884 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.4,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.0 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.4 home-assistant-intents==2024.11.27 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.4 home-assistant-intents==2024.12.2 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 966abd63d781e..a3edd4fa51c8a 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -535,7 +535,7 @@ 'name': 'HassTurnOn', }), 'match': True, - 'sentence_template': ' on [all] in ', + 'sentence_template': ' on [] ', 'slots': dict({ 'area': 'kitchen', 'domain': 'light', @@ -606,7 +606,7 @@ 'name': 'OrderBeer', }), 'match': True, - 'sentence_template': "I'd like to order a {beer_style} [please]", + 'sentence_template': "[I'd like to ]order a {beer_style} [please]", 'slots': dict({ 'beer_style': 'lager', }), diff --git a/tests/testing_config/custom_sentences/en/beer.yaml b/tests/testing_config/custom_sentences/en/beer.yaml index f318e0221b2a4..7222ffcb0ca75 100644 --- a/tests/testing_config/custom_sentences/en/beer.yaml +++ b/tests/testing_config/custom_sentences/en/beer.yaml @@ -3,11 +3,11 @@ intents: OrderBeer: data: - sentences: - - "I'd like to order a {beer_style} [please]" + - "[I'd like to ]order a {beer_style} [please]" OrderFood: data: - sentences: - - "I'd like to order {food_name:name} [please]" + - "[I'd like to ]order {food_name:name} [please]" lists: beer_style: values: From f480cc3396c3370c3476dc78ad2cb2bd4ea701b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 3 Dec 2024 16:08:09 +0000 Subject: [PATCH 1043/1070] Use translations on NumberEntity unit_of_measurement property (#132095) Co-authored-by: Martin Hjelmare --- homeassistant/components/number/__init__.py | 12 ++++ homeassistant/components/sensor/__init__.py | 16 ----- homeassistant/helpers/entity.py | 16 +++++ tests/components/number/test_init.py | 65 ++++++++++++++++++++- 4 files changed, 92 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index dc169fcb348b8..9f4aef08aa924 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -384,6 +384,18 @@ def unit_of_measurement(self) -> str | None: ): return self.hass.config.units.temperature_unit + if (translation_key := self._unit_of_measurement_translation_key) and ( + unit_of_measurement + := self.platform.default_language_platform_translations.get(translation_key) + ): + if native_unit_of_measurement is not None: + raise ValueError( + f"Number entity {type(self)} from integration '{self.platform.platform_name}' " + f"has a translation key for unit_of_measurement '{unit_of_measurement}', " + f"but also has a native_unit_of_measurement '{native_unit_of_measurement}'" + ) + return unit_of_measurement + return native_unit_of_measurement @cached_property diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 1e3b5d10c98d5..a0220c23d9d18 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -504,22 +504,6 @@ def suggested_unit_of_measurement(self) -> str | None: return self.entity_description.suggested_unit_of_measurement return None - @cached_property - def _unit_of_measurement_translation_key(self) -> str | None: - """Return translation key for unit of measurement.""" - if self.translation_key is None: - return None - if self.platform is None: - raise ValueError( - f"Sensor {type(self)} cannot have a translation key for " - "unit of measurement before being added to the entity platform" - ) - platform = self.platform - return ( - f"component.{platform.platform_name}.entity.{platform.domain}" - f".{self.translation_key}.unit_of_measurement" - ) - @final @property @override diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 1f77dd3f95cd7..19076c4edc000 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -647,6 +647,22 @@ def _name_translation_key(self) -> str | None: f".{self.translation_key}.name" ) + @cached_property + def _unit_of_measurement_translation_key(self) -> str | None: + """Return translation key for unit of measurement.""" + if self.translation_key is None: + return None + if self.platform is None: + raise ValueError( + f"Entity {type(self)} cannot have a translation key for " + "unit of measurement before being added to the entity platform" + ) + platform = self.platform + return ( + f"component.{platform.platform_name}.entity.{platform.domain}" + f".{self.translation_key}.unit_of_measurement" + ) + def _substitute_name_placeholders(self, name: str) -> str: """Substitute placeholders in entity name.""" try: diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 721b531e8cdb5..31d99dc55d76b 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -2,7 +2,7 @@ from collections.abc import Generator from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -836,6 +836,69 @@ async def test_custom_unit_change( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == default_unit +async def test_translated_unit( + hass: HomeAssistant, +) -> None: + """Test translated unit.""" + + with patch( + "homeassistant.helpers.service.translation.async_get_translations", + return_value={ + "component.test.entity.number.test_translation_key.unit_of_measurement": "Tests" + }, + ): + entity0 = common.MockNumberEntity( + name="Test", + native_value=123, + unique_id="very_unique", + ) + entity0.entity_description = NumberEntityDescription( + "test", + translation_key="test_translation_key", + ) + setup_test_component_platform(hass, DOMAIN, [entity0]) + + assert await async_setup_component( + hass, "number", {"number": {"platform": "test"}} + ) + await hass.async_block_till_done() + + entity_id = entity0.entity_id + state = hass.states.get(entity_id) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "Tests" + + +async def test_translated_unit_with_native_unit_raises( + hass: HomeAssistant, +) -> None: + """Test that translated unit.""" + + with patch( + "homeassistant.helpers.service.translation.async_get_translations", + return_value={ + "component.test.entity.number.test_translation_key.unit_of_measurement": "Tests" + }, + ): + entity0 = common.MockNumberEntity( + name="Test", + native_value=123, + unique_id="very_unique", + ) + entity0.entity_description = NumberEntityDescription( + "test", + translation_key="test_translation_key", + native_unit_of_measurement="bad_unit", + ) + setup_test_component_platform(hass, DOMAIN, [entity0]) + + assert await async_setup_component( + hass, "number", {"number": {"platform": "test"}} + ) + await hass.async_block_till_done() + # Setup fails so entity_id is None + assert entity0.entity_id is None + + def test_device_classes_aligned() -> None: """Make sure all sensor device classes are also available in NumberDeviceClass.""" From 54ec41f25d101401c22d745ba84a681c55d43c1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Dec 2024 09:03:43 -0600 Subject: [PATCH 1044/1070] Bump PyJWT to 2.10.1 (#132100) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1e43a098712a6..7d8a116e7948a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -47,7 +47,7 @@ paho-mqtt==1.6.1 Pillow==11.0.0 propcache==0.2.1 psutil-home-assistant==0.0.1 -PyJWT==2.10.0 +PyJWT==2.10.1 pymicro-vad==1.0.1 PyNaCl==1.5.0 pyOpenSSL==24.2.1 diff --git a/pyproject.toml b/pyproject.toml index 2f86ff4a6c2cc..64795d19f692e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "ifaddr==0.2.0", "Jinja2==3.1.4", "lru-dict==1.3.0", - "PyJWT==2.10.0", + "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. "cryptography==43.0.1", "Pillow==11.0.0", diff --git a/requirements.txt b/requirements.txt index 0f5047a0bbb34..7aadd55c02453 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 Jinja2==3.1.4 lru-dict==1.3.0 -PyJWT==2.10.0 +PyJWT==2.10.1 cryptography==43.0.1 Pillow==11.0.0 propcache==0.2.1 From 155fafb735d4322437a878e1fe6f4a361674b9dd Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 3 Dec 2024 14:08:31 +0100 Subject: [PATCH 1045/1070] Update frontend to 20241127.2 (#132109) Co-authored-by: Franck Nijhof --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7bd500f17ea93..f59ca05ba5571 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241127.1"] + "requirements": ["home-assistant-frontend==20241127.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7d8a116e7948a..4839c71327e91 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ habluetooth==3.6.0 hass-nabucasa==0.85.0 hassil==2.0.5 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241127.1 +home-assistant-frontend==20241127.2 home-assistant-intents==2024.12.2 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 60d4ac6170149..e7408b516861a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1130,7 +1130,7 @@ hole==0.8.0 holidays==0.61 # homeassistant.components.frontend -home-assistant-frontend==20241127.1 +home-assistant-frontend==20241127.2 # homeassistant.components.conversation home-assistant-intents==2024.12.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd2504a6d6858..2b79b7423ec05 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -956,7 +956,7 @@ hole==0.8.0 holidays==0.61 # homeassistant.components.frontend -home-assistant-frontend==20241127.1 +home-assistant-frontend==20241127.2 # homeassistant.components.conversation home-assistant-intents==2024.12.2 From 0a38af7e48a9eebad8868015eea45a0d28a29835 Mon Sep 17 00:00:00 2001 From: Tobias Perschon Date: Tue, 3 Dec 2024 13:33:47 +0100 Subject: [PATCH 1046/1070] Bump unifi_ap to 0.0.2 (#132125) --- homeassistant/components/unifi_direct/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi_direct/manifest.json b/homeassistant/components/unifi_direct/manifest.json index 775279c64e210..aa696985dbe34 100644 --- a/homeassistant/components/unifi_direct/manifest.json +++ b/homeassistant/components/unifi_direct/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["unifi_ap"], "quality_scale": "legacy", - "requirements": ["unifi_ap==0.0.1"] + "requirements": ["unifi_ap==0.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index e7408b516861a..a57451f892c75 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2906,7 +2906,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.2.0 # homeassistant.components.unifi_direct -unifi_ap==0.0.1 +unifi_ap==0.0.2 # homeassistant.components.unifiled unifiled==0.11 From 07196b0fdaa5feabf4db7573e598b66906565512 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 3 Dec 2024 00:08:51 -0500 Subject: [PATCH 1047/1070] Fix bad hassil tests on CI (#132132) * Fix CI * Fix whitespace --------- Co-authored-by: Michael Hansen --- .../conversation/snapshots/test_default_agent.ambr | 6 +++--- tests/components/conversation/test_default_agent.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr index b1f2ea0db75e4..f1e220b10b2ca 100644 --- a/tests/components/conversation/snapshots/test_default_agent.ambr +++ b/tests/components/conversation/snapshots/test_default_agent.ambr @@ -308,7 +308,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called late added light', + 'speech': 'Sorry, I am not aware of any area called late added', }), }), }), @@ -378,7 +378,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called kitchen light', + 'speech': 'Sorry, I am not aware of any area called kitchen', }), }), }), @@ -428,7 +428,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called renamed light', + 'speech': 'Sorry, I am not aware of any area called renamed', }), }), }), diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 6990ffe7717b7..00c47b42629e8 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -2930,7 +2930,7 @@ async def test_intent_cache_fuzzy(hass: HomeAssistant) -> None: ) result = await agent.async_recognize_intent(user_input) assert result is not None - assert result.unmatched_entities["name"].text == "test light" + assert result.unmatched_entities["area"].text == "test " # Mark this result so we know it is from cache next time mark = "_from_cache" From 8a310cbbf8ffd4620acea8c27079ba0cab1126b9 Mon Sep 17 00:00:00 2001 From: Tobias Perschon Date: Tue, 3 Dec 2024 13:34:13 +0100 Subject: [PATCH 1048/1070] Improve error logging for unifi-ap (#132141) --- homeassistant/components/unifi_direct/device_tracker.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index 144cbd4dec7dd..d5e2e926114fe 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -67,11 +67,11 @@ def update_clients(self) -> bool: """Update the client info from AP.""" try: self.clients = self.ap.get_clients() - except UniFiAPConnectionException: - _LOGGER.error("Failed to connect to accesspoint") + except UniFiAPConnectionException as e: + _LOGGER.error("Failed to connect to accesspoint: %s", str(e)) return False - except UniFiAPDataException: - _LOGGER.error("Failed to get proper response from accesspoint") + except UniFiAPDataException as e: + _LOGGER.error("Failed to get proper response from accesspoint: %s", str(e)) return False return True From b7038d4eb7691163b539a42631e9b6c1f1ffa4b9 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 3 Dec 2024 17:03:43 +0100 Subject: [PATCH 1049/1070] Bump uiprotect to 6.6.5 (#132147) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 9730c1e37410a..e8a8c0628004b 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==6.6.4", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==6.6.5", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index a57451f892c75..ede3290e322fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2897,7 +2897,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.4 +uiprotect==6.6.5 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b79b7423ec05..6209f1e71a61f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2310,7 +2310,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.4 +uiprotect==6.6.5 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 79352ea0f068a3ea6487c7330c2d7876acaf2bc9 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Dec 2024 12:34:50 +0000 Subject: [PATCH 1050/1070] Bump pytouchlinesl to 0.3.0 (#132157) --- homeassistant/components/touchline_sl/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json index ca3136f55c05c..ab07ae770fd08 100644 --- a/homeassistant/components/touchline_sl/manifest.json +++ b/homeassistant/components/touchline_sl/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/touchline_sl", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["pytouchlinesl==0.2.0"] + "requirements": ["pytouchlinesl==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ede3290e322fa..2cca98afc4bdf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2435,7 +2435,7 @@ pytomorrowio==0.3.6 pytouchline==0.7 # homeassistant.components.touchline_sl -pytouchlinesl==0.2.0 +pytouchlinesl==0.3.0 # homeassistant.components.traccar # homeassistant.components.traccar_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6209f1e71a61f..528b9c2506843 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1947,7 +1947,7 @@ pytile==2023.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline_sl -pytouchlinesl==0.2.0 +pytouchlinesl==0.3.0 # homeassistant.components.traccar # homeassistant.components.traccar_server From 08773cefb7aa08496d83460f08fdef9abfd1a99b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 3 Dec 2024 15:01:35 +0100 Subject: [PATCH 1051/1070] Pin rpds-py to 0.21.0 to fix CI (#132170) * Pin rpds-py==0.21.0 to fix CI * Add carriage return --- homeassistant/package_constraints.txt | 5 +++++ script/gen_requirements_all.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4839c71327e91..46df289265315 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -205,3 +205,8 @@ async-timeout==4.0.3 # https://github.com/home-assistant/core/issues/122508 # https://github.com/home-assistant/core/issues/118004 aiofiles>=24.1.0 + +# 0.22.0 causes CI failures on Python 3.13 +# python3 -X dev -m pytest tests/components/matrix +# python3 -X dev -m pytest tests/components/zha +rpds-py==0.21.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 97ffcac79a467..450469096ea6c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -238,6 +238,11 @@ # https://github.com/home-assistant/core/issues/122508 # https://github.com/home-assistant/core/issues/118004 aiofiles>=24.1.0 + +# 0.22.0 causes CI failures on Python 3.13 +# python3 -X dev -m pytest tests/components/matrix +# python3 -X dev -m pytest tests/components/zha +rpds-py==0.21.0 """ GENERATED_MESSAGE = ( From ebffcb455fa750dc211aa07c7bd7a9a8972ce0e9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 3 Dec 2024 16:13:15 +0100 Subject: [PATCH 1052/1070] Update frontend to 20241127.3 (#132176) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index f59ca05ba5571..264f0756b825d 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241127.2"] + "requirements": ["home-assistant-frontend==20241127.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 46df289265315..af91964994e15 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ habluetooth==3.6.0 hass-nabucasa==0.85.0 hassil==2.0.5 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241127.2 +home-assistant-frontend==20241127.3 home-assistant-intents==2024.12.2 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2cca98afc4bdf..aeb999eef6601 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1130,7 +1130,7 @@ hole==0.8.0 holidays==0.61 # homeassistant.components.frontend -home-assistant-frontend==20241127.2 +home-assistant-frontend==20241127.3 # homeassistant.components.conversation home-assistant-intents==2024.12.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 528b9c2506843..af4688b7e3e47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -956,7 +956,7 @@ hole==0.8.0 holidays==0.61 # homeassistant.components.frontend -home-assistant-frontend==20241127.2 +home-assistant-frontend==20241127.3 # homeassistant.components.conversation home-assistant-intents==2024.12.2 From 759a2b84f5c78522b915e4cfe8e51aae352f1167 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 3 Dec 2024 18:03:36 +0100 Subject: [PATCH 1053/1070] Bump version to 2024.12.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5617ab1d22ac7..075262346b408 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 64795d19f692e..57523be4e6826 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.12.0b4" +version = "2024.12.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 33633f885d913e5a3191b9b1aae34673a8894ee2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 4 Dec 2024 09:59:28 +0100 Subject: [PATCH 1054/1070] Ran hassfest --- script/hassfest/docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 044635d2d5884..26ca6475af72e 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.4,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.0 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.4 home-assistant-intents==2024.12.2 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.5 home-assistant-intents==2024.12.2 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 139b42471789454116bd08af8db8b00a08f094c5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 4 Dec 2024 14:56:42 +0100 Subject: [PATCH 1055/1070] Bump knocki to 0.4.2 (#129261) --- homeassistant/components/knocki/__init__.py | 5 ++--- homeassistant/components/knocki/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/knocki/__init__.py b/homeassistant/components/knocki/__init__.py index 42c3956bd684e..dfdf060e3b5cd 100644 --- a/homeassistant/components/knocki/__init__.py +++ b/homeassistant/components/knocki/__init__.py @@ -41,13 +41,12 @@ async def _refresh_coordinator(_: Event) -> None: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_create_background_task( - hass, client.start_websocket(), "knocki-websocket" - ) + await client.start_websocket() return True async def async_unload_entry(hass: HomeAssistant, entry: KnockiConfigEntry) -> bool: """Unload a config entry.""" + await entry.runtime_data.client.close() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/knocki/manifest.json b/homeassistant/components/knocki/manifest.json index d9a45b18f0ec6..a91119ca831f9 100644 --- a/homeassistant/components/knocki/manifest.json +++ b/homeassistant/components/knocki/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["knocki"], - "requirements": ["knocki==0.3.5"] + "requirements": ["knocki==0.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index aeb999eef6601..527b979ba5143 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1250,7 +1250,7 @@ kegtron-ble==0.4.0 kiwiki-client==0.1.1 # homeassistant.components.knocki -knocki==0.3.5 +knocki==0.4.2 # homeassistant.components.knx knx-frontend==2024.11.16.205004 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af4688b7e3e47..0260108e7c825 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1049,7 +1049,7 @@ justnimbus==0.7.4 kegtron-ble==0.4.0 # homeassistant.components.knocki -knocki==0.3.5 +knocki==0.4.2 # homeassistant.components.knx knx-frontend==2024.11.16.205004 From 66e3ffffa796a9d9a3432f8163b2ac350e3e7dd6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 3 Dec 2024 22:24:39 +0100 Subject: [PATCH 1056/1070] Bump holidays to 0.62 (#132108) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index a3c0a4514d3d7..7edc140da1128 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.61", "babel==2.15.0"] + "requirements": ["holidays==0.62", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index ea08bfe17176e..842c6f1f1ad3b 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.61"] + "requirements": ["holidays==0.62"] } diff --git a/requirements_all.txt b/requirements_all.txt index 527b979ba5143..25111274e828b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.61 +holidays==0.62 # homeassistant.components.frontend home-assistant-frontend==20241127.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0260108e7c825..e3f573dc56cf5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -953,7 +953,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.61 +holidays==0.62 # homeassistant.components.frontend home-assistant-frontend==20241127.3 From 629c7a53ce8303f4f81c1e9f253f29899eadcc86 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 4 Dec 2024 09:46:36 +0900 Subject: [PATCH 1057/1070] Bump thinqconnect to 1.0.2 (#132131) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index daab135309818..6dd60909c6682 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==1.0.1"] + "requirements": ["thinqconnect==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 25111274e828b..8f1a4ede8c124 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2837,7 +2837,7 @@ thermopro-ble==0.10.0 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.1 +thinqconnect==1.0.2 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3f573dc56cf5..afd21bede3c4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2259,7 +2259,7 @@ thermobeacon-ble==0.7.0 thermopro-ble==0.10.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.1 +thinqconnect==1.0.2 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From 49c40cd9023d0979575be15d7586e75d8cfc8d57 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 4 Dec 2024 09:52:15 +0100 Subject: [PATCH 1058/1070] Track if intent was processed locally (#132166) --- .../components/assist_pipeline/pipeline.py | 8 +++++++- .../assist_pipeline/snapshots/test_init.ambr | 8 ++++++++ .../snapshots/test_websocket.ambr | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 5bbc81adb865c..9e9e84fb5d6ea 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1018,6 +1018,7 @@ async def recognize_intent( "intent_input": intent_input, "conversation_id": conversation_id, "device_id": device_id, + "prefer_local_intents": self.pipeline.prefer_local_intents, }, ) ) @@ -1031,6 +1032,7 @@ async def recognize_intent( language=self.pipeline.language, agent_id=self.intent_agent, ) + processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT conversation_result: conversation.ConversationResult | None = None if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT: @@ -1061,6 +1063,7 @@ async def recognize_intent( response=intent_response, conversation_id=user_input.conversation_id, ) + processed_locally = True if conversation_result is None: # Fall back to pipeline conversation agent @@ -1085,7 +1088,10 @@ async def recognize_intent( self.process_event( PipelineEvent( PipelineEventType.INTENT_END, - {"intent_output": conversation_result.as_dict()}, + { + "processed_locally": processed_locally, + "intent_output": conversation_result.as_dict(), + }, ) ) diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index c70d3944f88b6..3b829e0e14a6c 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -37,6 +37,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }), 'type': , }), @@ -60,6 +61,7 @@ }), }), }), + 'processed_locally': True, }), 'type': , }), @@ -126,6 +128,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en-US', + 'prefer_local_intents': False, }), 'type': , }), @@ -149,6 +152,7 @@ }), }), }), + 'processed_locally': True, }), 'type': , }), @@ -215,6 +219,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en-US', + 'prefer_local_intents': False, }), 'type': , }), @@ -238,6 +243,7 @@ }), }), }), + 'processed_locally': True, }), 'type': , }), @@ -328,6 +334,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }), 'type': , }), @@ -351,6 +358,7 @@ }), }), }), + 'processed_locally': True, }), 'type': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 566fb129959d2..41747a50eb6a5 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -36,6 +36,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_audio_pipeline.4 @@ -58,6 +59,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_audio_pipeline.5 @@ -117,6 +119,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_audio_pipeline_debug.4 @@ -139,6 +142,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_audio_pipeline_debug.5 @@ -210,6 +214,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_audio_pipeline_with_enhancements.4 @@ -232,6 +237,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_audio_pipeline_with_enhancements.5 @@ -313,6 +319,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.6 @@ -335,6 +342,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.7 @@ -519,6 +527,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_intent_failed.2 @@ -541,6 +550,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_intent_timeout.2 @@ -569,6 +579,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'never mind', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_pipeline_empty_tts_output.2 @@ -592,6 +603,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_pipeline_empty_tts_output.3 @@ -680,6 +692,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_text_only_pipeline[extra_msg0].2 @@ -702,6 +715,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_text_only_pipeline[extra_msg0].3 @@ -724,6 +738,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_text_only_pipeline[extra_msg1].2 @@ -746,6 +761,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_text_only_pipeline[extra_msg1].3 From 22b353f7d55d4322ebf4eb278e1d4028f4c95459 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 4 Dec 2024 13:21:10 +0100 Subject: [PATCH 1059/1070] Fix recorder "year" period in leap year (#132167) * FIX: make "year" period work in leap year * Add test * Set second and microsecond to non-zero in test start times * FIX: better fix for leap year problem * Revert "FIX: better fix for leap year problem" This reverts commit 06aba46ec6a0a1e944c88fe99d9bc6181a73cc1c. --------- Co-authored-by: Erik --- homeassistant/components/recorder/util.py | 2 +- tests/components/recorder/test_util.py | 92 ++++++++++++++++------- 2 files changed, 67 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index a59519ef38dfa..125b354211eb5 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -902,7 +902,7 @@ def resolve_period( start_time = (start_time + timedelta(days=cal_offset * 366)).replace( month=1, day=1 ) - end_time = (start_time + timedelta(days=365)).replace(day=1) + end_time = (start_time + timedelta(days=366)).replace(day=1) start_time = dt_util.as_utc(start_time) end_time = dt_util.as_utc(end_time) diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 4904bdecc4d9a..7b8eef6b16f96 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -9,6 +9,7 @@ from typing import Any from unittest.mock import MagicMock, Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from sqlalchemy import lambda_stmt, text from sqlalchemy.engine.result import ChunkedIteratorResult @@ -1052,55 +1053,94 @@ def all(self): assert rows == ["mock_row"] -@pytest.mark.freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=UTC)) -async def test_resolve_period(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("start_time", "periods"), + [ + ( + # Test 00:25 local time, during DST + datetime(2022, 10, 21, 7, 25, 50, 123, tzinfo=UTC), + { + "hour": ["2022-10-21T07:00:00+00:00", "2022-10-21T08:00:00+00:00"], + "hour-1": ["2022-10-21T06:00:00+00:00", "2022-10-21T07:00:00+00:00"], + "day": ["2022-10-21T07:00:00+00:00", "2022-10-22T07:00:00+00:00"], + "day-1": ["2022-10-20T07:00:00+00:00", "2022-10-21T07:00:00+00:00"], + "week": ["2022-10-17T07:00:00+00:00", "2022-10-24T07:00:00+00:00"], + "week-1": ["2022-10-10T07:00:00+00:00", "2022-10-17T07:00:00+00:00"], + "month": ["2022-10-01T07:00:00+00:00", "2022-11-01T07:00:00+00:00"], + "month-1": ["2022-09-01T07:00:00+00:00", "2022-10-01T07:00:00+00:00"], + "year": ["2022-01-01T08:00:00+00:00", "2023-01-01T08:00:00+00:00"], + "year-1": ["2021-01-01T08:00:00+00:00", "2022-01-01T08:00:00+00:00"], + }, + ), + ( + # Test 00:25 local time, standard time, February 28th a leap year + datetime(2024, 2, 28, 8, 25, 50, 123, tzinfo=UTC), + { + "hour": ["2024-02-28T08:00:00+00:00", "2024-02-28T09:00:00+00:00"], + "hour-1": ["2024-02-28T07:00:00+00:00", "2024-02-28T08:00:00+00:00"], + "day": ["2024-02-28T08:00:00+00:00", "2024-02-29T08:00:00+00:00"], + "day-1": ["2024-02-27T08:00:00+00:00", "2024-02-28T08:00:00+00:00"], + "week": ["2024-02-26T08:00:00+00:00", "2024-03-04T08:00:00+00:00"], + "week-1": ["2024-02-19T08:00:00+00:00", "2024-02-26T08:00:00+00:00"], + "month": ["2024-02-01T08:00:00+00:00", "2024-03-01T08:00:00+00:00"], + "month-1": ["2024-01-01T08:00:00+00:00", "2024-02-01T08:00:00+00:00"], + "year": ["2024-01-01T08:00:00+00:00", "2025-01-01T08:00:00+00:00"], + "year-1": ["2023-01-01T08:00:00+00:00", "2024-01-01T08:00:00+00:00"], + }, + ), + ], +) +async def test_resolve_period( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + start_time: datetime, + periods: dict[str, tuple[str, str]], +) -> None: """Test statistic_during_period.""" + assert hass.config.time_zone == "US/Pacific" + freezer.move_to(start_time) now = dt_util.utcnow() start_t, end_t = resolve_period({"calendar": {"period": "hour"}}) - assert start_t.isoformat() == "2022-10-21T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-21T08:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "hour"}}) - assert start_t.isoformat() == "2022-10-21T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-21T08:00:00+00:00" + assert start_t.isoformat() == periods["hour"][0] + assert end_t.isoformat() == periods["hour"][1] start_t, end_t = resolve_period({"calendar": {"period": "hour", "offset": -1}}) - assert start_t.isoformat() == "2022-10-21T06:00:00+00:00" - assert end_t.isoformat() == "2022-10-21T07:00:00+00:00" + assert start_t.isoformat() == periods["hour-1"][0] + assert end_t.isoformat() == periods["hour-1"][1] start_t, end_t = resolve_period({"calendar": {"period": "day"}}) - assert start_t.isoformat() == "2022-10-21T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-22T07:00:00+00:00" + assert start_t.isoformat() == periods["day"][0] + assert end_t.isoformat() == periods["day"][1] start_t, end_t = resolve_period({"calendar": {"period": "day", "offset": -1}}) - assert start_t.isoformat() == "2022-10-20T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-21T07:00:00+00:00" + assert start_t.isoformat() == periods["day-1"][0] + assert end_t.isoformat() == periods["day-1"][1] start_t, end_t = resolve_period({"calendar": {"period": "week"}}) - assert start_t.isoformat() == "2022-10-17T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-24T07:00:00+00:00" + assert start_t.isoformat() == periods["week"][0] + assert end_t.isoformat() == periods["week"][1] start_t, end_t = resolve_period({"calendar": {"period": "week", "offset": -1}}) - assert start_t.isoformat() == "2022-10-10T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-17T07:00:00+00:00" + assert start_t.isoformat() == periods["week-1"][0] + assert end_t.isoformat() == periods["week-1"][1] start_t, end_t = resolve_period({"calendar": {"period": "month"}}) - assert start_t.isoformat() == "2022-10-01T07:00:00+00:00" - assert end_t.isoformat() == "2022-11-01T07:00:00+00:00" + assert start_t.isoformat() == periods["month"][0] + assert end_t.isoformat() == periods["month"][1] start_t, end_t = resolve_period({"calendar": {"period": "month", "offset": -1}}) - assert start_t.isoformat() == "2022-09-01T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-01T07:00:00+00:00" + assert start_t.isoformat() == periods["month-1"][0] + assert end_t.isoformat() == periods["month-1"][1] start_t, end_t = resolve_period({"calendar": {"period": "year"}}) - assert start_t.isoformat() == "2022-01-01T08:00:00+00:00" - assert end_t.isoformat() == "2023-01-01T08:00:00+00:00" + assert start_t.isoformat() == periods["year"][0] + assert end_t.isoformat() == periods["year"][1] start_t, end_t = resolve_period({"calendar": {"period": "year", "offset": -1}}) - assert start_t.isoformat() == "2021-01-01T08:00:00+00:00" - assert end_t.isoformat() == "2022-01-01T08:00:00+00:00" + assert start_t.isoformat() == periods["year-1"][0] + assert end_t.isoformat() == periods["year-1"][1] # Fixed period assert resolve_period({}) == (None, None) From 512ac7d572871992facd3adccec78ead68156852 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 3 Dec 2024 12:37:05 -0600 Subject: [PATCH 1060/1070] Ensure entity names are not hassil templates (#132184) --- .../components/conversation/default_agent.py | 2 +- .../conversation/test_default_agent.py | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index c1256a1507b37..1194091fd460c 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -711,7 +711,7 @@ def _get_unexposed_entity_names(self, text: str) -> TextSlotList: for name_tuple in self._get_entity_name_tuples(exposed=False): self._unexposed_names_trie.insert( name_tuple[0].lower(), - TextSlotValue.from_tuple(name_tuple), + TextSlotValue.from_tuple(name_tuple, allow_template=False), ) # Build filtered slot list diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 00c47b42629e8..39ecdb7f42248 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -3013,3 +3013,39 @@ async def test_entities_filtered_by_input(hass: HomeAssistant) -> None: assert len(name_list.values) == 2 assert name_list.values[0].text_in.text == "test light" assert name_list.values[1].text_in.text == "test light" + + +@pytest.mark.usefixtures("init_components") +async def test_entities_names_are_not_templates(hass: HomeAssistant) -> None: + """Test that entities names are not treated as hassil templates.""" + # Contains hassil template characters + hass.states.async_set( + "light.test_light", "off", attributes={ATTR_FRIENDLY_NAME: " Date: Tue, 3 Dec 2024 19:31:28 +0100 Subject: [PATCH 1061/1070] Fix typo in exception message in google_photos integration (#132194) --- homeassistant/components/google_photos/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index bd565a6122d36..fa3f4669dac60 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -48,7 +48,7 @@ "message": "`{filename}` is not an image" }, "missing_upload_permission": { - "message": "Home Assistnt was not granted permission to upload to Google Photos" + "message": "Home Assistant was not granted permission to upload to Google Photos" }, "upload_error": { "message": "Failed to upload content: {message}" From d40a9bd9ef011372775062f64f4aeb2870a76c6f Mon Sep 17 00:00:00 2001 From: cnico Date: Wed, 4 Dec 2024 09:53:29 +0100 Subject: [PATCH 1062/1070] Fix blocking call in netdata (#132209) Co-authored-by: G Johansson --- homeassistant/components/netdata/manifest.json | 2 +- homeassistant/components/netdata/sensor.py | 5 ++++- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netdata/manifest.json b/homeassistant/components/netdata/manifest.json index 199073298ab88..8901a271de226 100644 --- a/homeassistant/components/netdata/manifest.json +++ b/homeassistant/components/netdata/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["netdata"], "quality_scale": "legacy", - "requirements": ["netdata==1.1.0"] + "requirements": ["netdata==1.3.0"] } diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index b77a4392ef42e..f33349c56ced8 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -24,6 +24,7 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -70,7 +71,9 @@ async def async_setup_platform( port = config[CONF_PORT] resources = config[CONF_RESOURCES] - netdata = NetdataData(Netdata(host, port=port, timeout=20.0)) + netdata = NetdataData( + Netdata(host, port=port, timeout=20.0, httpx_client=get_async_client(hass)) + ) await netdata.async_update() if netdata.api.metrics is None: diff --git a/requirements_all.txt b/requirements_all.txt index 8f1a4ede8c124..5d4acff1ae3ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1433,7 +1433,7 @@ ndms2-client==0.1.2 nessclient==1.1.2 # homeassistant.components.netdata -netdata==1.1.0 +netdata==1.3.0 # homeassistant.components.nmap_tracker netmap==0.7.0.2 From f28579357efd92644ab1b54da44bfb6b91c9e9a2 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 4 Dec 2024 06:03:31 +0100 Subject: [PATCH 1063/1070] fix: unifiprotect prevent RTSP repair for third-party cameras (#132212) Co-authored-by: J. Nick Koston --- .../components/unifiprotect/camera.py | 2 +- tests/components/unifiprotect/test_repairs.py | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index a40939be9177f..0b1c03b8dd605 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -90,7 +90,7 @@ def _get_camera_channels( is_default = False # no RTSP enabled use first channel with no stream - if is_default: + if is_default and not camera.is_third_party_camera: _create_rtsp_repair(hass, entry, data, camera) yield camera, camera.channels[0], True else: diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index adb9555e6ea47..1117038bbd0c8 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -363,3 +363,30 @@ async def test_rtsp_writable_fix_when_not_setup( ufp.api.update_device.assert_called_with( ModelType.CAMERA, doorbell.id, {"channels": channels} ) + + +async def test_rtsp_no_fix_if_third_party( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test no RTSP disabled warning if camera is third-party.""" + + for channel in doorbell.channels: + channel.is_rtsp_enabled = False + for user in ufp.api.bootstrap.users.values(): + user.all_permissions = [] + + ufp.api.get_camera = AsyncMock(return_value=doorbell) + doorbell.is_third_party_camera = True + + await init_entry(hass, ufp, [doorbell]) + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert not msg["result"]["issues"] From e463d5d16f7e9c3514e0f71daa23941a16455b14 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 4 Dec 2024 09:35:53 +0100 Subject: [PATCH 1064/1070] Bump yt-dlp to 2024.12.03 (#132220) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 866215839bf9b..f85f1561bb95d 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2024.11.18"], + "requirements": ["yt-dlp[default]==2024.12.03"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 5d4acff1ae3ea..799da7d791c20 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3066,7 +3066,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.11.18 +yt-dlp[default]==2024.12.03 # homeassistant.components.zamg zamg==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index afd21bede3c4e..04f2cfb48ee9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2452,7 +2452,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.11.18 +yt-dlp[default]==2024.12.03 # homeassistant.components.zamg zamg==0.3.6 From 7e96666dc53dfc48dbbc36f4973c59b11b74c8f9 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 4 Dec 2024 11:50:55 +0100 Subject: [PATCH 1065/1070] Bump deebot-client to 9.1.0 (#132253) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 4a43489ff2451..546aba01d901a 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==9.0.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==9.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 799da7d791c20..c3bc9c0942d2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -738,7 +738,7 @@ debugpy==1.8.6 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==9.0.0 +deebot-client==9.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04f2cfb48ee9e..4476faa48f827 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -628,7 +628,7 @@ dbus-fast==2.24.3 debugpy==1.8.6 # homeassistant.components.ecovacs -deebot-client==9.0.0 +deebot-client==9.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 4fd4ba781366357c50b19e85a8bfe1fed6d49246 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 4 Dec 2024 14:57:25 +0100 Subject: [PATCH 1066/1070] Update frontend to 20241127.4 (#132268) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 264f0756b825d..97a67cbc082dc 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241127.3"] + "requirements": ["home-assistant-frontend==20241127.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index af91964994e15..7e4958a7a0023 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ habluetooth==3.6.0 hass-nabucasa==0.85.0 hassil==2.0.5 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241127.3 +home-assistant-frontend==20241127.4 home-assistant-intents==2024.12.2 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c3bc9c0942d2c..22c5ffe3f3007 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1130,7 +1130,7 @@ hole==0.8.0 holidays==0.62 # homeassistant.components.frontend -home-assistant-frontend==20241127.3 +home-assistant-frontend==20241127.4 # homeassistant.components.conversation home-assistant-intents==2024.12.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4476faa48f827..267bb9dfa69f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -956,7 +956,7 @@ hole==0.8.0 holidays==0.62 # homeassistant.components.frontend -home-assistant-frontend==20241127.3 +home-assistant-frontend==20241127.4 # homeassistant.components.conversation home-assistant-intents==2024.12.2 From 333ada767045dd9c57ce10cb5ef716ba835b2c60 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 4 Dec 2024 15:18:04 +0100 Subject: [PATCH 1067/1070] Ensure MQTT subscriptions can be made when the broker is disconnected (#132270) --- homeassistant/components/mqtt/client.py | 2 +- tests/components/mqtt/test_client.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index a626e0e5b28a1..ee6f02912b27e 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -227,7 +227,7 @@ def async_subscribe_internal( translation_placeholders={"topic": topic}, ) from exc client = mqtt_data.client - if not client.connected and not mqtt_config_entry_enabled(hass): + if not mqtt_config_entry_enabled(hass): raise HomeAssistantError( f"Cannot subscribe to topic '{topic}', MQTT is not enabled", translation_key="mqtt_not_setup_cannot_subscribe", diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 164c164cdfc97..4bfcde752ae6b 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -1045,10 +1045,17 @@ async def test_restore_subscriptions_on_reconnect( mqtt_client_mock.reset_mock() mqtt_client_mock.on_disconnect(None, None, 0) + # Test to subscribe orther topic while the client is not connected + await mqtt.async_subscribe(hass, "test/other", record_calls) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + assert ("test/other", 0) not in help_all_subscribe_calls(mqtt_client_mock) + mock_debouncer.clear() mqtt_client_mock.on_connect(None, None, None, 0) await mock_debouncer.wait() + # Assert all subscriptions are performed at the broker assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) + assert ("test/other", 0) in help_all_subscribe_calls(mqtt_client_mock) @pytest.mark.parametrize( From 4c3ae395a4d7cff46ed36e1afa36e9a01c88a9d8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 4 Dec 2024 15:33:47 +0100 Subject: [PATCH 1068/1070] Bump version to 2024.12.0b6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 075262346b408..255b19e666747 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 57523be4e6826..a2f0659f3df6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.12.0b5" +version = "2024.12.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From dcdf033fa93eb888886a6bfed9ad1121bcc37dbb Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 4 Dec 2024 10:02:00 -0600 Subject: [PATCH 1069/1070] Bump intents to 2024.12.4 (#132274) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2d2f2f58a3ac8..72e1cebf462d9 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.0.5", "home-assistant-intents==2024.12.2"] + "requirements": ["hassil==2.0.5", "home-assistant-intents==2024.12.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7e4958a7a0023..ed7e995408fa5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ hass-nabucasa==0.85.0 hassil==2.0.5 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241127.4 -home-assistant-intents==2024.12.2 +home-assistant-intents==2024.12.4 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 22c5ffe3f3007..20f105b7f0799 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,7 +1133,7 @@ holidays==0.62 home-assistant-frontend==20241127.4 # homeassistant.components.conversation -home-assistant-intents==2024.12.2 +home-assistant-intents==2024.12.4 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 267bb9dfa69f3..38440ddcf5264 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -959,7 +959,7 @@ holidays==0.62 home-assistant-frontend==20241127.4 # homeassistant.components.conversation -home-assistant-intents==2024.12.2 +home-assistant-intents==2024.12.4 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 26ca6475af72e..100be4fdec940 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.4,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.0 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.5 home-assistant-intents==2024.12.2 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.5 home-assistant-intents==2024.12.4 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 9b90df74a6d19682b8b3f49ff9f58bf895ee60e9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 4 Dec 2024 19:18:48 +0100 Subject: [PATCH 1070/1070] Bump version to 2024.12.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 255b19e666747..c41ab6ec38299 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index a2f0659f3df6c..2ceb074cc48b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.12.0b6" +version = "2024.12.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst"