From 388c5807ea3339d51aea5aac01bd325f4c2ead67 Mon Sep 17 00:00:00 2001 From: Erik Elkins Date: Mon, 11 Nov 2024 09:10:52 -0600 Subject: [PATCH 01/81] 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 a2738ed446f..625b4698301 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 ac612aea119..90135ad96b3 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -9,7 +9,11 @@ from homeassistant.components.sensor import ( 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 @@ from .entity import SwitchBotCloudEntity 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_PLUS_SENSOR_DESCRIPTIONS = ( ), ) +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 25ea370efe5..43431ae04c0 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 02/81] 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 9ddb6abf093..e74d22b8207 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 ff2e42fe779..4582dc3f50d 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 7e0be99a682..4495e8a2c21 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 03/81] 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 622c09f0d38..0d357cce199 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -168,7 +168,7 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN): ) 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 92d9450b670..f8f94d44126 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 04/81] 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 afe352904ce..8f8f7e0d588 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 4582dc3f50d..fe737af17e7 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 4495e8a2c21..ae4d027dc8f 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 05/81] 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 9a66c4542b5..e5fbfe0e8c5 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -134,7 +134,6 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { 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 06/81] 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 3b3c50b3326..285de399e5d 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 241fff89ac3..166fd965e2c 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 37d0ea1d105..c5611069bf5 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -160,7 +160,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 From 3f34ddd74fc0e4a50382cad2b840f6e1cb854cb0 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Mon, 11 Nov 2024 20:07:12 +0100 Subject: [PATCH 07/81] 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 6ce41a2d08d..695a35df871 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 fe737af17e7..526fa853ffc 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 ae4d027dc8f..c19e6bb241d 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 08/81] 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 7da047682ac..6a9c63ea212 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -9,7 +9,6 @@ import re from typing import cast import pypck -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -19,17 +18,12 @@ from homeassistant.const import ( 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 homeassistant.helpers.typing import ConfigType 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 ae0b1b01f9a..088a3654500 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 09/81] 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 604f9b7cc88..3ea8f581245 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -18,7 +18,7 @@ from homeassistant.components.media_player import ( 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 30f8fa8d07a..fc2a8ccb1cc 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -16,7 +16,7 @@ from sqlalchemy.pool import ( 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 @@ class RecorderPool(SingletonThreadPool, NullPool): # 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 b9d51cd3c36..8ddfdbd592d 100644 --- a/homeassistant/components/zeroconf/usage.py +++ b/homeassistant/components/zeroconf/usage.py @@ -4,7 +4,7 @@ from typing import Any 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 10/81] 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 fabb2c30190..7c85ca63467 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 b02a8fa2520..c1fbc16d9be 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 963721a0476..bb8c33ba749 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 520bd0550cc..43c151c7c23 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 1e70c4d3e10..f13799422df 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 14f2d093f37..3fcc895c2b9 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 2f39644d6d3..af00a1fdfed 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 875c98acb6d..7d08367cf7d 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 91ce27badd3..86fd83ad088 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 285de399e5d..ec2dc977989 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 143330f5adb..4a9192d7767 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 aa72a7d23eb..19f8ac9ee22 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 526fa853ffc..83bf653e424 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 c19e6bb241d..db4fea6aa0e 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 f4d534365bc..464a2fc456b 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -84,6 +84,7 @@ OSI_APPROVED_LICENSES_SPDX = { "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 11/81] Add go2rtc to devcontainer (#130380) --- Dockerfile.dev | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile.dev b/Dockerfile.dev index d05c6df425c..48f582a1581 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 12/81] 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 d32e0ce4047..ef89a2f1acb 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 83bf653e424..608b025f5eb 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 db4fea6aa0e..631cc0b0343 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 13/81] 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 39ac17d94f9..39d38a8f47d 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -25,7 +25,6 @@ except ImportError: 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 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): 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 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): 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 8db3f49ab8e..12a7eca5f9d 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 14/81] 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 1b38dc8ce5c..af52e89d399 100644 --- a/homeassistant/components/russound_rio/const.py +++ b/homeassistant/components/russound_rio/const.py @@ -17,7 +17,7 @@ RUSSOUND_RIO_EXCEPTIONS = ( ) -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 96fc0fb53db..ab77ca3ab6a 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 561f3b008c7..45818d3e25b 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -5,7 +5,7 @@ from __future__ import annotations 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 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): 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 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): 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 608b025f5eb..b46c6dbfef4 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 631cc0b0343..c4ae704eca6 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 38ef603c21d..e720e2c7f65 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 15/81] 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 47143f3595c..c8a639cd239 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -146,14 +146,21 @@ class DeviceListener(SharingDeviceListener): 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 4d3710f7570..cc258560067 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -283,10 +283,15 @@ class TuyaEntity(Entity): 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 305a74160de..b53e6fa27d8 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 b46c6dbfef4..45c7b6f46b5 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 c4ae704eca6..80d3d806eb7 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 16/81] 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 bacbff63211..0c39392ca19 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -251,8 +251,8 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN): """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 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN): 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 5074a289d19..1ff1470f81c 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 17/81] 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 14ae57391ef..2b08031405f 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -318,7 +318,6 @@ class OptionsFlowHandler(OptionsFlow, ABC): 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 64eadeb0d7e..f1748c6b7fb 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3158,11 +3158,12 @@ class OptionsFlow(ConfigEntryBaseFlow): @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 eb2a719eab8..41af8af3f21 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -7501,6 +7501,7 @@ async def test_options_flow_config_entry( 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 test_options_flow_deprecated_config_entry_setter( 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 18/81] 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 f89dadda43d..519674b9894 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 bab89d20584..23f584dd0de 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 745159d61d3..9bad1e8aecc 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 19/81] 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 d1bfefec484..bc9304ab59d 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 45c7b6f46b5..67c7c991146 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 80d3d806eb7..048f0ac7d76 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 20/81] 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 | 38 +++++++--------- 4 files changed, 31 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/nordpool/config_flow.py b/homeassistant/components/nordpool/config_flow.py index a9a834d8225..1d75d825e47 100644 --- a/homeassistant/components/nordpool/config_flow.py +++ b/homeassistant/components/nordpool/config_flow.py @@ -4,7 +4,12 @@ from __future__ import annotations 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 27016ae2b4b..fa4e9ca2548 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -9,8 +9,8 @@ from typing import TYPE_CHECKING from pynordpool import ( Currency, DeliveryPeriodData, - NordPoolAuthenticationError, NordPoolClient, + NordPoolEmptyResponseError, NordPoolError, NordPoolResponseError, ) @@ -19,7 +19,7 @@ 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.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from .const import CONF_AREAS, DOMAIN, LOGGER @@ -75,8 +75,8 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodData]): 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 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodData]): 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 d17db619b02..cfdfc63aca7 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 9cff34adb1f..d2d912b1b99 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -9,6 +9,7 @@ from freezegun.api import FrozenDateTimeFactory from pynordpool import ( DeliveryPeriodData, NordPoolAuthenticationError, + NordPoolEmptyResponseError, NordPoolError, NordPoolResponseError, ) @@ -18,14 +19,13 @@ 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") +@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,6 +74,17 @@ async def test_coordinator( assert "Authentication error" in caplog.text mock_data.reset_mock() + 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 "Empty response" 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)) @@ -85,25 +96,6 @@ async def test_coordinator( 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)) @@ -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 21/81] 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 187cc74b3eb..edcadf2598a 100644 --- a/homeassistant/components/lg_thinq/fan.py +++ b/homeassistant/components/lg_thinq/fan.py @@ -72,8 +72,11 @@ class ThinQFanEntity(ThinQEntity, FanEntity): 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 @@ class ThinQFanEntity(ThinQEntity, FanEntity): 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 @@ class ThinQFanEntity(ThinQEntity, FanEntity): 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 22/81] 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 ec2dc977989..a40c8745877 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 4a9192d7767..adc85c0f4f7 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 19f8ac9ee22..53d6b13a4ab 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 23/81] 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 81ac10f86cc..2b35ebade76 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 02ea8103192..61a798dbd75 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -997,14 +997,14 @@ class TrackTemplateResultInfo: 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 33e8f3d3d6e..e3da52604cb 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 753464c35d5..2eab666bbd4 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -515,18 +515,18 @@ class Template: 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 f5c2a2a1288..87d55891e90 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -29,7 +29,7 @@ from homeassistant.util.dt import utcnow 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 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): 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 24/81] 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 78296c70cef..86c555ec151 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -21,6 +21,7 @@ from .models import Eq3Config, Eq3ConfigEntryData 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 33d8e6b3cee..64bc1cf497c 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -21,6 +21,9 @@ DEVICE_MODEL = "CC-RT-BLE-EQ" 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 00000000000..fb0862f14bc --- /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 c911be099d5..03c3b21b964 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 00000000000..7525d8ca494 --- /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 25/81] 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 903a121c032..15574192093 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 04b5b9f9317..fc91ef5e546 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.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_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 @@ CONFIG_SCHEMA = vol.Schema( 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 async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 @@ class WebRTCProvider(CameraWebRTCProvider): ) -> 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 @@ class WebRTCProvider(CameraWebRTCProvider): 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 3c4dc9a9500..d33ae3e3897 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,4 +6,3 @@ CONF_DEBUG_UI = "debug_ui" 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 91f4433546c..6699ee4d8a2 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant 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 @@ api: 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 083cdaba1a9..9d38d8f7128 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -112,7 +112,7 @@ LABEL "com.github.actions.icon"="terminal" 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 ec586776142..9388110366e 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_setup_and_signaling( 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_setup_and_signaling( 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_setup_and_signaling( ], ) @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 + def after_setup() -> None: + server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled) + server_start.assert_called_once() - 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"), - ] - ), - } - - 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_setup_managed( ], ) @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 + 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) - - 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") + 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 26/81] 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 4cc4e61c9e4..42693035bd3 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 @@ class DownloadBackupView(HomeAssistantView): 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 @@ class DownloadBackupView(HomeAssistantView): 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 8265dade3aa..4300f75eed0 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -9,11 +9,15 @@ import hashlib 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 @@ class BaseBackupManager(abc.ABC): 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 @@ class BackupManager(BaseBackupManager): 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 93ecb27bc97..76b1f76b55b 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 a4dba5c6936..a3f70267643 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 27/81] Simplify modern_forms config flow (#130441) * Simplify modern_forms config flow * Rename variable * Drop CONF_NAME --- .../components/modern_forms/config_flow.py | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/modern_forms/config_flow.py b/homeassistant/components/modern_forms/config_flow.py index dee08736234..33e814efb51 100644 --- a/homeassistant/components/modern_forms/config_flow.py +++ b/homeassistant/components/modern_forms/config_flow.py @@ -9,11 +9,13 @@ import voluptuous as vol 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 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): 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 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 source == SOURCE_ZEROCONF: + if self.source == SOURCE_ZEROCONF: user_input[CONF_HOST] = self.host user_input[CONF_MAC] = self.mac @@ -75,18 +81,21 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): 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 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): 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 28/81] 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 a060c88da24..3e6fec9d986 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 29/81] 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 00000000000..3160c2bfa6d --- /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 00000000000..dde2eca0022 --- /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 00000000000..4639186ecf1 --- /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 30/81] 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 60ebf451f78..8806c67cd96 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 31/81] 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 e5fbfe0e8c5..7243af9d4d5 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -28,7 +28,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, - BloodGlugoseConcentrationConverter, + BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -130,8 +130,8 @@ QUERY_STATISTICS_SUMMARY_SUM = ( 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 8b8d1cfb0c6..f4dce73fa47 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv 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 @@ UPDATE_STATISTICS_METADATA_TIME_OUT = 10 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 ee6167a5643..f4573f873a2 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -48,7 +48,7 @@ from homeassistant.helpers.deprecation import ( ) from homeassistant.util.unit_conversion import ( BaseUnitConverter, - BloodGlugoseConcentrationConverter, + BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -501,7 +501,7 @@ STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] 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 95d8fbc9df1..1bf3561e66a 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 a57cdde821f..609809a96e8 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -33,7 +33,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import unit_conversion from homeassistant.util.unit_conversion import ( BaseUnitConverter, - BloodGlugoseConcentrationConverter, + BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -61,7 +61,7 @@ INVALID_SYMBOL = "bob" _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 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { # 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 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo _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 32/81] 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 a40c8745877..956ea032fe7 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 adc85c0f4f7..8e588ce0b0e 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 53d6b13a4ab..ac7c00b8050 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 33/81] 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 e3f643486a0..3631bf1163b 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 67c7c991146..b7a979050bf 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 048f0ac7d76..ec6be67d4b4 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 34/81] 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 23a46c5e1c9..22fd625770f 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 b7a979050bf..0009c93f673 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 ec6be67d4b4..7ad45aae832 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 35/81] 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 741b277d84d..07e132a0b5b 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 36/81] 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 a0a86be5da3..c24bdb743bf 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 0009c93f673..a5898c91708 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 7ad45aae832..a7f382e0251 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 37/81] 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 458ff50dac9..c19f37a040d 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -8,6 +8,7 @@ from aioautomower.exceptions import ( ApiException, AuthException, HusqvarnaWSServerHandshakeError, + TimeoutException, ) from aioautomower.model import MowerAttributes from aioautomower.session import AutomowerSession @@ -22,6 +23,7 @@ from .const import DOMAIN _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 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib 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 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib 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 2814e1558d1..0202cec05b9 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 ca0c2a04af1..ae688571d2c 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 38/81] 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 ea9308e5e18..201b7168847 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 956ea032fe7..7a0e43b299e 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 a5898c91708..9a27f4d3b04 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 a7f382e0251..38704005179 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 39/81] 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 2946fc64941..a9e433a3650 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -6,7 +6,7 @@ import asyncio 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 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A @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 90b23f87ab1..89a2a2a2b1a 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 test_alarm_control_panel_log_deprecated_state_warning_using_attr_state ) # 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 40/81] 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 519674b9894..56fbabe8087 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 fc91ef5e546..f1f6e44abc1 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -4,6 +4,7 @@ import logging 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.config_entries import SOURCE_SYSTEM, ConfigEntry 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 d33ae3e3897..3c1c84c42b5 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,3 +6,4 @@ CONF_DEBUG_UI = "debug_ui" 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 00000000000..e350c19af96 --- /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 9d38d8f7128..137bbc7ff66 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -4,6 +4,7 @@ from dataclasses import dataclass 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.icon"="terminal" 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 42b363b2324..abb139b89bf 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 9388110366e..0f1cac6942d 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -6,6 +6,7 @@ from typing import NamedTuple 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 @@ from homeassistant.components.go2rtc.const import ( 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 init_test_integration( 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_setup_and_signaling( @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 @@ async def test_setup_go_binary( 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 @@ async def test_setup_go_binary( ], ) @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 @@ async def test_setup_go( 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 41/81] 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 2c80c32245c..48e37717232 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 42/81] 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 23401f30abc..65e6652407f 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 43/81] 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 6d2f4087f59..4013c8a6c19 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -268,7 +268,6 @@ INTEGRATION_MANIFEST_SCHEMA = vol.Schema( ) ], 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 44/81] 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 c24bdb743bf..f9e8328ae53 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 9a27f4d3b04..334d36f0840 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 38704005179..c8d4fb15883 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 45/81] 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 76754fc5082..ffec4a9ea29 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 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): 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 46/81] 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 ec9ffdd7554..920ecda1c52 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 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): 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 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): 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 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): ) -> 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 e3fae8c083e..84f1b240b88 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -10,6 +10,7 @@ from fritzconnection.core.exceptions import ( ) import pytest +from homeassistant.components import ssdp from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -22,7 +23,6 @@ from homeassistant.components.fritz.const import ( 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 47/81] 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 16e7bf552ba..6316eb72096 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 334d36f0840..e562f218f83 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 c8d4fb15883..d74f9f8ba95 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 48/81] 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 022eda00123..76422734c92 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 00000000000..3ab0cbbe5bd --- /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 00000000000..4daad78c558 --- /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 00000000000..a55dfb2a52b --- /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 00000000000..e67744a741b --- /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 00000000000..b00ff309b10 --- /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 00000000000..05a464f73a6 --- /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 00000000000..af794490c43 --- /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 cbd30b560ce..78e16126542 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -537,6 +537,7 @@ FLOWS = { "simplefin", "simplepush", "simplisafe", + "sky_remote", "skybell", "slack", "sleepiq", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a1fdb9478f3..33a7d02776f 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 e562f218f83..97416c7ea39 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 d74f9f8ba95..3ffc1547722 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 00000000000..83d68330d5b --- /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 00000000000..d6c453d81f7 --- /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 00000000000..aaeda20788c --- /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 00000000000..fe316baa6bf --- /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 00000000000..301375bc039 --- /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 49/81] 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 63c47cb2979..e431c680081 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 97416c7ea39..3de766e93c7 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 3ffc1547722..b492a6f7020 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 50/81] 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 | 99 ++++++++----------- tests/components/backup/test_websocket.py | 18 ++-- 7 files changed, 199 insertions(+), 85 deletions(-) create mode 100644 tests/components/backup/conftest.py diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 200cb4a3f65..907fda4c7f8 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 4300f75eed0..ddc0a1eac3f 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -4,6 +4,7 @@ from __future__ import annotations import abc import asyncio +from collections.abc import Callable from dataclasses import asdict, dataclass import hashlib import io @@ -34,6 +35,13 @@ from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER 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 @@ class Backup: 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 @@ class BaseBackupManager(abc.ABC): """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 @@ class BackupManager(BaseBackupManager): 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 @@ class BackupManager(BaseBackupManager): 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 3ac8a7ace3e..a7c61b7c66c 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -8,6 +8,7 @@ from homeassistant.components import websocket_api 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 00000000000..631c774e63c --- /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 096df37d704..42eb524e529 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 a3f70267643..9d24964aedf 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 @@ from multidict import CIMultiDict, CIMultiDictProxy 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 .common import TEST_BACKUP 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() + def on_progress(_progress: BackupProgress) -> None: + """Mock progress callback.""" + progress.append(_progress) - 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] - ) + 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 test_exception_plaform_pre(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 test_exception_plaform_post(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 125ba8adaad..3e031f172ae 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 51/81] 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 c533ca34ef3..75faf1923df 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 b659e33038a..df740aea3d1 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 homeassistant.helpers.entity_platform import AddEntitiesCallback 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 10c31378184..6a5fd96b99d 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 homeassistant.helpers.aiohttp_client import async_get_clientsession 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 025d1b97f21..38562e1a072 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 3de766e93c7..00984b9a5a6 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 b492a6f7020..ffda690bc33 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 da66cc54b72..00000000000 --- 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 52/81] 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 191d10a70dd..eca4f465435 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 149e08014ac..e3abb3c98df 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 53/81] 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 78625192e4a..00da729dccd 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 d930aafbdfb..6abc544c92a 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 54/81] 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 7c08df39000..cc100c48fd8 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 48f582a1581..5a3f1a2ae64 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 13618740ab8..a8755bbbf5c 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 558e7ec2b0b..4082a076b94 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -29,9 +29,9 @@ PATCH_VERSION: Final = "0.dev0" __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 8e588ce0b0e..a9b958e0805 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 55/81] 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 7a0e43b299e..abaf269103e 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 a9b958e0805..ebf22a93d7d 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 ac7c00b8050..b97c8dc57a0 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 56/81] 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 5a3f1a2ae64..48f582a1581 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 57/81] 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 a7110c35795..4838d19537a 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -16,11 +16,11 @@ from hassil.expression import Expression, ListReference, Sequence 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 @@ class DefaultAgent(ConversationEntity): 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 @@ class DefaultAgent(ConversationEntity): 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 @@ class DefaultAgent(ConversationEntity): (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 @@ class DefaultAgent(ConversationEntity): 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 @@ class DefaultAgent(ConversationEntity): 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 df1ffc7f74f..5e5800ad6f1 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -6,12 +6,8 @@ from collections.abc import Iterable 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 8b5c6ef173f..1676cdf8254 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 ec7ecc76da0..a4f64ffbad9 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -4,7 +4,8 @@ from __future__ import annotations 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 @@ from .const import DATA_DEFAULT_ENTITY, DOMAIN 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 abaf269103e..04e28fef58a 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 00984b9a5a6..e9b5cb8129f 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 ffda690bc33..de08e2db395 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 9bad1e8aecc..c921cf0e186 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 131444c17ac..b806c6faf23 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 08aca43aba5..d9d859113f8 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 9f54671d8a1..3c6b463670a 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 59cd10d2510..7c00b9a80b2 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 58/81] 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 04e28fef58a..5bc539beb86 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 c5611069bf5..7d53741c661 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -214,8 +214,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/tests/components/dhcp/conftest.py b/tests/components/dhcp/conftest.py deleted file mode 100644 index b0fa3f573c5..00000000000 --- 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 59/81] 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 2066b65221e..8d56f3a5563 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 e9b5cb8129f..a68fc1a828c 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 de08e2db395..7501398f4d3 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 60/81] 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 f893b04b2d1..fb8c9f76d79 100644 --- a/homeassistant/components/hunterdouglas_powerview/number.py +++ b/homeassistant/components/hunterdouglas_powerview/number.py @@ -95,7 +95,7 @@ class PowerViewNumber(ShadeEntity, RestoreNumber): 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 61/81] 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 00976abf56f..8b43460436b 100644 --- a/homeassistant/components/tesla_fleet/oauth.py +++ b/homeassistant/components/tesla_fleet/oauth.py @@ -49,6 +49,7 @@ class TeslaSystemImplementation(config_entry_oauth2_flow.LocalOAuth2Implementati 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 @@ class TeslaUserImplementation(AuthImplementation): @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 62/81] 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 20bc50f9855..fe592074f71 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 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): 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 3dfe0e72a7b..73cb95d2d7c 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -135,6 +135,9 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): 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 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): @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 63/81] 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 e6d9d25542f..71a4bc8aea5 100644 --- a/homeassistant/components/ring/event.py +++ b/homeassistant/components/ring/event.py @@ -96,7 +96,7 @@ class RingEvent(RingBaseEntity[RingListenCoordinator, RingDeviceT], EventEntity) @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 64/81] 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 edacd17f54d..c359ca14a21 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 ca6eebdec6b..c99abc853e5 100644 --- a/homeassistant/components/cambridge_audio/select.py +++ b/homeassistant/components/cambridge_audio/select.py @@ -51,8 +51,13 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = ( 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 a68fc1a828c..32f111781da 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 7501398f4d3..237c70c8afb 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 65/81] 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 1c7e0d105c4..f3f7f38772d 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -33,6 +33,7 @@ from homeassistant.config_entries import ( 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 1382c5c2569..87ba46a4ced 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -21,7 +21,7 @@ import zigpy.types 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 66/81] 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 0612c96e40c..d627a888169 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod 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 @@ async def ws_webrtc_offer( } ) @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 67/81] 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 ba5cf35c52f..29fb9d61c4e 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -139,42 +139,46 @@ async def init_test_integration( 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 @@ async def test_async_register_ice_server( 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 @@ async def test_async_register_ice_server( 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 @@ async def test_ws_get_client_config( 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 test_rtsp_to_webrtc_offer_not_accepted( 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 68/81] 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 fb76253eb3d..307fcaf0ea8 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -22,7 +22,7 @@ from .const import _LOGGER, DOMAIN, LINE_TYPES 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 69/81] 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 22fd625770f..7921bdb6ed5 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 32f111781da..9ad6a1199f2 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 237c70c8afb..68d1c393fc1 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 70/81] 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 137bbc7ff66..0eb72b91c02 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 c921cf0e186..fe18c4dd486 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 71/81] 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 0eb72b91c02..57d86bc4def 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -80,7 +80,7 @@ WORKDIR /config _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 fe18c4dd486..0fa0a1a89fa 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 72/81] 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 bd3f14939ca..b30f806bf63 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 9ad6a1199f2..3b46bf19ae6 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 68d1c393fc1..b27979b23f2 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 73/81] 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 76422734c92..8fd34a357c0 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 00000000000..dfdb4cb935d --- /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 00000000000..50671eecbba --- /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 00000000000..36727059c8a --- /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 00000000000..c603578763d --- /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 00000000000..bd915b42408 --- /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 00000000000..8a2108d2687 --- /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 00000000000..aeab07ee912 --- /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 00000000000..c907a70a38e --- /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 00000000000..f6a1aeb66fd --- /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 c4612898cb2..a105efc2685 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -8,6 +8,26 @@ from __future__ import annotations 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 78e16126542..ffe61b915c6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -24,6 +24,7 @@ FLOWS = { ], "integration": [ "abode", + "acaia", "accuweather", "acmeda", "adax", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 33a7d02776f..f007db87868 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 3b46bf19ae6..cdba146d251 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 b27979b23f2..39fb7f17d80 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 00000000000..f4eaa39e615 --- /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 00000000000..1dc6ff31051 --- /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 00000000000..7e2624923af --- /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 00000000000..1cc3d8dbbc0 --- /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 00000000000..62eb8b61b8a --- /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 00000000000..2bf4b1dbe8a --- /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 00000000000..8ad988d3b9b --- /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 74/81] 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 27f911822b5..eb26ef48e4e 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -20,7 +20,8 @@ from homeassistant.const import ( 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 @@ from .helpers import ( 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 611a7353bcd..92f5863c47e 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -429,3 +429,11 @@ SERVICES = ( (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 75/81] 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 8fd34a357c0..e204463695e 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 57188aebaa3..f1225f74f06 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 76/81] 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 10fb20bb2ce..6bf374087a6 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 cdba146d251..65ef5f1ebf2 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 39fb7f17d80..b61e65f3c68 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 77/81] 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 86c555ec151..84b27161edd 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -21,6 +21,7 @@ from .models import Eq3Config, Eq3ConfigEntryData 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 64bc1cf497c..78292940e60 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -24,6 +24,11 @@ ENTITY_KEY_WINDOW = "window" 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 @@ DEFAULT_SCAN_INTERVAL = 10 # seconds 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 fb0862f14bc..e6eb7532f37 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 8ea0955dbdd..858465effa8 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 00000000000..2e069180fa3 --- /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 03c3b21b964..acfd5082f45 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 78/81] 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 188459b4f16..341a300a26e 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 79/81] 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 5535ec3b976..363d39a2e63 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -26,7 +26,12 @@ from homeassistant.config_entries import ( ) 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 80/81] 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 dcfb6685627..1034223051c 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 81/81] 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 7e2624923af..cd91ca1a17a 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 62eb8b61b8a..f68f85e253d 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